第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内部的初始化逻辑,性能就急剧下降。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349