示例:PyUnicodeObject初始化過程
那么關(guān)于“”這個unicode字符的是一個對應的PyUnicodeObject的內(nèi)存模型和PyASCIIObject和PyCompactUnicodeObject有一些差別,以“”字符串為例距潘,我們快速瀏覽一下PyUnicodeObject的初始化過程镰禾。
如果字符串嚴格由Latin-1范圍內(nèi)的字符組成,則Python將占用盡可能少的空間,并完全使用1字節(jié)的字符對象惶傻。 但是嚼摩,只要該字符串包含UCS-2字符,就必須將所有其他字符也轉(zhuǎn)換為占用2個字節(jié)阅签。同理, 如果字符包含UCS-4字符掐暮,那么字節(jié)序列中的所有字符都轉(zhuǎn)換為4字節(jié)。
如前一篇所述政钟,第一次調(diào)用PyUnicode_New函數(shù)路克,CPython總是假定被字符串是一個ASCII字符串,因此下圖分配以一個字節(jié)的位寬來給字符串分配內(nèi)存
因此第一次從PyUnicode_New返回給unicoce_decode_utf8函數(shù)一個PyASCIIObject的實例锥涕,如下圖所示
接著衷戈,unicoce_decode_utf8函數(shù)將該PyASCIIObject實例傳遞給ascii_decode函數(shù),
這里需要注意的是PyUnicode_1BYTE_DATA該宏函數(shù)獲取的是PyASCIIObject實例中有效負載部分的首個字節(jié)的內(nèi)存地址层坠,我們這里可以分析一下PyUnicode_1BYTE_DATA這個宏函數(shù)的行為,
在編譯時,按照如下圖的執(zhí)行順序最終等價于,在本示例中獲取PyASCIIObject對象的有效負載的地址是2870023376
(Py_UCS*)((PyASCIIObject*)(op) + 1)
話說回來,在ascii_decode函數(shù)的上下文殖妇,start參數(shù)持有C級別字符串首個字節(jié)的地址(本示例假定2869856865),end參數(shù)持有C級別字符串的尾指針(假定是2869856869)破花,*dest參數(shù)持有PyASCIIObject對象的有效負載的地址2870023376 谦趣,根據(jù)下圖的執(zhí)行軌跡,ascii_decode函數(shù)沒有對PyASCIIObject的有效負載部分做任何操作座每。并且返回一個0的指針偏移量前鹅。
在ascii_decode函數(shù)返回后,在當前unicode_decode_utf8函數(shù)的上下文峭梳,變量s持有仍然是指向C級別utf-8字節(jié)序列的首個字節(jié)舰绘,end指針仍然指向的是C級別utf-8字節(jié)序列的末端字節(jié)。如果你了解之前
asciilib_utf8_decode函數(shù)從unicode_decode_utf8函數(shù)獲取如下參數(shù)葱椭,參數(shù)inptr是二級指針捂寿,它使得事實上可以偏移C級別utf8字節(jié)序列的指針s,end參數(shù)指向C級別utf8字節(jié)序列的末端字節(jié)。參數(shù)outpos實際上修改_PyUnicodeWriter的pos字段的值
根據(jù)上圖的執(zhí)行軌跡孵运,在執(zhí)行到第三個紅框變量最終得到ch的值是128013秦陋,剛好正是“”unicode的十進制編碼,并且s指針已經(jīng)位移到4個字節(jié)已經(jīng)到達字節(jié)序列的末端治笨。內(nèi)存狀態(tài)圖如下
解碼后的ch值會返回給unicode_decode_utf8函數(shù)驳概,按照如下圖的執(zhí)行軌跡,ch=128013傳遞給PyUnicodeWriter_WriteCharInline函數(shù)赤嚼。
事實上整個PyUnicodeWriter_WriteCharInline的核心代碼,就是_PyUnicodeWriter_PrepareInternal函數(shù)顺又,執(zhí)行本示例時更卒,傳入該函數(shù)的參數(shù)length=1,參數(shù)maxchar就是128013
根據(jù)_PyUnicodeWriter_PrepareInternal函數(shù)上下文的執(zhí)行軌跡,再次調(diào)用PyUnicode_New函數(shù),傳入的參數(shù)size=4,maxchar=128013
查看上圖的執(zhí)行軌跡待榔,顯然計算分配內(nèi)存的尺寸
- struct_size表示PyCompactUnicode的頭部尺寸逞壁,本示例是74字節(jié)
- (size+1)*char_size表示有效負載,本示例20字節(jié)
下圖是PyUnicode_New函數(shù)下半部分代碼的執(zhí)行軌跡锐锣。這里值得一提的是第二個紅框的代碼塊
- unicode+1這個表達式從PyCompactUnicodeObject的首個字節(jié)指向腌闯,有效負載的首個字節(jié),PyCompactUnicodeObject頭部和有效負載的地址邊界雕憔。
- PyUnicode_LENGTH宏用于修改PyCompactUnicode對象的length字段
- PyUnicode_HASH宏用于修改PyCompactUnicode對象的hash字段
- PyUnicode_STATE宏用于獲取PyCompactUnicode對象的state字段,state是PyASCIIObject的內(nèi)部類姿骏,并且修改其內(nèi)部類的屬性。
當PyUnicode_New函數(shù)返回_PyUnicodeWriter_PrepareInternal函數(shù)時斤彼,堆內(nèi)存中已經(jīng)存在兩個字符串對象分瘦,一個是PyASCIIObject實例,一個是PyCompactUnicodeObject實例琉苇。注意這個PyCompactUnicodeObject有些怪異嘲玫,你發(fā)現(xiàn)了嗎?
就是其kind字段為4并扇,表示該PyCompactUnicodeObject會進一步衍生為PyUnicodeObject對象去团。在以上內(nèi)存圖可知由于writer->pos=0,那么_PyUnicode_FastCopyCharacters函數(shù)內(nèi)部調(diào)用_copy_character函數(shù)什么事情都沒做會馬上返回_PyUnicodeWriter_PrepareInternal函數(shù)。
這里我們將注意力放到Py_SETREF這個宏函數(shù)穷蛹,它將PyCompactUnicodeObject實例的內(nèi)存地址綁定到writer->buffer并且將舊的PyASCIIObject執(zhí)行內(nèi)存釋放
那么_PyUnicodeWriter對象它托管了新的PyCompactUnicodeObject實例土陪,writer的字段kind和PyCompactUnicodeObject對象的kind字段信息顯然是不對稱的嘛~
那么_PyUnicodeWriter_PrepareInternal函數(shù)會繼續(xù)調(diào)用PyUnicodeWriter_Update函數(shù)刷新_PyUnicodeWriter對象。
_PyUnicodeWriter_Prepare調(diào)用_PyUnicodeWriter_PrepareInternal函數(shù)已經(jīng)返回0肴熏,那么理所當然就調(diào)用PyUnicode_WRITE這個宏函數(shù)
當執(zhí)行完_PyUnicodeWriter_WriteCharInline函數(shù)后鬼雀,返回到unicode_decode_utf8函數(shù)后,PyCompactUnicodeObject的內(nèi)存圖如下圖所示蛙吏。
這個內(nèi)存圖還是有些詭異是吧~的確源哩,因為unicode_decode_utf8函數(shù)值到目前為止,仍然是以2字節(jié)位寬的模式來構(gòu)建一個PyCompactUnicodeObject內(nèi)存實體鸦做,而從PyUnicodeWriter對象的字段信息和PyCompactUnicodeObject的state字段看來信息是不對稱的璧疗。還有當前
所以下一步會調(diào)用_PyUnicodeWriter_Finish函數(shù)做進一步處理。
unicode_decode_utf8函數(shù)退出內(nèi)置while循環(huán)馁龟,安裝上下文的代碼順序執(zhí)行End分支區(qū)塊內(nèi)的代碼,我們這里主要關(guān)注_PyUnicodeWriter_Finish函數(shù)
當字符串對象和的length字段和_PyUnicodeWriter對象的pos字段不一致時漆魔,_PyUnicodeWriter_Finish函數(shù)主要調(diào)用函數(shù)resize_compact函數(shù)對對應的字符串對象嘗試內(nèi)存重分配坷檩。
在resize_compact函數(shù)中上下文
- 參數(shù)unicode是對PyCompactUnicodeObject對象的引用
- 參數(shù)length持有對_PyUnicodeWriter_Finish函數(shù)傳遞的writer->pos=1
從代碼的執(zhí)行軌跡來看却音,本示例resize_compact函數(shù)調(diào)用PyObject_REALLOC函數(shù),
我們重點理解一下重新計算分配內(nèi)存的細節(jié)矢炼。struct_size是PyCompactUnicodeObject的頭部尺寸72字節(jié)系瓢,而后面是(length+1)*char_size是什么東東呢?也就是請好好回憶一下PyUnicodeObject的結(jié)構(gòu)體定義
typedef struct {
PyCompactUnicodeObject _base; //72字節(jié)
union {
void *any;
Py_UCS1 *latin1;
Py_UCS2 *ucs2;
Py_UCS4 *ucs4;
} data; /* Canonical, smallest-form Unicode buffer */
} PyUnicodeObject;
Ok,PyUnicodeObject的頭部實際上是PyCompactUnicodeObject的頭部+聯(lián)合體data句灌,我們知道length=1夷陋,(length+1)*char_size=2×4=8字節(jié)。也就是聯(lián)合體data僅在本示例是8個字胰锌∑疲可能有人會問“不是聯(lián)合體內(nèi)所有字段的尺寸在對齊后的總內(nèi)存嗎?”如果你這種錯誤的理解资昧,表示你對union這個數(shù)據(jù)類型的內(nèi)存模型不了解酬土。
事實上,由于聯(lián)合體內(nèi)有void*指針的存在,由于void*指針表明它可以指向任意字寬的字節(jié)序列格带,在CPython的PyUnicodeObject實例化過程中撤缴,union的內(nèi)存尺寸取決于聯(lián)合體內(nèi)部成員中最大類型尺寸的某一個成員。也就是當多個數(shù)據(jù)成員每次只能取其一因此聯(lián)合體的每一項元素起始地址都一樣叽唱,都跟聯(lián)合體 union 的地址偏移量為0屈呕;
那可能有人挑刺了:“你憑什么說該該內(nèi)存分配就一定是PyUnicodeObject嗎?通篇代碼都沒有顯式聲明PyUnicodeObject*類型的內(nèi)存分配代碼肮淄ぁ虎眨!”,拜托!對此類無知的問題我是不屑一顧的侦铜,反問一下自己比PyCompactUnicode的類型尺寸還大的類型专甩,在CPython3.3+實現(xiàn)中,除了PyUnicodeObject之外钉稍,還有額外的字符串類型嗎5佣恪!
從聯(lián)合體data的void指針成員贡未,也看得出即便將來有比4字節(jié)編碼更大的編碼類型出現(xiàn)种樱,PyUnicodeObject對象可以兼容任意字寬的編碼類型的字節(jié)序列,因為有void*指針配合kind字段就能解碼任意字節(jié)序列俊卤,當然這是理論上的嫩挤。
性能問題
到目前為止,我介紹了PyASCIIObject消恍、PyCompactUnicodeObject岂昭、PyUnicodeObject的初始化過程。已經(jīng)知道
- 單個ASCII字節(jié)會優(yōu)先緩存在unicode_latin1全局靜態(tài)字符數(shù)組中狠怨。
- PyASCIIObject初始化最多就涉及一次malloc函數(shù)的調(diào)用约啊。
- PyCompactUnicodeObject的初始化涉及2次malloc函數(shù)調(diào)用邑遏。
- PyUnicodeObject的初始化涉及3次malloc函數(shù)調(diào)用
然而這一切CPython都無法事先預知的,而是在遍歷C級別字節(jié)序列對帶有明顯特征的字節(jié)進行檢測時才確定哪一種適合當前傳入的C級別字節(jié)序列的初始化方案恰矩。CPython在字符串初始化的起始階段采取的邏輯是先一刀切地假定是PyASCIIObject方案记盒,若檢測字節(jié)特征不符合PyASCIIObject初始化方案,再選擇PyCompactUnicodeObject初始化方案(第2次調(diào)用malloc),若后續(xù)遍歷字節(jié)序列外傅,檢測到不符合2字節(jié)位寬的字節(jié)特征碼纪吮,CPython會最后選擇PyUnicodeObject的初始化方案(第3次調(diào)用malloc)。
CPython之所以這樣做的目地是為了最大限度地節(jié)省內(nèi)存萎胰。但犧牲的是時間效率碾盟,對于CPython的內(nèi)部而言,即便初始化一個4字節(jié)位寬的字符串也要經(jīng)歷兩個嵌套在一起while內(nèi)外循環(huán)為主體函數(shù)調(diào)用奥洼,它們一般情況下是O(n)巷疼,對于復雜的字節(jié)序列包含拉丁字符,中文字或一些unicode編碼靠后的字符的字符序列灵奖,那么這是最壞的情況是O(n^2)嚼沿。因此對于密集性的字符串i/o必然需要大量字符串初始化的操作。因此你不要告訴我還有字符串駐留這一特性瓷患,現(xiàn)實中這特性是于事無補的骡尽。原生的Python代碼寫的字符串I/O處理代碼不論在內(nèi)存開銷還是時間開銷都不是字符串密集I/O應用場景的最佳選擇。
曾幾何時我跟某些Python程序員辯論到這一問題,有人就反駁我:“既然你都說的CPython那么不堪了擅编,拉倒吧~還用它干什么呢攀细!”,首先我們要辯證地正視問題爱态,那解決方案有嗎谭贪?Sure,it is Cython,Cython下的語境是C級別下的字符串,當然你也可以調(diào)用內(nèi)置C++標準庫的string容器锦担。Cython下的字符串初始化的這些類似操作能夠直接在C底層完成俭识,我們稱為Python的后端。相反地洞渔,Python解釋器的內(nèi)部就稱為前端套媚。如果Python解析器需要讀取Cython處理的字符串就需要經(jīng)歷類似CPython內(nèi)部的初始化邏輯,性能就急劇下降磁椒。