iT邦幫忙

2024 iThome 鐵人賽

DAY 20
0
Python

為你自己讀 CPython 原始碼系列 第 20

Day 20 - 虛擬機器大冒險(三)

  • 分享至 

  • xImage
  •  

本文同步刊載於 「為你自己學 Python - 虛擬機器大冒險(三)

虛擬機器大冒險(三)

為你自己學 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 Object 的建立,大概是從 _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 Object。

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

// 檔案: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 Object 之前,要先把這傢伙從整串的 Frame Object 鏈拆下來。所以這兩行:

_PyInterpreterFrame *dying = frame;

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

frame = cframe.current_frame = dying->previous;

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

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

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

本文同步刊載於 「為你自己學 Python - 虛擬機器大冒險(三)


上一篇
Day 19 - 虛擬機器大冒險(二)
下一篇
Day 21 - 虛擬機器大冒險(四)
系列文
為你自己讀 CPython 原始碼21
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言