python3 原始碼閱讀-虛擬機器執行原理

G1733發表於2024-06-03

原文

閱讀原始碼版本python 3.8.3

參考書籍<<Python原始碼剖析>>

參考書籍<<Python學習手冊 第4版>>

官網文件目錄介紹

  1. Doc目錄主要是官方文件的說明。
  2. Include:目錄主要包括了Python的執行的標頭檔案。
  3. Lib:目錄主要包括了用Python實現的標準庫。
  4. Modules: 該目錄中包含了所有用C語言編寫的模組,比如random、cStringIO等。Modules中的模組是那些對速度要求非常嚴格的模組,而有一些對速度沒有太嚴格要求的模組,比如os,就是用Python編寫,並且放在Lib目錄下的
  5. Objects:該目錄中包含了所有Python的內建物件,包括整數、list、dict等。同時,該目錄還包括了Python在執行時需要的所有的內部使用物件的實現。
  6. Parser:該目錄中包含了Python直譯器中的Scanner和Parser部分,即對Python原始碼進行詞法分析和語法分析的部分。除了這些,Parser目錄下還包含了一些有用的工具,這些工具能夠根據Python語言的語法自動生成Python語言的詞法和語法分析器,將python檔案編譯生成語法樹等相關工作。
  7. Programs目錄主要包括了python的入口函式。
  8. Python:目錄主要包括了Python動態執行時執行的程式碼,裡面包括編譯、位元組碼直譯器等工作。

1. 總體架構#

image.png

  • Runtime Env:python執行時環境,初始化物件/型別系統(Object/Type structures),記憶體分配器(Memory Allocator) 和 執行時狀態資訊 (Current state of Python)。執行時狀態維護瞭直譯器在執行位元組碼時不同的狀態(如正常和異常)之間的切換動作,可以視為一個巨大而複雜的有窮狀態機。記憶體管理機制可參考另外一篇文章Python3 原始碼閱讀 - 記憶體管理機制

  • Python Core: 中間部分是python的核心----直譯器(PyInterpreter), 也可以成為PVM。大致流程就是 先對.py程式進行此法分析,將檔案輸入的原始碼或從命令列輸入的一行行python程式碼切分一個個Token, 然後使用Parser進行語法分析,建立抽象語法樹(AST), Compiler根據AST生成位元組碼指令集合,最後由Code Evaluator來執行這些位元組碼。

  • File Groups: Python Lib庫和使用者自己的模組包等原始碼檔案

2. Run Python檔案的啟動流程#

Python啟動是由Programs下的python.c檔案中的main函式開始執行

/* Minimal main program -- everything is loaded from the library */

#include "Python.h"
#include "pycore_pylifecycle.h"

#ifdef MS_WINDOWS
int
wmain(int argc, wchar_t **argv)
{
    return Py_Main(argc, argv);
}
#else
int
main(int argc, char **argv)
{
    return Py_BytesMain(argc, argv);
}
#endif

int
Py_Main(int argc, wchar_t **argv) {
    ...
    return pymian_main(&args);
}

static int
pymain_main(_PyArgv *args)
{
    PyStatus status = pymain_init(args);  // 初始化
    if (_PyStatus_IS_EXIT(status)) {
        pymain_free();
        return status.exitcode;
    }
    if (_PyStatus_EXCEPTION(status)) {
        pymain_exit_error(status);
    }

    return Py_RunMain();
}

2.1 初始化關鍵流程#

  • 初始化一些與配置項 如:開啟utf-8模式,設定Python記憶體分配器
  • 初始化pyinit_core核心部分
    • 建立生命週期 pycore_init_runtime, 同時生成HashRandom
    • 初始化執行緒和直譯器並建立GIL鎖 pycore_create_interpreter
    • 初始化所有基礎型別,list, int, tuple等 pycore_init_types
    • 初始化sys模組 _PySys_Create
    • 初始化內建函式或者物件,如map, None, True等 pycore_init_builtins
      • 其中包括內建的錯誤型別初始化 _PyBuiltins_AddExceptions

Python3.8 對Python直譯器的初始化做了重構PEP 587-Python初始化配置

2.2 run 相關原始碼閱讀#

int
Py_RunMain(void)
{
    int exitcode = 0;
    
    pymain_run_python(&exitcode);  //執行python指令碼

    if (Py_FinalizeEx() < 0) {  // 釋放資源
        /* Value unlikely to be confused with a non-error exit status or
           other special meaning */
        exitcode = 120;
    }

    pymain_free();   // 釋放資源

    if (_Py_UnhandledKeyboardInterrupt) {
        exitcode = exit_sigint();
    }

    return exitcode;
}


static void
pymain_run_python(int *exitcode)
{   
    // 獲取一個持有GIL鎖的直譯器
    PyInterpreterState *interp = _PyInterpreterState_GET_UNSAFE();
    /* pymain_run_stdin() modify the config */
    ... // 新增sys_path等操作

    if (config->run_command) {
        // 命令列模式
        *exitcode = pymain_run_command(config->run_command, &cf); 
    }
    else if (config->run_module) {
        // 模組名
        *exitcode = pymain_run_module(config->run_module, 1);
    }
    else if (main_importer_path != NULL) {
        *exitcode = pymain_run_module(L"__main__", 0);
    }
    else if (config->run_filename != NULL) {
        // 檔名
        *exitcode = pymain_run_file(config, &cf);
    }
    else {
        *exitcode = pymain_run_stdin(config, &cf);
    }

    ...
}

/* Parse input from a file and execute it */ //Python/pythonrun.c
int
PyRun_AnyFileExFlags(FILE *fp, const char *filename, int closeit,
                     PyCompilerFlags *flags)
{
    if (filename == NULL)
        filename = "???";
    if (Py_FdIsInteractive(fp, filename)) {
        int err = PyRun_InteractiveLoopFlags(fp, filename, flags);  // 是否是互動模式
        if (closeit)
            fclose(fp);
        return err;
    }
    else
        return PyRun_SimpleFileExFlags(fp, filename, closeit, flags);   // 執行指令碼
}

// 執行python .py檔案
int
PyRun_SimpleFileExFlags(FILE *fp, const char *filename, int closeit,
                        PyCompilerFlags *flags)
{
    ...
    if (maybe_pyc_file(fp, filename, ext, closeit)) {
        FILE *pyc_fp;
        /* Try to run a pyc file. First, re-open in binary */
        ...
        v = run_pyc_file(pyc_fp, filename, d, d, flags);
    } else {
        /* When running from stdin, leave __main__.__loader__ alone */
        ...
        v = PyRun_FileExFlags(fp, filename, Py_file_input, d, d,
                              closeit, flags);
    }
    ...
}

PyObject *
PyRun_FileExFlags(FILE *fp, const char *filename_str, int start, PyObject *globals,
                  PyObject *locals, int closeit, PyCompilerFlags *flags)
{
    ...
    // // 解析傳入的指令碼,解析成AST
    mod = PyParser_ASTFromFileObject(fp, filename, NULL, start, 0, 0,
                                     flags, NULL, arena); 
    ...
    // 將AST編譯成位元組碼然後啟動位元組碼直譯器執行編譯結果
    ret = run_mod(mod, filename, globals, locals, flags, arena);
    ...
}

// 檢視run_mode
static PyObject *
run_mod(mod_ty mod, PyObject *filename, PyObject *globals, PyObject *locals,
            PyCompilerFlags *flags, PyArena *arena)
{
    ...
    // 將AST編譯成位元組碼
    co = PyAST_CompileObject(mod, filename, flags, -1, arena);  
    ...

    // 解釋執行編譯的位元組碼
    v = run_eval_code_obj(co, globals, locals);
    Py_DECREF(co);
    return v;
}

2.3 位元組碼檢視案例#

新建test.py

def show(a):
    return  a


if __name__ == "__main__":
    print(show(10))

執行命令: python3 -m dis test.py

python3 -m dis test.py
  3           0 LOAD_CONST               0 (<code object show at 0x000000E7FC89E270, file "test.py", line 3>)
              2 LOAD_CONST               1 ('show')
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (show)

  7           8 LOAD_NAME                1 (__name__)
             10 LOAD_CONST               2 ('__main__')
             12 COMPARE_OP               2 (==)
             14 POP_JUMP_IF_FALSE       28

  8          16 LOAD_NAME                2 (print)
             18 LOAD_NAME                0 (show)
             20 LOAD_CONST               3 (10)
             22 CALL_FUNCTION            1
             24 CALL_FUNCTION            1
             26 POP_TOP
        >>   28 LOAD_CONST               4 (None)

左邊3, 7, 8表示 test.py中的第一行和第二行,右邊表示python byte code

Include/opcode.h 發現總共有 163 個 opcode, 所有的 python 原始檔(Lib庫中的檔案)都會被編譯器翻譯成由 opcode 組成的 pyx 檔案,並快取在執行目錄,下次啟動程式如果原始碼沒有修改過,則直接載入這個pyx檔案,這個檔案的存在可以加快 python 的載入速度。普通.py檔案如我們的test.py 是直接進行編譯解釋執行的,不會生成.pyc檔案,想生成test.pyc 需要使用python內建的py_compile模組來編譯該檔案,或者執行命令python3 -m test.py python生成.pyc檔案

嚴格意義上來說: 只有檔案匯入import 的情況下位元組碼.pyc檔案才會儲存下來,__pycache__ --- 《python學習手冊(第四版) Page40》

2.4 python中的code物件#

位元組碼在python虛擬機器中對應的是PyCodeObject物件, .pyc檔案是位元組碼在磁碟上的表現形式。python編譯的過程中,一個程式碼塊就對應一個code物件,那麼如何確定多少程式碼算是一個Code Block呢? 編譯過程中遇到一個新的名稱空間或者作用域時就生成一個code物件,即類或函式都是一個程式碼塊,一個code的型別結構就是PyCodeObject, 參考Junnplus

/* Bytecode object */
typedef struct {
    PyObject_HEAD
    int co_argcount;            /* #arguments, except *args */     // 位置引數的個數,
    int co_posonlyargcount;     /* #positional only arguments */  
    int co_kwonlyargcount;      /* #keyword only arguments */
    int co_nlocals;             /* #local variables */
    int co_stacksize;           /* #entries needed for evaluation stack */
    int co_flags;               /* CO_..., see below */
    int co_firstlineno;         /* first source line number */
    PyObject *co_code;          /* instruction opcodes */
    PyObject *co_consts;        /* list (constants used) */
    PyObject *co_names;         /* list of strings (names used) */
    PyObject *co_varnames;      /* tuple of strings (local variable names) */
    PyObject *co_freevars;      /* tuple of strings (free variable names) */
    PyObject *co_cellvars;      /* tuple of strings (cell variable names) */
    /* The rest aren't used in either hash or comparisons, except for co_name,
       used in both. This is done to preserve the name and line number
       for tracebacks and debuggers; otherwise, constant de-duplication
       would collapse identical functions/lambdas defined on different lines.
    */
    Py_ssize_t *co_cell2arg;    /* Maps cell vars which are arguments. */
    PyObject *co_filename;      /* unicode (where it was loaded from) */
    PyObject *co_name;          /* unicode (name, for reference) */
    PyObject *co_lnotab;        /* string (encoding addr<->lineno mapping) See
                                   Objects/lnotab_notes.txt for details. */
    void *co_zombieframe;       /* for optimization only (see frameobject.c) */
    PyObject *co_weakreflist;   /* to support weakrefs to code objects */
    /* Scratch space for extra data relating to the code object.
       Type is a void* to keep the format private in codeobject.c to force
       people to go through the proper APIs. */
    void *co_extra;

    /* Per opcodes just-in-time cache
     *
     * To reduce cache size, we use indirect mapping from opcode index to
     * cache object:
     *   cache = co_opcache[co_opcache_map[next_instr - first_instr] - 1]
     */

    // co_opcache_map is indexed by (next_instr - first_instr).
    //  * 0 means there is no cache for this opcode.
    //  * n > 0 means there is cache in co_opcache[n-1].
    unsigned char *co_opcache_map;
    _PyOpcache *co_opcache;
    int co_opcache_flag;  // used to determine when create a cache.
    unsigned char co_opcache_size;  // length of co_opcache.
} PyCodeObject;
FieldContentType
co_argcount Code Block 的引數個數 PyIntObject
co_posonlyargcount Code Block 的位置引數個數 PyIntObject
co_kwonlyargcount Code Block 的關鍵字引數個數 PyIntObject
co_nlocals Code Block 中區域性變數的個數 PyIntObject
co_stacksize Code Block 的棧大小 PyIntObject
co_flags N/A PyIntObject
co_firstlineno Code Block 對應的 .py 檔案中的起始行號 PyIntObject
co_code Code Block 編譯所得的位元組碼 PyBytesObject
co_consts Code Block 中的常量集合 PyTupleObject
co_names Code Block 中的符號集合 PyTupleObject
co_varnames Code Block 中的區域性變數名集合 PyTupleObject
co_freevars Code Block 中的自由變數名集合 PyTupleObject
co_cellvars Code Block 中巢狀函式所引用的區域性變數名集合 PyTupleObject
co_cell2arg N/A PyTupleObject
co_filename Code Block 對應的 .py 檔名 PyUnicodeObject
co_name Code Block 的名字,通常是函式名/類名/模組名 PyUnicodeObject
co_lnotab Code Block 的位元組碼指令於 .py 檔案中 source code 行號對應關係 PyBytesObject
co_opcache_map python3.8新增欄位,儲存位元組碼索引與CodeBlock物件的對映關係 PyDictObject

2.4.1 LOAD_CONST#

// Python\ceval.c
PREDICTED(LOAD_CONST);     -> line 943: #define PREDICTED(op)           PRED_##op:
FAST_DISPATCH();           -> line 876 #define FAST_DISPATCH() goto fast_next_opcode

額外收穫: c 語言中 ##和# 號 在marco 裡的作用可以參考 這篇

在宏定義裡, ## 被稱為連線符(concatenator) , a##b 表示將ab連線起來

a 表示把a轉換成字串,即加雙引號,

所以LONAD_CONST這個指領根據宏定義展開如下:

case TARGET(LOAD_CONST): {
    PRED_LOAD_CONST:
    PyObject *value = GETITEM(consts, oparg); // 獲取一個PyObject* 指標物件
    Py_INCREF(value);  // 引用計數加1
    PUSH(value);     // 把剛剛建立的PyObject* push到當前的frame的stack上, 以便下一個指令從這個 stack 上面獲取
    goto fast_next_opcode;

2.5 main_loop#

// Python\ceval.c
main_loop:
    for (;;) {
        ...
            
        switch (opcode) {
 
        /* BEWARE!
           It is essential that any operation that fails must goto error
           and that all operation that succeed call [FAST_]DISPATCH() ! */
 
        case TARGET(NOP): {
            FAST_DISPATCH();
        }
 
        case TARGET(LOAD_FAST): {
            PyObject *value = GETLOCAL(oparg);
            if (value == NULL) {
                format_exc_check_arg(PyExc_UnboundLocalError,
                                     UNBOUNDLOCAL_ERROR_MSG,
                                     PyTuple_GetItem(co->co_varnames, oparg));
                goto error;
            }
            Py_INCREF(value);
            PUSH(value);
            FAST_DISPATCH();
        }
 
        case TARGET(LOAD_CONST): {
            PREDICTED(LOAD_CONST);
            PyObject *value = GETITEM(consts, oparg);
            Py_INCREF(value);
            PUSH(value);
            FAST_DISPATCH();
        }
        ...
    }
}

在 python 虛擬機器中,直譯器主要在一個很大的迴圈中,不停地讀入 opcode, 並根據 opcode 執行對應的指令,當執行完所有指令虛擬機器退出,程式也就結束了

2.6 總結#

image-20200608163433117.png

過程描述:

  1. python先把程式碼(.py檔案)編譯成位元組碼,交給位元組碼虛擬機器,然後虛擬機器會從編譯得到的PyCodeObject物件中一條一條執行位元組碼指令,並在當前的上下文環境中執行這條位元組碼指令,從而完成程式的執行。Python虛擬機器實際上是在模擬操作中執行檔案的過程。PyCodeObject物件中包含了位元組碼指令以及程式的所有靜態資訊,但沒有包含程式執行時的動態資訊——執行環境(PyFrameObject),後面會繼續記錄執行環境的閱讀。
  2. 從整體上看:OS中執行程式離不開兩個概念:程序和執行緒。python中模擬了這兩個概念,模擬程序和執行緒的分別是PyInterpreterState和PyTreadState。即:每個PyThreadState都對應著一個幀棧,python虛擬機器在多個執行緒上切換(靠GIL實現執行緒之間的同步)。當python虛擬機器開始執行時,它會先進行一些初始化操作,最後進入PyEval_EvalFramEx函式,內部實現了一個main_loop它的作用是不斷讀取編譯好的位元組碼,並一條一條執行,類似CPU執行指令的過程。函式內部主要是一個switch結構,根據位元組碼的不同執行不同的程式碼

3. Python中的Frame#

如上所說,PyCodeObject物件只是包含了位元組碼指令集以及程式的相關靜態資訊,虛擬機器的執行還需要一個執行環境,即PyFrameObject,也就是對系統棧幀的模擬。

3.1 堆和棧的認識#

堆中存的是物件。棧中存的是基本資料型別和堆中物件的引用。一個物件的大小是不可估計的,或者說是可以動態變化的,但是在棧中,一個物件只對應了一個4btye的引用(堆疊分離的好處)

記憶體中的堆疊和資料結構堆疊不是一個概念,可以說記憶體中的堆疊是真實存在的物理區,資料結構中的堆疊是抽象的資料儲存結構。

記憶體空間在邏輯上分為三部分:程式碼區,靜態資料區和動態資料區,動態資料區有分為堆區和棧區

  • 程式碼區:儲存的二進位制程式碼塊,高階排程(作業排程)、中級排程(記憶體排程)、低階排程(程序排程)控制程式碼區執行程式碼的切換
  • 靜態資料區:儲存全域性變數,靜態變數,常量,系統自動分配和回收。
  • 動態資料區:
    • 棧區(stack):儲存執行方法的形參,區域性變數,返回值,有編譯器自動分配和回收,操作類似資料結構中的棧
    • 堆區(heap):new一個物件的引用或者地址儲存在棧區,該地址指向指向物件儲存在堆區中的真實資料。如c中的malloc函式,python中的Pymalloc

image.png

3.2 PyFrameObject物件#

typedef struct _frame{  
    PyObject_VAR_HEAD //"執行時棧"的大小是不確定的, 所以用可變長的物件
    struct _frame *f_back; //執行環境鏈上的前一個frame,很多個PyFrameObject連線起來形成執行環境連結串列  
    PyCodeObject *f_code; //PyCodeObject 物件,這個frame就是這個PyCodeObject物件的上下文環境  
    PyObject *f_builtins; //builtin名字空間  
    PyObject *f_globals;  //global名字空間  
    PyObject *f_locals;   //local名字空間  
    PyObject **f_valuestack; //"執行時棧"的棧底位置  
    PyObject **f_stacktop;   //"執行時棧"的棧頂位置  
    //...  
    int f_lasti;  //上一條位元組碼指令在f_code中的偏移位置  
    int f_lineno; //當前位元組碼對應的原始碼行  
    //...  
      
    //動態記憶體,維護(區域性變數+cell物件集合+free物件集合+執行時棧)所需要的空間  
    PyObject *f_localsplus[1];    
} PyFrameObject; 

如果你想知道 PyFrameObject 中每個欄位的意義, 請參考 Junnplus' blog 或者直接閱讀原始碼,瞭解frame的執行過程可以參考zpoint'blog.

名字空間實際上是維護著變數名和變數值之間關係的PyDictObject物件。
f_builtins, f_globals, f_locals名字空間分別維護了builtin, global, local的name與對應值之間的對映關係。

每一個 PyFrameObject物件都維護了一個 PyCodeObject物件,這表明每一個 PyFrameObject中的動態記憶體空間物件都和原始碼中的一段Code相對應。

每當在直譯器中做一次函式呼叫時,會建立一個新的PyFrameObject物件,這個物件就是當前函式呼叫的棧幀物件。

從呼叫棧理解Python協程的執行流程#

具體可以參考zpoint'blog. 以下為個人小結。

python的yield是用底層虛擬機器的棧狀態切換來實現的,實現機制借鑑Lua5.2 的協程,

CPythonyield實現是基於棧和Frame, PyFrameObjectCython中的一個模擬棧幀的物件,yield對應一個生成器物件genobject.c yield在虛擬機器中對應一個操作碼 YIELD_VALUE, 即虛擬機器對應的位元組碼, 這樣就可以很好的理解,上下文是如何儲存的了,一個物件的狀態儲存和切換,使用一些屬性來做,在虛擬機器中很好實現。CPythonyield的確是單執行緒,或者說,其實CPythonyield和對應的生成器只是轉化為一段位元組碼,CPython虛擬機器的位元組碼執行是單執行緒的。

yield的實現我個人理解為中斷機制,當一個生成器物件初始化的時候就會把對應的引數,變數值放入堆中,當載入到yield 的時候,會先執行一個 LOAD FAST 的操作碼,獲取yield所要返回的值如果沒有就是None, 將其壓入棧中, 接著由於LOAD FAST對應著FAST DISPATCH的機制,就會繼續執行下一個操作碼 YIELD_VALUE 緊接著 POP_TOP 推出棧頂元素。此時被呼叫的Frame(當前的迭代器物件)並沒有被釋放而是進入一個zombie的狀態,下一次同個程式碼段執行時, 這個 frame 物件會優先被複用。

3.2.1 棧幀的獲取,工作中會用到#

可以透過sys._getframe([depth]), 獲取指定深度的PyFrameObject物件

>>> import sys
>>> frame = sys._getframe()
>>> frame
<frame object at 0x103ab2d48>

3.2.2 python中變數名的解析規則 LEGB#

Local -> Enclosed -> Global -> Built-In

  • Local 表示區域性變數

  • Enclosed 表示巢狀的變數

  • Global 表示全域性變數

  • Built-In 表示內建變數

如果這幾個順序都取不到,就會丟擲 ValueError

可以在這個網站python執行視覺化網站,觀察程式碼執行流程,以及變數的轉換賦值情況。

4. 額外收穫#

意外收穫: 之前知道pythonGIL , 遇到I/O阻塞時會釋放gil,現在從原始碼中看到了對應的流程

if (_Py_atomic_load_relaxed(&ceval->gil_drop_request)) {
    /* Give another thread a chance */
    if (_PyThreadState_Swap(&runtime->gilstate, NULL) != tstate) {
        Py_FatalError("ceval: tstate mix-up");
    }
    drop_gil(ceval, tstate);

    /* Other threads may run now */

    take_gil(ceval, tstate);

    /* Check if we should make a quick exit. */
    exit_thread_if_finalizing(runtime, tstate);

    if (_PyThreadState_Swap(&runtime->gilstate, tstate) != NULL) {
        Py_FatalError("ceval: orphan tstate");
    }
}
/* Check for asynchronous exceptions. */

深入瞭解Python GIL

參考資料:#

python 原始碼分析 基本篇

python虛擬機器執行原理

CPython-Internals-frame-by-zpoint

相關文章