深入源碼,進一步考察字節碼的執行流程
源碼解析字節碼的執行過程
之前說了,虛擬機就是把自己當成一個 CPU,在棧幀中執行字節碼。面對不同的字節碼指令,執行不同的處理邏輯。
具體實現由 Python/ceval.c 中的 _PyEval_EvalFrameDefault 函數負責,該函數比較長,并且存在很多的宏,我們會進行適當的簡化。
圖片
_PyEval_EvalFrameDefault 函數的上方定義了一個宏,這里需要解釋一下。對于 CPython 而言,一個 Python 函數調用在底層會涉及多個 C 函數調用,而 C 函數在調用時顯然也要創建棧幀。
所以整個過程存在兩個調用棧,一個是 Python 調用棧,另一個是 C 調用棧。而調用一個 Python 函數,底層的 C 調用棧會消耗 3 個單元。因此不難發現,隨著 Python 調用棧在增長的同時,C 調用棧也在增長。
圖片
為此 CPython 引入了一個優化,對于遞歸函數而言,當執行新的遞歸調用時,C 調用棧不會再增長。換句話說,不會再次調用 _PyEval_EvalFrameDefault 函數,而是通過改變上下文,在同一個 _PyEval_EvalFrameDefault 里面重新開始。
好啦,下面我們就來分析一下這個長得跟巨無霸一樣的函數,當然在 3.12 里面它已經不像之前那么大了,因為有一部分代碼被拆分出來了。
// Python/ceval.c
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate,
_PyInterpreterFrame *frame,
int throwflag)
{
// 檢測線程狀態對象是否不為 NULL
// 如果為 NULL,說明 GIL 被釋放,而這是不允許的
// 字節碼指令必須在當前線程持有 GIL 的情況下執行
_Py_EnsureTstateNotNULL(tstate);
// _PyEval_EvalFrameDefault 的調用次數加 1
CALL_STAT_INC(pyeval_calls);
// 如果使用 "計算跳轉",導入靜態跳轉表
#if USE_COMPUTED_GOTOS
#include "opcode_targets.h"
#endif
// ...
}
由于該函數的代碼量較大,并且很多內容都需要花費一定筆墨去解釋,所以為了更加清晰,我們分段講解。先看上面這段代碼,里面出現了計算跳轉,需要解釋一下它是什么意思。
_PyEval_EvalFrameDefault(后續簡稱為幀評估函數)雖然很復雜,但它核心不難理解,就是循環遍歷字節碼指令集,處理每一條指令。而當一條指令執行完畢時,虛擬機會有以下三種動作之一:
- 停止循環、退出幀評估函數,當執行的指令為 RETURN_VALUE、YIELD_VALUE 等。
- 指令執行過程中出錯了,比如執行 GET_ITER 指令,但對象不具備可迭代的性質。執行出錯也要退出幀評估函數,然后執行異常處理邏輯(或者直接拋出異常)。
- 進入下一輪循環,執行下一條指令。
前面兩種動作沒啥好說的,關鍵是第三種,如何執行下一條指令。首先虛擬機內部有一個巨型的 switch 語句,偽代碼如下:
for (;;) {
// 循環遍歷指令集,獲取指令和指令參數
uint8_t opcode = ...; // 指令
int oparg = ...; // 指令參數
// 執行對應的處理邏輯
switch (opcode) {
case LOAD_CONST:
處理邏輯;
case LOAD_FAST:
處理邏輯;
case LOAD_FAST:
處理邏輯;
case BUILD_LIST:
處理邏輯;
case DICT_UPDATE:
處理邏輯;
// ...
}
}
一個 case 分支,對應一個字節碼指令的實現,由于指令非常多,所以這個 switch 語句也非常龐大。然后遍歷出的指令,會進入這個 switch 語句進行匹配,執行相應的處理邏輯。
所以循環遍歷 co_code 得到字節碼指令,然后交給內部的 switch 語句、執行匹配到的 case 分支,如此周而復始,最終完成了對整個 Python 程序的執行。
其實到這里,你應該已經了解了幀評估函數的整體結構。說白了就是將自己當成一個 CPU,在棧幀中執行一條條指令,而執行過程中所依賴的常量、變量等,則由棧幀的其它字段來維護。因此在虛擬機的執行流程進入了那個巨大的 for 循環,并取出第一條字節碼指令交給里面的 switch 語句之后,第一張多米諾骨牌就已經被推倒,命運不可阻擋的降臨了。一條接一條的指令如同潮水般涌來,浩浩蕩蕩,橫無際涯。
雖然在概念上很好理解,但很多細節被忽略掉了,本篇文章就將它們深挖出來。還是之前的問題,當一個指令執行完畢時,怎么執行下一條指令。
估計有人對這個問題感到奇怪,在 case 分支的內部加一行 continue 進行下一輪循環不就行了嗎?沒錯,這種做法是行得通的,但存在性能問題。因為 continue 會跳轉到 for 循環所在位置,所以遍歷出下一條指令之后,會再次進入 switch 語句進行匹配。盡管邏輯上是正確的,但 switch 里面有數百個 case 分支,如果每來一個指令,都要順序匹配一遍的話,那么效率必然不高。
而事實上整個字節碼指令集是已知的,所以不管執行哪個指令,我們都可以提前得知它的下一個指令,只需將指針向后偏移兩個字節即可。
圖片
那么問題來了,既然知道下一條要執行的指令是什么,那么在當前指令執行完畢時,可不可以直接跳轉到下一條指令對應的 case 分支中呢?
答案是可以的,這個過程就叫做計算跳轉,通過標簽作為值即可實現。關于什么是標簽作為值,我們用一段 C 代碼解釋一下。
#include <stdio.h>
void label_as_value(int jump) {
int num = 4;
void *label;
switch (num) {
case 1:
printf("%d\n", 1);
break;
// 在 case 2 分支里面定義了一個標簽叫 two
case 2: two: {
printf("%d\n", 2);
break;
}
// 在 case 3 分支里面定義了一個標簽叫 three
case 3: three: {
printf("%d\n", 3);
break;
}
case 4:
printf("%d\n", 4);
// 如果參數 jump 等于 2,保存 two 標簽的地址
// 如果參數 jump 等于 3,保存 three 標簽的地址
if (jump == 2) label = &&two;
else if (jump == 3) label = &&three;
// 跳轉到指定標簽
goto *label;
default:
break;
}
}
int main() {
label_as_value(2);
// 4
// 2
label_as_value(3);
// 4
// 3
}
由于變量 num 等于 4,所以會進入 case 4 分支,在里面有一個 goto *label。如果你對 C 不是特別熟悉的話,估計會有些奇怪,覺得不應該是 goto label 嗎?如果是 goto label,那么需要顯式地定義一個名為 label 的標簽,但這里并沒有。
我們的目的是跳轉到 two 標簽或 three 標簽,具體跳轉到哪一個,則由參數控制。因此可以使用 && 運算符,這是 GCC 的一個擴展特性,叫做標簽作為值,它允許我們獲取標簽的地址作為一個值。
所以在開頭聲明了一個 void *label,然后讓 label 保存標簽地址,再通過 goto *label 跳轉到指定標簽。由于 *label 代表哪個標簽是在運行時經過計算才能知曉,因此稱為計算跳轉(在運行時動態決定跳轉目標)。
注意:goto *&&標簽名 屬于高級的、非標準的 C 語言用法。
回到源碼中來,在 Python/generated_cases.c.h 文件里面,我們可以看到標簽的定義。
圖片
這個文件里面定義的便是每個指令的處理邏輯,總共 4800 行,之前內嵌在幀評估函數里面,現在單獨拆出來了。執行幀評估函數時,直接 #include 進來即可。
圖片
另外我們看到每個指令都調用了 TARGET,顯然這是一個宏,看一下它長什么樣子。
// Python/ceval_macros.h
#define INSTRUCTION_START(op) (frame->prev_instr = next_instr++)
#if USE_COMPUTED_GOTOS
# define TARGET(op) TARGET_##op: INSTRUCTION_START(op);
# define DISPATCH_GOTO() goto *opcode_targets[opcode]
#else
# define TARGET(op) case op: TARGET_##op: INSTRUCTION_START(op);
# define DISPATCH_GOTO() goto dispatch_opcode
#endif
如果將宏展開的話,那么幀評估函數里面的 switch 語句等價于如下。
圖片
如果不使用計算跳轉,那么展開之后就是一個 switch 語句,里面是每個指令的處理邏輯。而在邏輯的最后,會調用一個 DISPATCHER() 或 DISPATCH_GOTO()。
// Python/ceval_macros.h
// 會依次調用 3 個宏,中間第二個宏可以忽略掉
#define DISPATCH() \
{ \
NEXTOPARG(); \
PRE_DISPATCH_GOTO(); \
DISPATCH_GOTO(); \
}
/*
typedef union {
uint16_t cache;
struct {
uint8_t code;
uint8_t arg;
} op;
} _Py_CODEUNIT;
*/
// next_instr 指向下一條待執行的指令,prev_instr 指向最近一條執行完畢的指令
// 所以 prev_instr 的下一條指令就是 next_instr
// 由于在處理指令時,先執行了 frame->prev_instr = next_instr++;
// 所以調用 NEXTOPARG() 時,next_instr 已經指向了新的待執行的字節碼指令
// 這里獲取指令和指令參數
#define NEXTOPARG() do { \
_Py_CODEUNIT word = *next_instr; \
opcode = word.op.code; \
oparg = word.op.arg; \
} while (0)
// 跳轉
#if USE_COMPUTED_GOTOS
# define DISPATCH_GOTO() goto *opcode_targets[opcode]
#else
# define DISPATCH_GOTO() goto dispatch_opcode
#endif
因此每個指令在執行完畢后,會調用 DISPATCHER_GOTO(),如果沒使用計算跳轉,那么會直接 goto 到 dispatch_opcode 標簽,然后進入 switch。
所以在 CPython 3.12 版本中,switch 外層的 for 循環已經沒有了,使用的是 goto,在獲取下一條待執行的指令和參數之后,直接 goto 到 switch 語句所在的標簽(dispatch_opcode)。當然這和 for 循環本質上沒太大差異,因為在獲取到新的指令時,都要重新走一遍 switch。
以上是不使用計算跳轉,如果使用計算跳轉,從圖中可以看到是沒有 switch 語句的,當然 TARGET 展開之后也不會出現 case 分支。以 TARGET(LOAD_FAST) 為例:
- 不使用計算跳轉,展開之后是 case LOAD_FAST: TARGET_LOAD_FAST:
- 使用計算跳轉,展開之后是 TARGET_LOAD_FAST:
可以看到使用計算跳轉之后,留下的只是一堆標簽,當然此時有沒有 switch 已經無所謂了,重點是 DISPATCHER_GOTO() 之后可以直接跳轉到指定位置,不需要挨個比較了。而根據宏定義,最后會 goto 到 *opcode_targets[opcode],這個 opcode_targets 便是一開始導入的靜態跳轉表。
它定義在 Python/opcode_targets.h 中,我們看一下。
每個指令的處理邏輯會對應一個標簽,這些標簽的地址全部保存在了數組中,執行幀評估函數時導入進來即可。這里可能有人會問,導入數組時,它里面的標簽都還沒有定義吧。確實如此,不過沒關系,對于 C 來說,標簽只要定義了,那么它在函數的任何一個位置都可以使用。
變量 opcode 就是指令(一個 uint8 整數),而最后跳轉到了 *opcode_targets[opcode],那么我們有理由相信,指令和 opcode_targets 數組的索引之間存在某種關聯。
這種關聯也很好想,opcode_targets[opcode] 指向的標簽,其內部的邏輯就是用來處理 opcode 指令的,我們來驗證一下。
圖片
LOAD_CONST 的值是 100,那么 opcode_targets[100] 一定是 &&TARGET_LOAD_CONST。
圖片
結果沒有問題,其它指令也是一樣的,通過計算跳轉,可以直接 goto 到指定的標簽。
好,我們總結一下,早期的幀評估函數內部有一個巨型的 switch,每來一個指令都要順序匹配數百個 case 分支,找到符合條件的那一個。盡管這些 case 分支內部也定義了標簽,但對實現精確跳轉沒太大幫助。
而在 3.12 里面引入了計算跳轉,此時 switch 已經沒有了,只剩下了標簽。當然有沒有 switch 都無所謂,重點是每個標簽的地址被保存在了數組 opcode_targets 中,并且標簽在數組中的索引和對應的指令是相等的。
比如下一條要執行的指令是 YIELD_VALUE,它等于 150,那么 opcode_targets[150] 就等于 &&TARGET_YIELD_VALUE,指向的標簽內部便是 YIELD_VALUE 的處理邏輯,至于其它指令也是同理。
因此讀取完下一條指令之后,就不用像之前一樣跳轉到開頭重新走一遍 switch 了。而是將指令作為索引,從 opcode_targets 拿到標簽地址直接跳轉即可,并且跳轉后的標簽內部的邏輯就是用來處理該指令的。
所以底層為每個指令的處理邏輯都定義了一個標簽,而標簽的地址在數組中的索引,和處理的指令是相等的。
不過要想實現計算跳轉,需要 GCC 支持標簽作為值這一特性,即 goto *標簽地址,至于標簽地址是哪一個標簽的地址,則在運行時動態計算得出。比如 opcode_targets[opcode] 指向哪個標簽無從得知,這取決于 opcode 的值。
goto 標簽:靜態跳轉,標簽需要顯式地定義好,跳轉位置在編譯期間便已經固定。
goto *標簽地址:動態跳轉(計算跳轉),跳轉位置不固定,可以是已有標簽中的任意一個。至于具體是哪一個,需要在運行時經過計算才能確定。
關于計算跳轉我們就解釋完了,當然還解釋了很多其它內容,下面繼續看源碼。
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate,
_PyInterpreterFrame *frame,
int throwflag)
{
_Py_EnsureTstateNotNULL(tstate);
CALL_STAT_INC(pyeval_calls);
#if USE_COMPUTED_GOTOS
#include "opcode_targets.h"
#endif
uint8_t opcode; // 指令
int oparg; // 指令參數
/*
typedef struct _PyCFrame {
struct _PyInterpreterFrame *current_frame;
struct _PyCFrame *previous;
} _PyCFrame;
結構體位于 Include/cpython/pystate.h 中
*/
// _PyCFrame 相當于對 _PyInterpreterFrame 做了一層封裝
// 為了方便描述,我們也稱 _PyCFrame 實例為棧楨
// cframe 會在 C 語言的調用棧中傳遞,從而提高性能
_PyCFrame cframe;
// 入口幀
_PyInterpreterFrame entry_frame;
PyObject *kwnames = NULL;
// tstate 表示線程狀態對象,tstate->cframe 指向正在執行的棧楨
// 那么顯然要將 tstate->cframe 更新為 &cframe
// 不過更新之前,要先將目前的 tstate->cframe 保存起來
// 因為進入到幀評估函數中,說明開啟了新的幀,也意味著它成為了上一個幀
_PyCFrame *prev_cframe = tstate->cframe;
cframe.previous = prev_cframe; // 通過 previous 保存上一個幀
tstate->cframe = &cframe; // 指向當前幀
// 對入口幀內部的屬性初始化
entry_frame.f_code = tstate->interp->interpreter_trampoline;
entry_frame.prev_instr =
_PyCode_CODE(tstate->interp->interpreter_trampoline);
entry_frame.stacktop = 0;
entry_frame.owner = FRAME_OWNED_BY_CSTACK;
entry_frame.return_offset = 0;
/* Push frame */
entry_frame.previous = prev_cframe->current_frame;
// frame 是 _PyEval_Vector 內部創建的棧楨,也就是當前正在執行的幀
// _PyEval_Vector 創建完棧楨后,會將它作為參數,調用幀評估函數
// 而 frame->previous 同樣指向上一個 _PyInterpreterFrame
// 這里將它賦值為 &entry_frame,即入口幀
frame->previous = &entry_frame;
// cframe 和 frame 都可以理解為正在執行的棧楨
// 但前者對后者做了一層封裝,這里將 cframe.current_frame 賦值為 frame
cframe.current_frame = frame;
// 判斷還能否安全地進行遞歸調用
// 如果不能,就減少遞歸計數并跳轉到退出邏輯
tstate->c_recursion_remaining -= (PY_EVAL_C_STACK_UNITS - 1);
if (_Py_EnterRecursiveCallTstate(tstate, "")) {
tstate->c_recursion_remaining--;
tstate->py_recursion_remaining--;
goto exit_unwind;
}
// next_instr 剛才說過的,它指向下一條待執行的指令
// 通過不斷執行 frame->prev_instr = next_instr++
// 最終完成整個字節碼指令集的遍歷
_Py_CODEUNIT *next_instr;
// stack_pointer 指向運行時棧的棧頂
// 元素的入棧和出棧,都是通過操作 stack_pointer 實現的
PyObject **stack_pointer;
// 一個宏,負責初始化 next_instr 和 stack_pointer
// 但里面有一個 _PyInterpreterFrame_LASTI 函數需要說一下
// 我們說 prev_instr 指向上一條、或者最近一條執行完畢的字節碼指令
// 那么 prev_instr 的偏移量是多少呢?這個還是很簡單的
// 用 frame->prev_instr - _PyCode_CODE(f_code) 即可
// 而這個偏移量在以前的版本中,會有一個專門的棧楨字段(f_lasti)保存
#define SET_LOCALS_FROM_FRAME() \
assert(_PyInterpreterFrame_LASTI(frame) >= -1); \
next_instr = frame->prev_instr + 1; \
stack_pointer = _PyFrame_GetStackPointer(frame);
// 開始在棧楨中執行字節碼了,但還要做一下檢測
// 判斷函數的調用層級是否超過了最大深度(默認 1000)
start_frame:
if (_Py_EnterRecursivePy(tstate)) {
goto exit_unwind;
}
// 調用宏 SET_LOCALS_FROM_FRAME,初始化 next_instr 和 stack_pointer
resume_frame:
SET_LOCALS_FROM_FRAME();
// ...
}
這部分代碼主要是負責初始化一些屬性,其中最重要的兩個屬性是 next_instr 和 stack_pointer,字節碼的遍歷由 next_instr 負責,運行時棧的操作由 stack_pointer 負責。
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate,
_PyInterpreterFrame *frame,
int throwflag)
{
// ...
// ...
// 開始進行調度,準備執行字節碼
DISPATCH();
handle_eval_breaker:
if (_Py_HandlePending(tstate) != 0) {
goto error;
}
DISPATCH();
{
/* Start instructions */
// 如果不使用計算跳轉,那么這里就是帶標簽的 switch 語句
// 如果使用計算跳轉,那么這兩行無效
#if !USE_COMPUTED_GOTOS
dispatch_opcode:
switch (opcode)
#endif
{
// 導入 generated_cases.c.h,還記得這個文件嗎?里面包含了指令的處理邏輯
// 如果不使用計算跳轉,那么宏 TARGET 展開之后會生成一個帶標簽的 case 語句
// 如果使用計算跳轉,那么宏 TARGET 展開之后就只有一個標簽
#include "generated_cases.c.h"
// ...
} /* End instructions */
Py_UNREACHABLE();
}
// ...
}
這一部分是虛擬機開始執行字節碼,如果程序沒有出現錯誤,那么會將所有字節碼指令執行完畢。
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate,
_PyInterpreterFrame *frame,
int throwflag)
{
// ...
{
// ...
// 如果執行時出現 UnboundLocalError,會跳轉到此標簽
unbound_local_error:
{
format_exc_check_arg(tstate, PyExc_UnboundLocalError,
UNBOUNDLOCAL_ERROR_MSG,
PyTuple_GetItem(frame->f_code->co_localsplusnames, oparg)
);
goto error;
}
// 如果字節碼執行時報錯了,但運行時棧里面還有元素
// 那么要將運行時棧里的元素彈出
// 當運行時棧里包含 4 個元素時,跳轉到此標簽
pop_4_error:
STACK_SHRINK(1);
// 當運行時棧里包含 3 個元素時,跳轉到此標簽
pop_3_error:
STACK_SHRINK(1);
// 當運行時棧里包含 2 個元素時,跳轉到此標簽
pop_2_error:
STACK_SHRINK(1);
// 當運行時棧里包含 1 個元素時,跳轉到此標簽
pop_1_error:
STACK_SHRINK(1);
error:
kwnames = NULL;
assert(_PyErr_Occurred(tstate));
assert(frame != &entry_frame);
// 報錯時,要生成 traceback
// 關于 traceback,等介紹異常捕獲的時候再說
if (!_PyFrame_IsIncomplete(frame)) {
PyFrameObject *f = _PyFrame_GetFrameObject(frame);
if (f != NULL) {
PyTraceBack_Here(f);
}
}
monitor_raise(tstate, frame, next_instr-1);
exception_unwind:
// ...
// 后續介紹
}
// ...
}
以上就是幀評估函數的源碼邏輯,因為涉及到后面的內容,所以我們省略掉了一部分。
通過反編譯查看字節碼
源碼部分我們就看完了,可能有小伙伴會覺得有些枯燥吧,但我相信認真讀完一定會有很大收獲。下面我們實際操作一波,通過反編譯一段簡單的代碼,來觀察虛擬機執行字節碼的整個過程。
code_string = """
chinese = 89
math = 99
english = 91
avg = (chinese + math + english) / 3
"""
# 將上面的代碼以模塊的方式進行編譯
code_obj = compile(code_string, "...", "exec")
# 查看常量池
print(code_obj.co_consts) # (89, 99, 91, 3, None)
# 查看符號表
print(
code_obj.co_names
) # ('chinese', 'math', 'english', 'avg')
在編譯的時候,常量和符號(變量)都會被靜態收集起來,然后我們反編譯一下看看字節碼,直接通過 dis.dis(code_obj) 即可。結果如下:
0 0 RESUME 0
2 2 LOAD_CONST 0 (89)
4 STORE_NAME 0 (chinese)
3 6 LOAD_CONST 1 (99)
8 STORE_NAME 1 (math)
4 10 LOAD_CONST 2 (91)
12 STORE_NAME 2 (english)
5 14 LOAD_NAME 0 (chinese)
16 LOAD_NAME 1 (math)
18 BINARY_OP 0 (+)
22 LOAD_NAME 2 (english)
24 BINARY_OP 0 (+)
28 LOAD_CONST 3 (3)
30 BINARY_OP 11 (/)
34 STORE_NAME 3 (avg)
36 RETURN_CONST 4 (None)
我們從上到下依次解釋每條指令都干了什么?
2 LOAD_CONST:表示加載一個常量(地址),并壓入運行時棧。后面的指令參數 0 表示從常量池中加載索引為 0 的常量。
4 STORE_NAME:表示將 LOAD_CONST 加載的常量用一個名字綁定起來,放入所在的名字空間中。后面的 0 (chinese) 表示使用符號表中索引為 0 的名字(符號),且名字為 "chinese"。
所以像 chinese = 89 這種簡單的賦值語句,會對應兩條字節碼指令。
然后 6 LOAD_CONST、8 STORE_NAME、10 LOAD_CONST、12 STORE_NAME 的作用顯然和上面是一樣的,都是加載一個常量,然后和指定的符號綁定起來,并放入名字空間中。
14 LOAD_NAME:加載一個變量,并壓入運行時棧。后面的 0 (chinese) 表示加載符號表中索引為 0 的變量的值,然后這個變量叫 chinese。
16 LOAD_NAME:同理,將符號表中索引為 1 的變量的值壓入運行時棧,并且變量叫 math。此時棧里面有兩個元素,從棧底到棧頂分別是 chinese 和 math。
18 BINARY_OP:將上面兩個變量從運行時棧彈出,然后執行加法操作,并將結果壓入運行時棧。
22 LOAD_NAME:將符號表中索引為 2 的變量 english 的值壓入運行時棧,此時棧里面有兩個元素,從棧底到棧頂分別是 chinese + math 的返回結果和 english。
24 BINARY_OP:將運行時棧里的兩個元素彈出,然后執行加法操作,并將結果壓入運行時棧。此時棧里面有一個元素,就是 chinese + math + english 的運算結果。
28 LOAD_CONST:將常量 3 壓入運行時棧,此時棧里面有兩個元素。
30 BINARY_OP:將運行時棧里的兩個元素彈出,然后執行除法操作,并將結果壓入運行時棧,此時棧里面有一個元素。
34 STORE_NAME:將元素從運行時棧里面彈出,并用符號表中索引為 3 的符號 avg 和它綁定起來,然后放在名字空間中。
36 RETURN_CONST:將常量 None 壓入運行時棧,然后彈出并返回。
所以 Python 虛擬機就是把自己想象成一顆 CPU,在棧幀中一條條執行字節碼指令,當指令執行完畢或執行出錯時,停止執行。
我們通過幾張圖展示一下上面的過程,為了閱讀方便,這里將相應的源代碼再貼一份:
chinese = 89
math = 99
english = 91
avg = (chinese + math + english) / 3
上面的代碼位于最外層的模塊中,由于模塊也有自己的作用域,并且是全局作用域,所以虛擬機也會為它創建棧幀。而在代碼還沒有執行的時候,棧幀就已經創建好了,整個布局如下。
f_localsplus 下面的箭頭方向,代表運行時棧從棧底到棧頂的方向。
這里再強調一個重要的知識點,我們看到棧幀里面有一個 f_localsplus 屬性,它是一個數組。雖然聲明的時候寫著長度為 1,但實際使用時,長度不受限制,和 Go 語言不同,C 數組的長度不屬于類型的一部分。
所以 f_localsplus 是一個動態內存,運行時棧所需要的空間就存儲在里面,但這塊內存并不光給運行時棧使用,它被分成了四塊。
函數的局部變量是靜態存儲的,那么都存在哪呢?答案是在 f_localsplus 里面,而且是開頭的位置。在獲取的時候直接基于索引操作即可,因此速度會更快。所以源碼內部還有兩個宏:
圖片
函數在編譯的時候就知道每個局部變量在 f_localsplus 中的索引,所以直接通過索引操作即可,關于局部變量的具體操作細節,后續再聊。
然后 cell 變量和 free 變量是用來處理閉包的,而 f_localsplus 的最后一塊內存則用于運行時棧。
所以 f_localsplus 是一個數組,它是一段連續內存,只不過從邏輯上講,它被分成了四份,每一份用在不同的地方。但它們整體是連續的,都是數組的一部分。但當前示例中的代碼是以模塊的方式編譯的,里面所有的變量都是全局變量,而且也不涉及閉包啥的,所以這里就把 f_localsplus 理解為運行時棧即可。
接下來就開始執行字節碼了,虛擬機會執行:2 LOAD_CONST,該指令表示將常量加載進運行時棧,而要加載的常量在常量池中的索引,由指令參數表示。
在源碼中,指令對應的變量是 opcode,指令參數對應的變量是 oparg
// Python/generated_cases.c.h
TARGET(LOAD_CONST) {
PREDICTED(LOAD_CONST);
PyObject *value;
#line 204 "Python/bytecodes.c"
// 從常量池中加載索引為 oparg 的常量
value = GETITEM(frame->f_code->co_consts, oparg);
// 增加引用計數
Py_INCREF(value);
#line 114 "Python/generated_cases.c.h"
// stack_pointer++,為入棧的元素留出一個空間
// 我們上一篇專門介紹了這些運行時棧的 API
STACK_GROW(1);
// 將棧頂元素設置為 value
stack_pointer[-1] = value;
// STACK_GROW(1) 和 stack_pointer[-1] = value 組合起來等價于 PUSH(value)
// 調用 DISPATCHER,讀取下一條指令,并跳轉到指定位置進行處理
DISPATCH();
}
// Python/ceval_macros.h
static inline PyObject *
GETITEM(PyObject *v, Py_ssize_t i) {
// 從元組 v 中獲取索引為 i 的元素
assert(PyTuple_Check(v));
assert(i >= 0);
assert(i < PyTuple_GET_SIZE(v));
return PyTuple_GET_ITEM(v, i);
}
該指令的參數為 0,所以會將常量池中索引為 0 的元素 89 壓入運行時棧,執行完之后,棧幀的布局就變成了下面這樣:
圖片
接著虛擬機執行 4 STORE_NAME 指令,從符號表中獲取索引為 0 的符號、即 chinese。然后將棧頂元素 89 彈出,再將符號 chinese 和整數對象 89 綁定起來保存到 local 名字空間中。
TARGET(STORE_NAME) {
// 獲取運行時棧的棧頂元素,顯然是上一步壓入的 89
PyObject *v = stack_pointer[-1];
#line 1013 "Python/bytecodes.c"
// 從符號表中加載索引為 oparg 的符號
// 符號本質上就是一個 PyUnicodeObject 對象
// 這里就是字符串 "chinese"
PyObject *name = GETITEM(frame->f_code->co_names, oparg);
// #define LOCALS() frame->f_locals
// 獲取名字空間(namespace)
PyObject *ns = LOCALS();
int err;
if (ns == NULL) {
// 如果沒有名字空間則報錯,設置異常
_PyErr_Format(tstate, PyExc_SystemError,
"no locals found when storing %R", name);
#line 1405 "Python/generated_cases.c.h"
Py_DECREF(v);
#line 1020 "Python/bytecodes.c"
if (true) goto pop_1_error;
}
// 將符號和對象綁定起來放在名字空間 ns 中
// 名字空間是一個字典,PyDict_CheckExact 負責檢測 ns 是否為字典
if (PyDict_CheckExact(ns))
err = PyDict_SetItem(ns, name, v);
else
err = PyObject_SetItem(ns, name, v);
#line 1414 "Python/generated_cases.c.h"
// 對象的引用計數減 1
Py_DECREF(v);
#line 1027 "Python/bytecodes.c"
if (err) goto pop_1_error;
#line 1418 "Python/generated_cases.c.h"
// 棧收縮,v = stack_pointer[-1] 和 STACK_SHRINK(1)
// 兩者組合起來就等價于 v = POP(),即彈出運行時棧的棧頂元素
STACK_SHRINK(1);
DISPATCH();
}
執行完之后,棧幀的布局就變成了下面這樣:
圖片
此時運行時棧為空,local 名字空間多了個鍵值對。
同理剩余的兩個賦值語句也是類似的,只不過指令參數不同,比如 8 STORE_NAME 加載的是符號表中索引為 1 的符號,12 STORE_NAME 加載的是符號表中索引為 2 的符號,分別是 math 和 english。執行完之后,棧楨的布局如下:
圖片
然后 14 LOAD_NAME 和 16 LOAD_NAME 負責將符號表中索引為 0 和 1 的變量的值壓入運行時棧:
TARGET(LOAD_NAME) {
PyObject *v;
#line 1236 "Python/bytecodes.c"
// 獲取全局名字空間,對于模塊來說
// f->f_locals 和 f->f_globals 指向同一個字典
PyObject *mod_or_class_dict = LOCALS();
if (mod_or_class_dict == NULL) {
_PyErr_SetString(tstate, PyExc_SystemError,
"no locals found");
if (true) goto error;
}
// 從符號表 co_names 中加載索引為 oparg 的符號
// 但是注意:全局變量是通過字典存儲的
// 所以這里的 name 只是一個字符串罷了,比如 "chinese"
// 然后還要再根據這個字符串從字典里面查找對應的 value
PyObject *name = GETITEM(frame->f_code->co_names, oparg);
// 根據 name 從字典中獲取 value
if (PyDict_CheckExact(mod_or_class_dict)) {
v = PyDict_GetItemWithError(mod_or_class_dict, name);
if (v != NULL) {
Py_INCREF(v);
}
else if (_PyErr_Occurred(tstate)) {
goto error;
}
}
// ...
#line 1763 "Python/generated_cases.c.h"
// 將變量的值壓入運行時棧
STACK_GROW(1);
stack_pointer[-1] = v;
DISPATCH();
}
上面兩條指令執行完之后,棧幀的布局就變成了下面這樣:
圖片
接下來執行 18 BINARY_OP,它會將棧里的兩個元素彈出,然后執行加法操作,最后再將結果入棧。
TARGET(BINARY_OP) {
PREDICTED(BINARY_OP);
// 獲取棧里的兩個元素
PyObject *rhs = stack_pointer[-1];
PyObject *lhs = stack_pointer[-2];
// 計算結果
PyObject *res;
// ...
// binary_ops[oparg] 會拿到對應的函數指針(這里是加法)
// 然后傳入 lhs 和 rhs 進行計算
res = binary_ops[oparg](lhs, rhs);
#line 4663 "Python/generated_cases.c.h"
// 減少引用計數
Py_DECREF(lhs);
Py_DECREF(rhs);
#line 3384 "Python/bytecodes.c"
if (res == NULL) goto pop_2_error;
#line 4668 "Python/generated_cases.c.h"
// 棧里面有兩個元素,應該將它們彈出,然后再將 res 入棧
// 但事實上只需彈出一個,然后再用 res 將棧頂元素替換掉即可
STACK_SHRINK(1);
stack_pointer[-1] = res;
next_instr += 1;
DISPATCH();
}
BINARY_OP 指令執行完之后,棧幀的布局就變成了下面這樣:
圖片
然后 22 LOAD_NAME 負責將符號表中索引為 2 的變量 english 的值壓入運行時棧;而指令 24 BINARY_OP 則是繼續執行加法操作,并將結果設置在棧頂;然后 28 LOAD_CONST 將常量 3 再壓入運行時棧;接著再執行 30 BINARY_OP,此時對應的是除法操作。
這四條指令執行時,運行時棧變化如下:
圖片
此時運行時棧里面只剩下一個元素 93.0,然后 34 STORE_NAME 將棧頂元素 93.0 彈出,并將符號表中索引為 3 的符號 avg 和它綁定起來,放到名字空間中。因此最終棧幀關系圖如下:
圖片
以上就是虛擬機對這幾行代碼的執行流程,整個過程就像 CPU 執行指令一樣。
我們再用 Python 代碼描述一遍上面的邏輯:
# LOAD_CONST 將 89 壓入棧中
# STORE_NAME 將 89 從棧中彈出
# 并將符號 "chinese" 和 89 綁定起來,放在名字空間中
chinese = 89
print(
{k: v for k, v in locals().items() if not k.startswith("__")}
) # {'chinese': 89}
math = 99
print(
{k: v for k, v in locals().items() if not k.startswith("__")}
) # {'chinese': 89, 'math': 99}
english = 91
print(
{k: v for k, v in locals().items() if not k.startswith("__")}
) # {'chinese': 89, 'math': 99, 'english': 91}
avg = (chinese + math + english) / 3
print(
{k: v for k, v in locals().items() if not k.startswith("__")}
) # {'chinese': 89, 'math': 99, 'english': 91, 'avg': 93.0}
現在你是不是對虛擬機執行字節碼有一個更深的了解了呢?當然字節碼指令有很多,不止我們上面看到的那幾個。你可以隨便寫一些代碼,然后分析一下它的字節碼指令是什么樣的。
小結
到此,我們就深入源碼,考察了虛擬機執行字節碼的流程,幀評估函數雖然很長,也有那么一些復雜,但是核心邏輯不難理解。就是把自己當成一顆 CPU,在棧幀中執行指令。