跳至主要内容

虛擬機器大冒險(三)

為你自己學 Python

在前面的章節簡單的看了一下 Code Object 和函數物件的結構,大概可以知道每個函數裡面會包一個 Code Object,而這個 Code Object 就是實際被執行的最小單位。我們從最底下的 Code Object 往上層追到函數,接下來我們要繼續再往上層追,看看函數執行的過程發生什麼事。

一般程式語言在執行函數的時候,會建立一個新的執行環境,這個執行環境包含了當前函數的所有必要資訊,如本地變數、全域變數等。這個執行環境有些程式語言會稱它叫「呼叫堆疊(Call Stack)」,在 Python 裡被稱為 Frame Object。每當呼叫一個函數,就會建立一個新的 Frame。Frame 會被放在一個堆疊裡,當函數執行完畢後,這顆 Frame 會被移除。

我們就來看看在 CPython 裡 Frame 長什麼樣子,以及它的出生與銷毀的過程是怎麼回事。

Frame Object

檔案:Include/internal/pycore_frame.h
struct _frame {
PyObject_HEAD
PyFrameObject *f_back;
struct _PyInterpreterFrame *f_frame;
PyObject *f_trace;
int f_lineno;
char f_trace_lines;
char f_trace_opcodes;
char f_fast_as_locals;
PyObject *_f_frame_data[1];
};

除了大家都有的 PyObject_HEAD 之外還有幾個比較值得介紹看的,其中 f_back 會指向堆疊中的前一個 Frame Object,一個接一個,可以形成一個鏈表結構。f_frame 是一個指向 _PyInterpreterFrame 型別的指標,嗯...這個待會再來追。中間還有一些看起來像是程式碼行號的東西,最後面的 _f_frame_data[1] 成員,我們在前面章節有看過類似的設計,這是一個彈性陣列成員(Flexible Array Member),用於儲存額外的 frame 相關資料。

來看看 _PyInterpreterFrame 的定義:

檔案:Include/internal/pycore_frame.h
typedef struct _PyInterpreterFrame {
PyCodeObject *f_code;
struct _PyInterpreterFrame *previous;
PyObject *f_funcobj;
PyObject *f_globals;
PyObject *f_builtins;
PyObject *f_locals;
PyFrameObject *frame_obj;
_Py_CODEUNIT *prev_instr;
int stacktop;
uint16_t return_offset;
char owner;
PyObject *localsplus[1];
} _PyInterpreterFrame;

喔喔喔,在這個結構裡有看到我們前面看過的 Code Object f_code,然後它也有個 previous 指向前一個結構,形成執行堆疊。f_funcobj 指向與此這個 Frame 關聯的函數物件。

這裡還能看到 f_globalsf_builtins 以及 f_locals 這三個物件,從名字大概就能看出來分別是用來儲存全域變數、內建變數以及區域變數。

最後還有一個 localsplus,這也是一個彈性陣列成員,事實上它才是用來存放區域變數的地方。f_locals 通常是一個字典結構,大部份時候它都是 NULL,只有當真正需要以字典形式訪問區域變數時(例如呼叫 locals() 函數),才會建立 f_locals 字典,而當需要建立 f_locals 字典時,localsplus 裡的值就會被複製或填充到 f_locals 裡。

Frame Object 的一生

當呼叫一個 Python 函數的時候,會產生一個新的 PyFrameObject_PyInterpreterFramePyFrameObjectf_frame 成員指向 _PyInterpreterFrame,而且 _PyInterpreterFrameframe_obj 成員反過來指向 PyFrameObject

要追蹤 Frame 的建立,大概是從 _PyEval_EvalFrameDefault 這個函數開始,但還沒看始追,一開頭寫著有點嚇人的註解:

_PyEval_EvalFrameDefault() is a *big* function

看了一下是真的不小,大概有 350 行左右:

檔案:Python/ceval.c
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, _PyInterpreterFrame *frame, int throwflag)
{
// ... 略 ...
}

我試著從這裡拆解一些重點出來:

檔案:Python/ceval.c
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, _PyInterpreterFrame *frame, int throwflag)
{
_PyInterpreterFrame entry_frame;
_PyCFrame *prev_cframe = tstate->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;

entry_frame.previous = prev_cframe->current_frame;
frame->previous = &entry_frame;
}

一進來先建立一個新的 entry_frame,同時也透過 PyThreadState 取得當前的堆疊。在過程中把 entry_frame 的一些成員設定好,透過把 entry_frameprevious 指向前一個剛才取得的堆疊裡的 Frame 做到把 entry_frame 推到堆疊裡的效果,最後再把 frameprevious 指向剛剛建立的這個 entry_frame。有點暈,但這樣就建立了一個新的 Frame。

接下來,在中間有一段格式看起來有點難懂的:

檔案:Python/ceval.c
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, _PyInterpreterFrame *frame, int throwflag)
{
/* Start instructions */
#if !USE_COMPUTED_GOTOS
dispatch_opcode:
switch (opcode)
#endif
{

#include "generated_cases.c.h"

// ... 略 ...
#if USE_COMPUTED_GOTOS
TARGET_INSTRUMENTED_LINE:
#else
case INSTRUMENTED_LINE:
#endif
// ... 略 ...

} /* End instructions */
}

/* Start instructions *//* End instructions */ 這整段,是一個大型的 switch 語法,裡面有很多不同的 case,每個 case 代表一個指令,特別注意 #include "generated_cases.c.h" 這一行,這個引入了大量生成的操作碼。這個生成的檔案追進去看大概有快 4,800 多行,一開頭的註解也寫到這個檔案是怎麼生成的:

// This file is generated by Tools/cases_generator/generate_cases.py
// from:
// Python/bytecodes.c
// Do not edit!

還叫我們不要自己手動編輯,有興趣可以再去追一下生成這個檔案的 generate_cases.py

最後來看看 Frame Object 的銷毀:

檔案:Python/ceval.c
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyThreadState *tstate, _PyInterpreterFrame *frame, int throwflag)
{
// ... 略 ...
exit_unwind:
assert(_PyErr_Occurred(tstate));
_Py_LeaveRecursiveCallPy(tstate);
assert(frame != &entry_frame);
// GH-99729: We need to unlink the frame *before* clearing it:
_PyInterpreterFrame *dying = frame;
frame = cframe.current_frame = dying->previous;
_PyEvalFrameClearAndPop(tstate, dying);
frame->return_offset = 0;
if (frame == &entry_frame) {
/* Restore previous cframe and exit */
tstate->cframe = cframe.previous;
assert(tstate->cframe->current_frame == frame->previous);
tstate->c_recursion_remaining += PY_EVAL_C_STACK_UNITS;
return NULL;
}
// ... 略 ...
}

exit_unwind 這個標籤大概就是做準備撤退的收尾工作,這裡有段註解這樣寫著:

// GH-99729: We need to unlink the frame *before* clearing it:

這表明在斷開 Frame 之前,要先把這傢伙從整串的 Frame 鏈拆下來。所以這兩行:

檔案:Python/ceval.c
_PyInterpreterFrame *dying = frame;

在做的事情就是建立一個新的 _PyInterpreterFrame 物件,會叫 dying 是因為它就是快要 GG 的。而接下來這行:

檔案:Python/ceval.c
frame = cframe.current_frame = dying->previous;

原本的 Frame 是串串相連到天邊,這行程式的意思就是把後面的 Frame 指向 dying 的前一個 Frame,這樣就把 dying 從整串 Frame 鏈拆下來了。

最後,就是呼叫 _PyEvalFrameClearAndPop() 函數來做清理善後的事了。

Frame 從它誕生的那一刻,就註定要過著忙碌又短暫的生活。我們每次呼叫函數的背後都有一個 Frame 幫我們打理搬運變數、執行指令,還要隨時準備處理意外狀況這些雜事,它們的辛苦換來了我們程式的正常運行。下次當你呼叫一個函數時,別忘了向這些幕後英雄們說聲謝謝!

工商服務

想學 Python 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!