首頁 > 軟體

Python虛擬機器器棧幀物件及獲取原始碼學習

2023-03-23 22:00:59

Python虛擬機器器

注:本篇是根據教學學習記錄的筆記,部分內容與教學是相同的,因為轉載需要填連結,但是沒有,所以填的原創,如果侵權會直接刪除。此外,本篇內容大部分都諮詢了ChatGPT,為筆者解決了很多問題。

問題:

Python 程式執行過程與位元組碼中,我們研究了Python程式的編譯過程:通過Python直譯器中的編譯器對 Python 原始碼進行編譯,最終獲得程式碼物件 PyCodeObject 。編譯器根據語法規則對原始碼進行作用域的劃分,並以此為單位來編譯原始碼,最終為每個作用域生成一個程式碼物件。程式碼物件則儲存了位元組碼,以及相關名字、常數等靜態上下文資訊。

(上面這段話是原文章的作者總結的,我個人覺得還是很到位的,大家也可以再回顧一下這篇筆記的內容: Python 程式執行過程與位元組碼,更深刻體會下。)

那麼當我們得到了編譯產出的程式碼物件後,虛擬機器器是如何解析並執行其中的位元組碼指令的呢?與語法作用域相對應的執行時名稱空間,在虛擬機器器中又是如何動態維護的呢?

1. 棧幀物件

1.1 PyFrameObject

  • 當 Python 直譯器載入一個模組或者執行函數時,會為對應的 PyCodeObject 建立一個 PyFrameObject 物件,並將其壓入 Python 直譯器的執行棧中。以函數為例,PyFrameObject 物件表示函數呼叫的棧幀物件,它包含了函數呼叫時的所有狀態資訊,包括區域性變數、棧、當前指令等資訊。

具體地我們來看一下執行上下文的具體結構——PyFrameObject,原始碼如下:

typedef struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back;      /* previous frame, or NULL */
    PyCodeObject *f_code;       /* code segment */
    PyObject *f_builtins;       /* builtin symbol table (PyDictObject) */
    PyObject *f_globals;        /* global symbol table (PyDictObject) */
    PyObject *f_locals;         /* local symbol table (any mapping) */
    PyObject **f_valuestack;    /* points after the last local */
    /* Next free slot in f_valuestack.  Frame creation sets to f_valuestack.
       Frame evaluation usually NULLs it, but a frame that yields sets it
       to the current stack top. */
    PyObject **f_stacktop;
    PyObject *f_trace;          /* Trace function */
    char f_trace_lines;         /* Emit per-line trace events? */
    char f_trace_opcodes;       /* Emit per-opcode trace events? */
    /* Borrowed reference to a generator, or NULL */
    PyObject *f_gen;
    int f_lasti;                /* Last instruction if called */
    /* Call PyFrame_GetLineNumber() instead of reading this field
       directly.  As of 2.3 f_lineno is only valid when tracing is
       active (i.e. when f_trace is set).  At other times we use
       PyCode_Addr2Line to calculate the line from the current
       bytecode index. */
    int f_lineno;               /* Current line number */
    int f_iblock;               /* index in f_blockstack */
    char f_executing;           /* whether the frame is still executing */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
    PyObject *f_localsplus[1];  /* locals+stack, dynamically sized */
} PyFrameObject;

原始碼分析(只列出重要欄位):

思考:PyFrameObject為什麼沒有記錄閉包資訊?

  • f_back:表示當前棧幀的前一個棧幀,即呼叫當前函數的函數的棧幀。Python直譯器使用這個欄位來實現函數呼叫的遞迴和返回。如果當前函數是最外層函數,即沒有呼叫它的函數,則該欄位為NULL。
  • f_code:表示當前棧幀對應的 PyCodeObject 物件,即當前函數的位元組碼和相關資訊。Python 直譯器使用這個欄位來執行函數中的位元組碼指令。
  • f_builtins:表示當前棧幀的內建變數字典,即當前函數中存取的所有內建函數和物件的名稱和值。Python 直譯器使用這個欄位來實現對內建函數和物件的存取。
  • f_locals:表示當前棧幀的區域性變數字典,即當前函數的所有區域性變數的名稱和值。Python 直譯器使用這個欄位來實現變數的讀取和寫入操作。
  • f_lasti:表示當前棧幀執行的最後一條指令的指令碼在位元組碼序列中的索引。Python 直譯器使用這個欄位來記錄當前函數執行的進度,以便在函數被中斷或者函數返回時,能夠恢復到正確的執行位置。
  • f_lineno:表示當前棧幀執行的原始碼行號。Python 直譯器使用這個欄位來跟蹤當前函數的行號,以便在發生異常時能夠提供更準確的錯誤資訊。
  • f_localsplus:表示當前棧幀的棧頂指標,即當前函數呼叫的棧的頂部。Python 直譯器使用這個欄位來實現函數呼叫的引數傳遞和返回值傳遞。
  • PyFrameObject 物件本身不記錄閉包相關的資訊是出於設計上的考慮。一個主要的原因是為了保持執行棧的簡潔性和高效性。
  • 閉包是一種在 Python 中廣泛使用的程式設計模式,但是它在實現上是比較複雜的。在直譯器執行 Python 程式碼時,一個函數在定義時可能沒有參照外部變數,但是在執行時卻可能參照了。因此,如果要記錄函數中使用的外部變數,就需要在執行時動態地建立一個閉包物件,並將其與函數物件關聯起來。這就會給執行棧的實現帶來很大的複雜性。
  • 另一個原因是,閉包可能會被頻繁地建立和銷燬,而在執行棧中儲存大量的閉包資訊會導致執行效率變慢,甚至可能引起記憶體漏失。因此,Python 直譯器在設計執行棧時,選擇不記錄閉包相關的資訊,以保持執行棧的簡潔性和高效性。
  • 雖然 PyFrameObject 物件本身不記錄閉包相關的資訊,但是 Python 直譯器可以通過其他方式來獲取函數的閉包資訊,例如通過函數物件的 closure 屬性。

PyFrameObject結構圖如下:

  • 其中,f_code欄位儲存了當前執行的程式碼物件,最核心的位元組碼就在程式碼物件中。而f_lasti欄位則儲存著上條已執行位元組碼的編號。虛擬機器器內部用一個C區域性變數next_instr維護下條位元組碼的位置,並據此載入下一條待執行的位元組碼指令,原理和CPU的指令指標暫存器(%rip)一樣。
  • 另外,注意到f_back欄位執行前一個棧幀物件,也就是呼叫者的棧幀物件。這樣一來,棧幀物件按照呼叫關係串成一個呼叫鏈。(這裡和x86CPU棧幀佈局是如出一轍的,原作者在這裡介紹了x86CPU棧幀佈局與函數呼叫之間的關係,筆者能力有限就不介紹了,大家感興趣的可以自行查詢相關資料(主要還是微機原理和組合學的不是很好。。。))

1.2 棧幀物件鏈

現在,我們以具體例子來考察Python棧幀物件鏈以及函數呼叫之間的關係:

pi = 3.14
def square(r):
    return r ** 2
def circle_area(r):
    return pi * square(r)
def main():
    print(circle_area(5))
if __name__ == '__main__':
    main()

當Python開始執行這個程式時,虛擬機器器先建立一個棧幀物件,用於執行模組程式碼物件:

當虛擬機器器執行到模組程式碼第13行時,發生了函數呼叫。這時,虛擬機器器會新建一個棧幀物件,並開始執行函數main()的程式碼物件:

隨著函數呼叫逐層深入,當呼叫square()函數時,呼叫鏈達到最長:

當函數呼叫完畢後,虛擬機器器通過f_back欄位找到前一個棧幀物件並回到呼叫者程式碼中繼續執行。

1.3 棧幀獲取

棧幀物件PyFrameObject中儲存著Python執行時資訊,在底層執行流控制以及程式偵錯中非常有用。在Python程式碼層面,我們可以通過sys模組中的_getframe()函數,即可獲得當前棧幀物件:

>>> import sys
>>> frame = sys._getframe()
>>> frame
<frame at 0x00000183FA78F870, file '<pyshell#1>', line 1, code <module>>
>>> dir(frame)
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'clear', 'f_back', 'f_builtins', 'f_code', 'f_globals', 'f_lasti', 'f_lineno', 'f_locals', 'f_trace', 'f_trace_lines', 'f_trace_opcodes']

拿到棧幀物件之後,我們來具體看一下相關的屬性值,以之前的求面積的函數為例:

>>> import sys
>>> pi = 3.14
>>> def square(r):
        frame = sys._getframe()
        while frame:
            print('name:', frame.f_code.co_name)
            print('Locals', list(frame.f_locals.keys()))
            print('Globals', list(frame.f_globals.keys()))
            print('===========')
            frame = frame.f_back
        return r ** 2
>>> def circle_area(r):
        return pi * square(r)
>>> def main():
        print(circle_area(2))
>>> if __name__ == '__main__':
        main()
name: square
Locals ['r', 'frame']
Globals ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__annotations__', '__builtins__', '__file__', '__cached__', 'sys', 'pi', 'square', 'circle_area', 'main']
===========
name: circle_area
Locals ['r']
Globals ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__annotations__', '__builtins__', '__file__', '__cached__', 'sys', 'pi', 'square', 'circle_area', 'main']
===========
name: main
Locals []
Globals ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__annotations__', '__builtins__', '__file__', '__cached__', 'sys', 'pi', 'square', 'circle_area', 'main']
===========
name: <module>
Locals ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__annotations__', '__builtins__', '__file__', '__cached__', 'sys', 'pi', 'square', 'circle_area', 'main']
Globals ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__annotations__', '__builtins__', '__file__', '__cached__', 'sys', 'pi', 'square', 'circle_area', 'main']
===========
12.56

小拓展:自定義函數實現sys._getframe()功能:(這裡是原作者舉的一個例子,個人感覺對相關知識的理解是有幫助的)

當Python程式丟擲異常時,會將執行上下文帶出來,儲存在異常中:

>>> try:
        1 / 0
    except Exception as e:
        print(e.__traceback__.tb_frame)
<frame at 0x000002440D95BC50, file '<pyshell#5>', line 4, code <module>>

因此,我們可以自定義一個getframe()函數:

>>> def getframe():
        try:
            1 / 0
        except Exception as e:
            return e.__traceback__.tb_frame.f_back

注意:getframe()中通過異常獲得的是自己的棧幀物件e.traceback.tb_frame,所以還需要通過f_back欄位找到呼叫者的棧幀。

2. 位元組碼執行

Python 虛擬機器器執行程式碼物件的主要函數有兩個:

PyEval_EvalCodeEx() 是通用介面,一般用於函數這樣帶引數的執行場景:

PyObject *
PyEval_EvalCodeEx(PyObject *_co, PyObject *globals, PyObject *locals,
                  PyObject *const *args, int argcount,
                  PyObject *const *kws, int kwcount,
                  PyObject *const *defs, int defcount,
                  PyObject *kwdefs, PyObject *closure);

PyEval_EvalCode() 是更高層封裝,用於模組等無引數的執行場景:

PyObject *
PyEval_EvalCode(PyObject *co, PyObject *globals, PyObject *locals);

這兩個函數最終呼叫 _PyEval_EvalCodeWithName() 函數,初始化棧幀物件並呼叫 PyEval_EvalFrame 系列函數進行處理。棧幀物件將貫穿程式碼物件執行的始終,負責維護執行時所需的一切上下文資訊。而PyEval_EvalFrame 系列函數最終呼叫 _PyEval_EvalFrameDefault() 函數,虛擬機器器執行的核心就在這裡(具體原始碼這裡就不講解了)。

PyObject *
PyEval_EvalFrame(PyFrameObject *f);
PyObject *
PyEval_EvalFrameEx(PyFrameObject *f, int throwflag);
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag);

文章後續以順序執行、if判斷、while迴圈詳細講解了位元組碼的執行過程,這裡筆者就不贅述了。

以上就是Python虛擬機器器棧幀物件及獲取原始碼學習的詳細內容,更多關於Python虛擬機器器棧幀物件獲取的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com