第5篇:CPython内部探究:PyUnicodeObject的实例化

示例:PyUnicodeObject初始化过程

那么关于“”这个unicode字符的是一个对应的PyUnicodeObject的内存模型和PyASCIIObject和PyCompactUnicodeObject有一些差别,以“”字符串为例,我们快速浏览一下PyUnicodeObject的初始化过程。

如果字符串严格由Latin-1范围内的字符组成,则Python将占用尽可能少的空间,并完全使用1字节的字符对象。 但是,只要该字符串包含UCS-2字符,就必须将所有其他字符也转换为占用2个字节。同理, 如果字符包含UCS-4字符,那么字节序列中的所有字符都转换为4字节。

如前一篇所述,第一次调用PyUnicode_New函数,CPython总是假定被字符串是一个ASCII字符串,因此下图分配以一个字节的位宽来给字符串分配内存

因此第一次从PyUnicode_New返回给unicoce_decode_utf8函数一个PyASCIIObject的实例,如下图所示

接着,unicoce_decode_utf8函数将该PyASCIIObject实例传递给ascii_decode函数,


这里需要注意的是PyUnicode_1BYTE_DATA该宏函数获取的是PyASCIIObject实例中有效负载部分的首个字节的内存地址,我们这里可以分析一下PyUnicode_1BYTE_DATA这个宏函数的行为,

在编译时,按照如下图的执行顺序最终等价于,在本示例中获取PyASCIIObject对象的有效负载的地址是2870023376

(Py_UCS*)((PyASCIIObject*)(op) + 1)

话说回来,在ascii_decode函数的上下文,start参数持有C级别字符串首个字节的地址(本示例假定2869856865),end参数持有C级别字符串的尾指针(假定是2869856869),*dest参数持有PyASCIIObject对象的有效负载的地址2870023376 ,根据下图的执行轨迹,ascii_decode函数没有对PyASCIIObject的有效负载部分做任何操作。并且返回一个0的指针偏移量。

在ascii_decode函数返回后,在当前unicode_decode_utf8函数的上下文,变量s持有仍然是指向C级别utf-8字节序列的首个字节,end指针仍然指向的是C级别utf-8字节序列的末端字节。如果你了解之前

asciilib_utf8_decode函数从unicode_decode_utf8函数获取如下参数,参数inptr是二级指针,它使得事实上可以偏移C级别utf8字节序列的指针s,end参数指向C级别utf8字节序列的末端字节。参数outpos实际上修改_PyUnicodeWriter的pos字段的值

根据上图的执行轨迹,在执行到第三个红框变量最终得到ch的值是128013,刚好正是“”unicode的十进制编码,并且s指针已经位移到4个字节已经到达字节序列的末端。内存状态图如下

解码后的ch值会返回给unicode_decode_utf8函数,按照如下图的执行轨迹,ch=128013传递给PyUnicodeWriter_WriteCharInline函数。

事实上整个PyUnicodeWriter_WriteCharInline的核心代码,就是_PyUnicodeWriter_PrepareInternal函数,执行本示例时,传入该函数的参数length=1,参数maxchar就是128013

根据_PyUnicodeWriter_PrepareInternal函数上下文的执行轨迹,再次调用PyUnicode_New函数,传入的参数size=4,maxchar=128013


查看上图的执行轨迹,显然计算分配内存的尺寸

  • struct_size表示PyCompactUnicode的头部尺寸,本示例是74字节
  • (size+1)*char_size表示有效负载,本示例20字节

下图是PyUnicode_New函数下半部分代码的执行轨迹。这里值得一提的是第二个红框的代码块

  • unicode+1这个表达式从PyCompactUnicodeObject的首个字节指向,有效负载的首个字节,PyCompactUnicodeObject头部和有效负载的地址边界。
  • PyUnicode_LENGTH宏用于修改PyCompactUnicode对象的length字段
  • PyUnicode_HASH宏用于修改PyCompactUnicode对象的hash字段
  • PyUnicode_STATE宏用于获取PyCompactUnicode对象的state字段,state是PyASCIIObject的内部类,并且修改其内部类的属性。

当PyUnicode_New函数返回_PyUnicodeWriter_PrepareInternal函数时,堆内存中已经存在两个字符串对象,一个是PyASCIIObject实例,一个是PyCompactUnicodeObject实例。注意这个PyCompactUnicodeObject有些怪异,你发现了吗?

就是其kind字段为4,表示该PyCompactUnicodeObject会进一步衍生为PyUnicodeObject对象。在以上内存图可知由于writer->pos=0,那么_PyUnicode_FastCopyCharacters函数内部调用_copy_character函数什么事情都没做会马上返回_PyUnicodeWriter_PrepareInternal函数。

这里我们将注意力放到Py_SETREF这个宏函数,它将PyCompactUnicodeObject实例的内存地址绑定到writer->buffer并且将旧的PyASCIIObject执行内存释放

那么_PyUnicodeWriter对象它托管了新的PyCompactUnicodeObject实例,writer的字段kind和PyCompactUnicodeObject对象的kind字段信息显然是不对称的嘛~

那么_PyUnicodeWriter_PrepareInternal函数会继续调用PyUnicodeWriter_Update函数刷新_PyUnicodeWriter对象。

_PyUnicodeWriter_Prepare调用_PyUnicodeWriter_PrepareInternal函数已经返回0,那么理所当然就调用PyUnicode_WRITE这个宏函数

当执行完_PyUnicodeWriter_WriteCharInline函数后,返回到unicode_decode_utf8函数后,PyCompactUnicodeObject的内存图如下图所示。

这个内存图还是有些诡异是吧~的确,因为unicode_decode_utf8函数值到目前为止,仍然是以2字节位宽的模式来构建一个PyCompactUnicodeObject内存实体,而从PyUnicodeWriter对象的字段信息和PyCompactUnicodeObject的state字段看来信息是不对称的。还有当前

所以下一步会调用_PyUnicodeWriter_Finish函数做进一步处理。

unicode_decode_utf8函数退出内置while循环,安装上下文的代码顺序执行End分支区块内的代码,我们这里主要关注_PyUnicodeWriter_Finish函数

当字符串对象和的length字段和_PyUnicodeWriter对象的pos字段不一致时,_PyUnicodeWriter_Finish函数主要调用函数resize_compact函数对对应的字符串对象尝试内存重分配。

在resize_compact函数中上下文

  • 参数unicode是对PyCompactUnicodeObject对象的引用
  • 参数length持有对_PyUnicodeWriter_Finish函数传递的writer->pos=1

从代码的执行轨迹来看,本示例resize_compact函数调用PyObject_REALLOC函数,

我们重点理解一下重新计算分配内存的细节。struct_size是PyCompactUnicodeObject的头部尺寸72字节,而后面是(length+1)*char_size是什么东东呢?也就是请好好回忆一下PyUnicodeObject的结构体定义

typedef struct {
    PyCompactUnicodeObject _base; //72字节
    union {
        void *any;
        Py_UCS1 *latin1;
        Py_UCS2 *ucs2;
        Py_UCS4 *ucs4;
    } data;                     /* Canonical, smallest-form Unicode buffer */
} PyUnicodeObject;

Ok,PyUnicodeObject的头部实际上是PyCompactUnicodeObject的头部+联合体data,我们知道length=1,(length+1)*char_size=2×4=8字节。也就是联合体data仅在本示例是8个字。可能有人会问“不是联合体内所有字段的尺寸在对齐后的总内存吗?”如果你这种错误的理解,表示你对union这个数据类型的内存模型不了解。

事实上,由于联合体内有void*指针的存在,由于void*指针表明它可以指向任意字宽的字节序列,在CPython的PyUnicodeObject实例化过程中,union的内存尺寸取决于联合体内部成员中最大类型尺寸的某一个成员。也就是当多个数据成员每次只能取其一因此联合体的每一项元素起始地址都一样,都跟联合体 union 的地址偏移量为0;

那可能有人挑刺了:“你凭什么说该该内存分配就一定是PyUnicodeObject吗?通篇代码都没有显式声明PyUnicodeObject*类型的内存分配代码啊!”,拜托!对此类无知的问题我是不屑一顾的,反问一下自己比PyCompactUnicode的类型尺寸还大的类型,在CPython3.3+实现中,除了PyUnicodeObject之外,还有额外的字符串类型吗!!

从联合体data的void指针成员,也看得出即便将来有比4字节编码更大的编码类型出现,PyUnicodeObject对象可以兼容任意字宽的编码类型的字节序列,因为有void*指针配合kind字段就能解码任意字节序列,当然这是理论上的。

性能问题

到目前为止,我介绍了PyASCIIObject、PyCompactUnicodeObject、PyUnicodeObject的初始化过程。已经知道

  • 单个ASCII字节会优先缓存在unicode_latin1全局静态字符数组中。
  • PyASCIIObject初始化最多就涉及一次malloc函数的调用。
  • PyCompactUnicodeObject的初始化涉及2次malloc函数调用。
  • PyUnicodeObject的初始化涉及3次malloc函数调用

然而这一切CPython都无法事先预知的,而是在遍历C级别字节序列对带有明显特征的字节进行检测时才确定哪一种适合当前传入的C级别字节序列的初始化方案。CPython在字符串初始化的起始阶段采取的逻辑是先一刀切地假定是PyASCIIObject方案,若检测字节特征不符合PyASCIIObject初始化方案,再选择PyCompactUnicodeObject初始化方案(第2次调用malloc),若后续遍历字节序列,检测到不符合2字节位宽的字节特征码,CPython会最后选择PyUnicodeObject的初始化方案(第3次调用malloc)。

CPython之所以这样做的目地是为了最大限度地节省内存。但牺牲的是时间效率,对于CPython的内部而言,即便初始化一个4字节位宽的字符串也要经历两个嵌套在一起while内外循环为主体函数调用,它们一般情况下是O(n),对于复杂的字节序列包含拉丁字符,中文字或一些unicode编码靠后的字符的字符序列,那么这是最坏的情况是O(n^2)。因此对于密集性的字符串i/o必然需要大量字符串初始化的操作。因此你不要告诉我还有字符串驻留这一特性,现实中这特性是于事无补的。原生的Python代码写的字符串I/O处理代码不论在内存开销还是时间开销都不是字符串密集I/O应用场景的最佳选择。

曾几何时我跟某些Python程序员辩论到这一问题,有人就反驳我:“既然你都说的CPython那么不堪了,拉倒吧~还用它干什么呢!”,首先我们要辩证地正视问题,那解决方案有吗?Sure,it is Cython,Cython下的语境是C级别下的字符串,当然你也可以调用内置C++标准库的string容器。Cython下的字符串初始化的这些类似操作能够直接在C底层完成,我们称为Python的后端。相反地,Python解释器的内部就称为前端。如果Python解析器需要读取Cython处理的字符串就需要经历类似CPython内部的初始化逻辑,性能就急剧下降。