楔子 從現(xiàn)在開始,我們將剖析虛擬機(jī)運(yùn)行字節(jié)碼的原理。前面說了,Python 解釋器可以分為兩部分:Python 編譯器和 Python 虛擬機(jī)。 編譯器將源代碼編譯成 PyCodeObject 對象之后,就由虛擬機(jī)接手整個(gè)工作。虛擬機(jī)會(huì)從 PyCodeObject 中讀取字節(jié)碼,并在當(dāng)前的上下文中執(zhí)行,直到所有的字節(jié)碼都被執(zhí)行完畢。 那么問題來了,既然源代碼在經(jīng)過編譯之后,字節(jié)碼指令以及靜態(tài)信息都存儲(chǔ)在 PyCodeObject 當(dāng)中,那么是不是意味著虛擬機(jī)就在 PyCodeObject 對象上進(jìn)行所有的動(dòng)作呢? 很明顯不是的,因?yàn)楸M管 PyCodeObject 包含了關(guān)鍵的字節(jié)碼指令以及靜態(tài)信息,但有一個(gè)東西是沒有包含、也不可能包含的,就是程序在運(yùn)行時(shí)的執(zhí)行環(huán)境,這個(gè)執(zhí)行環(huán)境在 Python 里面就是棧幀。 棧幀:虛擬機(jī)的執(zhí)行環(huán)境 那什么是棧幀呢?我們舉個(gè)例子。
上面的代碼當(dāng)中出現(xiàn)了兩個(gè) print(name),它們的字節(jié)碼指令相同,但執(zhí)行的效果卻顯然是不同的,這樣的結(jié)果正是執(zhí)行環(huán)境的不同所產(chǎn)生的。因?yàn)榄h(huán)境的不同,name 的值也不同。 因此同一個(gè)符號(hào)在不同環(huán)境中可能指向不同的類型、不同的值,必須在運(yùn)行時(shí)進(jìn)行動(dòng)態(tài)捕捉和維護(hù),這些信息不可能在 PyCodeObject 對象中被靜態(tài)存儲(chǔ)。 因此虛擬機(jī)并不是在 PyCodeObject 對象上執(zhí)行操作的,而是在棧幀對象上。虛擬機(jī)在執(zhí)行時(shí),會(huì)根據(jù) PyCodeObject 對象動(dòng)態(tài)創(chuàng)建出棧幀對象,然后在棧幀里面執(zhí)行字節(jié)碼。所以棧幀是虛擬機(jī)執(zhí)行的上下文,執(zhí)行時(shí)依賴的所有信息都存儲(chǔ)在棧幀中。 因此對于上面的代碼,我們可以大致描述一下流程:
虛擬機(jī)和操作系統(tǒng) 不難發(fā)現(xiàn),Python 虛擬機(jī)執(zhí)行字節(jié)碼這個(gè)過程,就是在模擬操作系統(tǒng)運(yùn)行可執(zhí)行文件。比如: 程序加載
內(nèi)存管理
指令執(zhí)行
資源管理
異常處理
我們簡單地畫一張示意圖,來看看在一臺(tái)普通的 x64 機(jī)器上,可執(zhí)行文件是以什么方式運(yùn)行的,在這里主要關(guān)注棧幀的變化。假設(shè)有三個(gè)函數(shù),函數(shù) f 調(diào)用了函數(shù) g,函數(shù) g 又調(diào)用了函數(shù) h。 首先 CPU 有兩個(gè)關(guān)鍵的寄存器,它們在函數(shù)調(diào)用和棧幀管理中扮演關(guān)鍵角色。 RSP(Stack Pointer):棧指針,指向當(dāng)前棧幀的頂部,或者說最后一個(gè)入棧的元素。因此隨著元素的入棧和出棧,RSP 會(huì)動(dòng)態(tài)變化。由于地址從棧底到棧頂是逐漸減小的,所以 RSP 會(huì)隨著數(shù)據(jù)入棧而減小,隨著數(shù)據(jù)出棧而增大。當(dāng)然不管 RSP 怎么變,它始終指向當(dāng)前棧的頂部。 RBP(Base Pointer):基指針,指向當(dāng)前棧幀的基址,它的作用是提供一個(gè)固定的參考點(diǎn),用于訪問當(dāng)前函數(shù)的局部變量和參數(shù)。當(dāng)新的幀被創(chuàng)建時(shí),它的基址會(huì)保存上一個(gè)幀的基址,并由 RBP 指向。 我們用一段 C 代碼來解釋一下。
當(dāng)執(zhí)行函數(shù) add 時(shí),那么當(dāng)前幀顯然就是函數(shù) add 的棧幀,而調(diào)用者的幀(上一級(jí)棧幀)顯然就是函數(shù) main 的棧幀。
當(dāng)執(zhí)行函數(shù) main 的時(shí)候,RSP 指向 main 棧幀的頂部,RBP 指向 main 棧幀的基址。然后在 main 里面又調(diào)用了函數(shù) add,那么毫無疑問,系統(tǒng)會(huì)在地址空間中,在 main 的棧幀之上為 add 創(chuàng)建棧幀。然后讓 RSP 指向 add 棧幀的頂部,RBP 指向 add 棧幀的基址,而 add 棧幀的基址保存了上一級(jí)棧幀(main 棧幀)的基址。 當(dāng)函數(shù) add 執(zhí)行結(jié)束時(shí),會(huì)銷毀對應(yīng)棧幀,再將 RSP 和 RBP 恢復(fù)為創(chuàng)建 add 棧幀之前的值,這樣程序的執(zhí)行流程就又回到了函數(shù) main 里面,當(dāng)然程序的運(yùn)行空間也回到了函數(shù) main 的棧幀中。 不難發(fā)現(xiàn),通過兩個(gè) CPU 寄存器 RSP、RBP,以及棧幀中保存的上一級(jí)棧幀的基址,完美地維護(hù)了函數(shù)之間的調(diào)用鏈,這就是可執(zhí)行文件在 x64 機(jī)器上的運(yùn)行原理。 那么 Python 里面的棧幀是怎樣的呢? 棧幀的底層結(jié)構(gòu) 相較于 x64 機(jī)器上看到的那個(gè)簡簡單單的棧幀,Python 的棧幀實(shí)際上包含了更多的信息。注:棧幀也是一個(gè)對象。
棧幀在底層由 PyFrameObject 表示,在 3.11 之前,所有字段都保存在該結(jié)構(gòu)體中。但里面有一部分字段,在大部分情況下都用不到,比如一些用于 Debug 的字段。而這些不常用的字段,顯然會(huì)導(dǎo)致內(nèi)存浪費(fèi),因?yàn)閯?chuàng)建棧幀時(shí)要為所有字段都申請內(nèi)存空間。 于是從 3.11 開始,虛擬機(jī)將 PyFrameObject 里面的核心字段提取出來,形成了更加輕量級(jí)的 _PyInterpreterFrame,從而減少內(nèi)存使用并提高性能。
通過這種拆分,虛擬機(jī)在大多數(shù)情況下只需使用輕量級(jí)的 _PyInterpreterFrame 即可,只有在需要完整的幀信息時(shí),才會(huì)創(chuàng)建 PyFrameObject。 但要強(qiáng)調(diào)的是,由于 _PyInterpreterFrame 里面沒有 PyObject,所以它不是 Python 對象,它只是包含了棧幀的核心結(jié)構(gòu),真正的棧幀對象仍是 PyFrameObject。只不過對于虛擬機(jī)而言,很多時(shí)候只需實(shí)例化 _PyInterpreterFrame 結(jié)構(gòu)體,即可完成任務(wù)。 另外 _PyInterpreterFrame 除了更輕量、結(jié)構(gòu)更緊湊、創(chuàng)建速度快之外,它對 CPU 緩存也非常友好。 我們知道 Python 對象都是申請?jiān)诙焉系模瑮膊焕?,?dāng)調(diào)用嵌套函數(shù)時(shí),這些棧幀對象會(huì)零散在堆區(qū)的不同位置,對緩存不友好。但 _PyInterpreterFrame 則不是這樣,虛擬機(jī)為它專門引入了一個(gè) Stack,這是一段預(yù)分配的內(nèi)存區(qū)域,專門用于存儲(chǔ) _PyInterpreterFrame 實(shí)例。 當(dāng)需要?jiǎng)?chuàng)建 _PyInterpreterFrame 實(shí)例時(shí),只需要改動(dòng)一下棧指針,內(nèi)存便創(chuàng)建好了。當(dāng)需要銷毀時(shí),直接將它從棧的頂端彈出即可,不需要顯式地釋放內(nèi)存。并且由于 _PyInterpreterFrame 都是緊密排列在一起,所以對緩存也更加友好。 字段含義解析與代碼演示 下面來看一下這兩個(gè)結(jié)構(gòu)體里面的字段都表示啥含義,不過在解釋字段含義之前,我們需要先知道如何在 Python 中獲取棧幀對象。
我們看到棧幀的類型是 <class 'frame'>,正如 PyCodeObject 對象的類型是 <class 'code'> 一樣,這兩個(gè)類沒有暴露給我們,所以不可以直接使用。 同理,還有 Python 的函數(shù),類型是 <class 'function'>,模塊的類型是 <class 'module'>。這些解釋器都沒有給我們提供,如果直接使用的話,那么 frame、code、function、module 只是幾個(gè)沒有定義的變量罷了,這些類我們只能通過這種間接的方式獲取。 下面我們來看一下 PyFrameObject 里面每個(gè)字段的含義。 PyObject_HEAD 對象的頭部信息,所以棧幀也是一個(gè)對象。 PyFrameObject *f_back 當(dāng)前棧幀的上一級(jí)棧幀,也就是調(diào)用者的棧幀。所以 x64 機(jī)器是通過 RSP、RBP 兩個(gè)指針維護(hù)函數(shù)的調(diào)用關(guān)系,而 Python 虛擬機(jī)則是通過棧幀的 f_back 字段。
所以通過棧幀,你可以輕松地獲取完整的函數(shù)調(diào)用鏈路,我們一會(huì)兒演示。 struct _PyInterpreterFrame *f_frame 指向 struct _PyInterpreterFrame 實(shí)例,它包含了棧幀的核心結(jié)構(gòu)。 PyObject *f_trace 追蹤函數(shù),用于調(diào)試。 int f_lineno 獲取該棧幀時(shí)的源代碼行號(hào)。
我們是在第 4 行獲取的棧幀,所以打印結(jié)果是 4。 char f_trace_lines 是否為每一行代碼調(diào)用追蹤函數(shù),當(dāng)設(shè)置為真(非零值)時(shí),每當(dāng)虛擬機(jī)執(zhí)行到一個(gè)新的代碼行時(shí),都會(huì)調(diào)用追蹤函數(shù)。這允許調(diào)試器在每行代碼執(zhí)行時(shí)進(jìn)行干預(yù),比如設(shè)置斷點(diǎn)、檢查變量等。 char f_trace_opcodes 是否為每個(gè)字節(jié)碼指令調(diào)用追蹤函數(shù),當(dāng)設(shè)置為真時(shí),虛擬機(jī)會(huì)在執(zhí)行每個(gè)字節(jié)碼指令之前調(diào)用追蹤函數(shù)。這提供了更細(xì)粒度的控制,允許進(jìn)行指令級(jí)別的調(diào)試。 所以不難發(fā)現(xiàn),f_trace_lines 是行級(jí)追蹤,對應(yīng)源代碼的每一行,通常用于普通的調(diào)試,如設(shè)置斷點(diǎn)、單步執(zhí)行等,并且開銷相對較小。f_trace_opcodes 是指令級(jí)追蹤,對應(yīng)每個(gè)字節(jié)碼指令,通常用于更深層次的調(diào)試,比如分析具體的字節(jié)碼執(zhí)行過程,并且開銷較大。
設(shè)置追蹤函數(shù)一般需要通過 sys.settrace,不過不常用,了解一下即可。 char f_fast_as_locals 要解釋這個(gè)字段,需要用到后續(xù)的知識(shí),所以這里先簡單了解一下即可。Python 函數(shù)的局部變量是采用數(shù)組存儲(chǔ)的,以便快速訪問,這就是所謂的 fast locals。 但有時(shí)候我們就是需要一個(gè)字典,里面包含所有的局部變量,這時(shí)候可以調(diào)用 locals 函數(shù),將局部變量的名稱和值以 key、value 的形式拷貝到字典中。而 f_fast_as_locals 字段則負(fù)責(zé)標(biāo)記這個(gè)拷貝過程是否發(fā)生過。 然后再來看看 _PyInterpreterFrame 結(jié)構(gòu)體里面的字段,我們說棧幀的核心字段都在該結(jié)構(gòu)體中。 PyCodeObject *f_code 棧幀對象是在 PyCodeObject 之上構(gòu)建的,所以它內(nèi)部一定有一個(gè)字段指向 PyCodeObject。
模塊 -> f -> g -> h,顯然我們獲取了整個(gè)調(diào)用鏈路,是不是很有趣呢? struct _PyInterpreterFrame *previous 指向上一個(gè) struct _PyInterpreterFrame,該字段底層沒有暴露出來。 PyObject *f_funcobj 指向?qū)?yīng)的函數(shù)對象,該字段解釋器沒有暴露出來。 PyObject *f_globals 指向全局名字空間(一個(gè)字典),它是全局變量的容身之所。是的,Python 的全局變量是通過字典存儲(chǔ)的,調(diào)用函數(shù) globals 即可拿到該字典。
關(guān)于名字空間,我們后面會(huì)用專門的篇幅詳細(xì)說明。 PyObject *f_locals 指向局部名字空間(一個(gè)字典),但和全局變量不同,局部變量不存在局部名字空間中,而是靜態(tài)存儲(chǔ)在數(shù)組中。該字段先有個(gè)印象,后續(xù)再詳細(xì)說。 PyObject *f_builtins 指向內(nèi)建名字空間(一個(gè)字典),顯然一些內(nèi)置的變量都存在里面。
和我們直接使用 list("abcd") 是等價(jià)的。 PyFrameObject *frame_obj 這個(gè)不用多說,負(fù)責(zé)指向 PyFrameObject 對象。 _Py_CODEUNIT *prev_instr 指向上一條已執(zhí)行完畢的字節(jié)碼指令,比如虛擬機(jī)要執(zhí)行第 n 條指令,那么 prev_instr 便指向第 n - 1 條指令。由于每個(gè)指令都帶有一個(gè)參數(shù),所以 _Py_CODEUNIT 類型的大小是 2 字節(jié)。 int stacktop 表示棧頂相對于 localsplus 數(shù)組的偏移量。 uint16_t return_offset 表示 RETURN 指令相對 prev_instr 的偏移量,這個(gè)值只對被調(diào)用的函數(shù)有意義,它指示了函數(shù)返回后,調(diào)用者應(yīng)該從哪里繼續(xù)執(zhí)行。它會(huì)在 這個(gè)設(shè)計(jì)允許更高效的函數(shù)返回處理,因?yàn)樘摂M機(jī)可以直接跳轉(zhuǎn)到正確的位置,而不需要額外的查找或計(jì)算。
當(dāng)調(diào)用 some_func 時(shí),虛擬機(jī)會(huì)執(zhí)行 CALL 指令,在 CALL 指令中,會(huì)設(shè)置 return_offset。當(dāng)執(zhí)行完 some_func 的 RETURN 指令時(shí),它會(huì)使用 return_offset 來決定跳轉(zhuǎn)到調(diào)用者(main)中的哪個(gè)位置。 這種機(jī)制的優(yōu)點(diǎn)是不需要在運(yùn)行時(shí)計(jì)算返回位置,因?yàn)樗呀?jīng)在調(diào)用時(shí)預(yù)先計(jì)算好了,特別適用于處理生成器和協(xié)程等復(fù)雜控制流。 char owner 表示幀的所有權(quán)信息,用于區(qū)分幀是在虛擬機(jī)棧上的,還是單獨(dú)分配的。 PyObject *localsplus[1] 一個(gè)柔性數(shù)組,負(fù)責(zé)維護(hù) "局部變量 + cell 變量 + free 變量 + 運(yùn)行時(shí)棧",大小在運(yùn)行時(shí)確定。 以上就是棧幀內(nèi)部的字段,這些字段先有個(gè)印象,后續(xù)在剖析虛擬機(jī)的時(shí)候還會(huì)繼續(xù)細(xì)說。 總之我們看到,PyCodeObject 并不是虛擬機(jī)的最終目標(biāo),虛擬機(jī)最終是在棧幀中執(zhí)行的。每一個(gè)棧幀都會(huì)維護(hù)一個(gè) PyCodeObject 對象,換句話說,每一個(gè) PyCodeObject 對象都會(huì)隸屬于一個(gè)棧幀。并且從 f_back 可以看出,虛擬機(jī)在實(shí)際執(zhí)行時(shí),會(huì)產(chǎn)生很多的棧幀對象,而這些對象會(huì)被鏈接起來,形成一條執(zhí)行環(huán)境鏈表,或者說棧幀鏈表。 而這正是 x64 機(jī)器上棧幀之間關(guān)系的模擬,在 x64 機(jī)器上,棧幀之間通過 RSP 和 RBP 指針建立了聯(lián)系,使得新棧幀在結(jié)束之后能夠順利地返回到舊棧幀中,而 Python 虛擬機(jī)則是利用 f_back 來完成這個(gè)動(dòng)作。 當(dāng)然,獲取棧幀除了通過 inspect 模塊之外,在捕獲異常時(shí),也可以獲取棧幀。
關(guān)于棧幀內(nèi)部的字段的含義,我們就說完了。當(dāng)然如果有些字段現(xiàn)在不是很理解,也沒關(guān)系,隨著不斷地學(xué)習(xí),你會(huì)豁然開朗。 小結(jié) 因?yàn)楹芏鄤?dòng)態(tài)信息無法靜態(tài)地存儲(chǔ)在 PyCodeObject 對象中,所以 PyCodeObject 對象在交給虛擬機(jī)之后,虛擬機(jī)會(huì)在其之上動(dòng)態(tài)地構(gòu)建出 PyFrameObject 對象,也就是棧幀。 因此虛擬機(jī)是在棧幀里面執(zhí)行的字節(jié)碼,它包含了虛擬機(jī)在執(zhí)行字節(jié)碼時(shí)依賴的全部信息。 |
|