1. 程式人生 > 實用技巧 >11. 深入Python虛擬機器,探索虛擬機器執行位元組碼的奧祕

11. 深入Python虛擬機器,探索虛擬機器執行位元組碼的奧祕

楔子

這一次我們就來剖析Python執行位元組碼的原理,我們知道Python虛擬機器是Python的核心,在原始碼被編譯成PyCodeObject物件時,就將由Python虛擬機器接手整個工作。Python虛擬機器會從PyCodeObject中讀取位元組碼,並在當前的上下文中執行,直到所有的位元組碼都被執行完畢。

Python虛擬機器的執行環境

Python的虛擬機器實際上是在模擬作業系統執行可執行檔案的過程,我們先來看看在一臺普通的x86的機器上,可執行檔案是以什麼方式執行的。在這裡主要關注執行時棧的棧幀,如圖所示:

x86體系處理器通過棧維護呼叫關係,每次函式呼叫時就在棧上分配一個幀用於儲存呼叫上下文以及臨時儲存。CPU中有兩個關鍵暫存器,rsp指向當前棧頂,rbp指向當然棧幀。每次呼叫函式時,呼叫者(Caller)負責準備引數、儲存返回地址,並跳轉到被呼叫函式中執行程式碼;作為被呼叫者(Callee),函式先將當前rbp暫存器壓入棧,並將rbp設為當前棧頂(儲存當前新棧幀的位置)。由此,rbp暫存器與每個棧幀中儲存呼叫者棧幀地址一起完美地維護了函式呼叫關係鏈。

我們以Python中的程式碼為例:

def f(a, b):
    return a + b

def g():
    return f()

g()

當程式進入到函式 f 中執行時,那麼顯然呼叫者的幀就是函式 g 的棧幀,而當前幀則是 f 的棧幀。

解釋一下:棧是先入後出的資料結構,從棧頂到棧底地址是增大的。對於一個函式而言,其所有對區域性變數的操作都在自己的棧幀中完成,而呼叫函式的時候則會為呼叫的函式建立新的棧幀。

在上圖中,我們看到執行時棧的地址是從高地址向低地址延伸的。當在函式 g 中呼叫函式 f 的時候,系統就會在地址空間中,於 g 的棧幀之後建立 f 的棧幀。當然在函式呼叫的時候,系統會儲存上一個棧幀的棧指標(rsp)和幀指標(rbp)。當函式的呼叫完成時,系統就又會把rsp和rbp的值恢復為建立 f 棧幀之前的值,這樣程式的流程就又回到了 g 函式中,當然程式的執行空間則也又回到了函式g的棧幀中,這就是可執行檔案在x86機器上的執行原理。

而上一章我們說Python原始碼經過編譯之後,所有位元組碼指令以及其他靜態資訊都儲存在PyCodeObject當中,那麼是不是意味著Python虛擬機器就在PyCodeObject物件上進行所有的動作呢?其實不能給出唯一的答案,因為儘管PyCodeObject包含了關鍵的位元組碼指令以及靜態資訊,但是有一個東西,是沒有包含、也不可能包含的,就是程式執行的動態資訊--執行環境。

var = "satori"


def f():
    var = 666
    print(var)

f()
print(var)

首先程式碼當中出現了兩個print(var),它們的位元組碼指令是相同的,但是執行的效果卻顯然是不同的,這樣的結果正是執行環境的不同所產生的。因為環境的不同,var的值也是不同的。因此同一個符號在不同環境中對應不同的型別、不同的值,必須在執行時進行動態地捕捉和維護,這些資訊是不可能在PyCodeObject物件中被靜態的儲存的。

所以我們還需要執行環境,這裡的執行環境和我們下面將要說的名字空間比較類似(名字空間暫時就簡單地理解為作用域即可)。但是名字空間僅僅是執行環境的一部分,除了名字空間,在執行環境中,還包含了其他的一些資訊。

因此對於上面程式碼,我們可以大致描述一下流程:

  • 當python在執行第一條語句時,已經建立了一個執行環境,假設叫做A
  • 所有的位元組碼都會在這個環境中執行,Python可以從這個環境中獲取變數的值,也可以修改。
  • 當發生函式呼叫的時候,Python會在執行環境A中呼叫函式f的位元組碼指令,會在執行環境A之外重新建立一個執行環境B
  • 在環境B中也有一個名字為var的物件,但是由於環境的不同,var也不同。兩個人都叫小明,但一個是北京的、一個是上海的,所以這兩者沒什麼關係
  • 一旦當函式f的位元組碼指令執行完畢,會將當前f的棧幀銷燬(也可以保留下來),再回到呼叫者的棧幀中來。就像是遞迴一樣,每當呼叫函式就會建立一個棧幀,一層一層建立,一層一層返回。

所以Python在執行時的時候,並不是在PyCodeObject物件上執行操作的,而是我們一直在說的棧幀物件(PyFrameObject),從名字也能看出來,這個棧幀也是一個物件。

Python原始碼中的PyFrameObject

對於Python而言,PyFrameObject可不僅僅只是類似於x86機器上看到的那個簡簡單單的棧幀,Python中的PyFrameObject實際上包含了更多的資訊。

typedef struct _frame {
    PyObject_VAR_HEAD  		/* 可變物件的頭部資訊 */
    struct _frame *f_back;      /* 上一級棧幀, 也就是呼叫者的棧幀 */
    PyCodeObject *f_code;       /* PyCodeObject物件, 通過棧幀物件的f_code可以獲取對應的PyCodeObject物件 */
    PyObject *f_builtins;       /* builtin名稱空間,一個PyDictObject物件 */
    PyObject *f_globals;        /* global名稱空間,一個PyDictObject物件 */
    PyObject *f_locals;         /* local名稱空間,一個PyDictObject物件  */
    PyObject **f_valuestack;    /* 執行時的棧底位置 */

    PyObject **f_stacktop;      /* 執行時的棧頂位置 */
    PyObject *f_trace;          /* 回溯函式,列印異常棧 */
    char f_trace_lines;         /* 是否觸發每一行的回溯事件 */
    char f_trace_opcodes;       /* 是否觸發每一個操作碼的回溯事件 */

    PyObject *f_gen;            /* 是否是生成器 */

    int f_lasti;                /* 上一條指令在f_code中的偏移量 */

    int f_lineno;               /* 當前位元組碼對應的原始碼行 */
    int f_iblock;               /* 當前指令在棧f_blockstack中的索引 */
    char f_executing;           /* 當前棧幀是否仍在執行 */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* 用於try和loop程式碼塊 */
    PyObject *f_localsplus[1];  /* 動態記憶體,維護區域性變數+cell物件集合+free物件集合+執行時棧所需要的空間 */
} PyFrameObject;

因此我們看到,Python會根據PyCodeObject物件來建立一個棧幀物件(或者直接說棧幀也行),也就是PyFrameObject物件,虛擬機器實際上是在PyFrameObject物件上執行操作的。每一個PyFrameObject都會維護一個PyCodeObject,換句話說,每一個PyCodeObject都會隸屬於一個PyFrameObject。並且從f_back中可以看出,在Python的實際執行過程中,會產生很多PyFrameObject物件,而這些物件會被連結起來,形成一條執行環境連結串列,這正是x86機器上棧幀之間關係的模擬。在x86機器上,棧幀間通過rsp和rbp指標建立了聯絡,使得新棧幀在結束之後能夠順利的返回到舊棧幀中,而Python則是利用f_back來完成這個動作。

裡面f_code成員是一個指標,指向相應的PyCodeObject物件,而接下來的f_builtins、f_globals、f_locals是三個獨立的名字空間,在這裡我們看到了名字空間和執行環境(即棧幀)之間的關係。名字空間實際上是維護這變數名和變數值的PyDictObject物件,所以在這三個PyDictObject物件中分別維護了各自name和value的對應關係。

在PyFrameObject的開頭,有一個PyObject_VAR_HEAD,表示棧幀是一個變長物件,即每一次建立PyFrameObject物件大小可能是不一樣的,那麼變動在什麼地方呢?首先每一個PyFrameObject物件都維護了一個PyCodeObject物件,而每一個PyCodeObject物件都會對應一個程式碼塊(code block)。在編譯一段程式碼塊的時候,會計算這段程式碼塊執行時所需要的棧空間的大小,這個棧空間大小儲存在PyCodeObject物件的co_stacksize中。而不同的程式碼塊所需要的棧空間是不同的,因此PyFrameObject的開頭要有一個PyObject_VAR_HEAD物件。最後其實PyFrameObject裡面的記憶體空間分為兩部分,一部分是編譯程式碼塊需要的空間,另一部分是計算所需要的空間,我們也稱之為"執行時棧"。

注意:x86機器上執行時的執行時棧不止包含了計算(還有別的)所需要的記憶體空間,但PyFrameObject物件的執行時棧則只包含計算所需要的記憶體空間,這一點務必注意。

在python中訪問PyFrameObject物件

在Python中獲取棧幀,我們可以使用inspect模組。

import inspect


def f():
    # 返回當前所在的棧幀, 這個函式實際上是呼叫了sys._getframe(1)
    return inspect.currentframe()


frame = f()
print(frame)  # <frame at 0x000001FE3D6E69F0, file 'D:/satori/1.py', line 6, code f>
print(type(frame))  # <class 'frame'>

我們看到棧幀的型別是<class 'frame'>,正如PyCodeObject物件的型別是<class 'code'>一樣。還是那句話,這兩個類Python直譯器沒有暴露給我們,所以不可以直接使用。同理,還有Python的函式,型別是<class 'function'>;模組,型別是<class 'module'>,這些Python直譯器都沒有給我們提供,如果直接使用的話,那麼frame、code、function、module只是幾個沒有定義的變數罷了,這些類我們只能通過這種間接的方式獲取。

下面我們就來獲取一下棧幀的成員屬性

import inspect


def f():
    global frame
    name = "夏色祭"
    age = -1
    return inspect.currentframe()


def g():
    name = "神樂mea"
    age = 38
    return f()


# 當我們呼叫函式g的時候, 也會觸發函式f的呼叫
# 而一旦f執行完畢, 那麼f對應的棧幀就被全域性變數frame儲存起來了
frame = g()

print(frame)  # <frame at 0x00000194046863C0, file 'D:/satori/1.py', line 8, code f>

# 獲取上一級棧幀, 即呼叫者的棧幀, 顯然是g的棧幀
print(frame.f_back)  # <frame at 0x00000161C79169F0, file 'D:/satori/1.py', line 14, code g>

# 模組也是有棧幀的, 我們後面會單獨說
print(frame.f_back.f_back)  # <frame at 0x00000174CE997840, file 'D:/satori/1.py', line 25, code <module>>
# 顯然最外層就是模組了, 模組對應的上一級棧幀是None
print(frame.f_back.f_back.f_back)  # None

# 獲取PyCodeObject物件
print(frame.f_code)  # <code object f at 0x00000215D560D450, file "D:/satori/1.py", line 4>
print(frame.f_code.co_name)  # f

# 獲取f_locals, 即棧幀內部的local名字空間
print(frame.f_locals)  # {'name': '夏色祭', 'age': -1}
print(frame.f_back.f_locals)  # {'name': '神樂mea', 'age': 38}
"""
另外我們看到函式執行完畢之後裡面的區域性變數居然還能獲取
原因就是棧幀沒被銷燬, 因為它被返回了, 而且被外部變數接收了
同理:該棧幀的上一級棧幀也不能被銷燬, 因為當前棧幀的f_back指向它了, 引用計數不為0, 所以要保留
"""

# 獲取棧幀對應的行號
print(frame.f_lineno)  # 8
print(frame.f_back.f_lineno)  # 14
"""
行號為8的位置是: return inspect.currentframe()
行號為14的位置是: return f()
"""

通過棧幀我們可以獲取很多的屬性,我們後面還會慢慢說。

此外,異常處理也可以獲取到棧幀。

def foo():
    try:
        1 / 0
    except ZeroDivisionError:
        import sys
        # exc_info返回一個三元組,分別是異常的型別、值、以及traceback
        exc_type, exc_value, exc_tb = sys.exc_info()
        print(exc_type)  # <class 'ZeroDivisionError'>
        print(exc_value)  # division by zer
        print(exc_tb)  # <traceback object at 0x00000135CEFDF6C0>
        
        # 呼叫exc_tb.tb_frame即可拿到異常對應的棧幀
        # 另外這個exc_tb也可以通過except ZeroDivisionError as e; e.__traceback__的方式獲取
        print(exc_tb.tb_frame.f_back)  # <frame at 0x00000260C1297840, file 'D:/satori/1.py', line 17, code <module>>
        # 因為foo是在模組級別、也就是最外層呼叫的,所以tb_frame是當前函式的棧幀、那麼tb_frame.f_back就是整個模組對應的棧幀
        # 那麼再上一級的話, 棧幀就是None了
        print(exc_tb.tb_frame.f_back.f_back)  # None


foo()

名字、作用域、名字空間

我們在PyFrameObject裡面看到了3個獨立的名字空間:f_locals、f_globals、f_builtins。名字空間對於Python來說是一個非常重要的概念,整個Python虛擬機器執行的機制和名字空間有著非常緊密的聯絡。並且在Python中,與名稱空間這個概念緊密聯絡著的還有"名字"、"作用域"這些概念,下面就來剖析這些概念是如何實現的。

Python中的變數只是一個名字

很早的時候我們就說過,Python中的變數在底層一個泛型指標PyObject *,而在Python的層面上來說,變數只是一個名字、或者說符號,用於和物件進行繫結的。變數的定義本質上就是建立名字和物件之間的約束關係,所以a = 1這個賦值語句本質上就是將符號a和1對應的PyLongObject繫結起來,讓我們通過a可以找到對應的PyLongObject。

除了變數賦值,函式定義、類定義也相當於定義變數,或者說完成名字和物件之間的繫結。

def foo(): pass


class A(): pass

定義一個函式也相當於定義一個變數,會先根據函式體建立一個函式物件,然後將名字foo和函式物件繫結起來,所以函式名和函式體之間是分離的,同理類也是如此。

再有匯入一個模組,也相當於定義一個變數。

import os

import os,相當於將名字os和模組物件繫結起來,通過os可以訪問模組裡面的屬性。或者import numpy as np當中的as語句也相當於定義一個變數,將名字np和對應的模組物件繫結起來,以後就可以通過np這個名字去訪問模組內部的屬性了。

另外,當我們匯入一個模組的時候,直譯器是這麼做的。比如:import os等價於os = __import__("os"),可以看到本質上還是一個賦值語句。

作用域和名字空間

我們說賦值語句、函式定義、類定義、模組匯入,本質上只是完成了名字和物件之間的繫結。而從概念上將,我們實際上得到了一個nameobj這樣的對映關係,通過name獲取對應的obj,而它們的容身之所就是名字空間。而名字空間是通過PyDictObject物件實現的,這對於對映來說簡直再適合不過了,所以字典在Python底層也是被大量使用的,因此是經過高度優化的。

但是一個模組內部,名字還存在可見性的問題,比如:

a = 1

def foo():
    a = 2
    print(a)  # 2

foo()
print(a)  # 1

我們看到同一個變數名,列印的確實不同的值,說明指向了不同的物件。換句話說這兩個變數是在不同的名字空間中被建立的,我們知道名字空間本質上是一個字典,如果兩者是在同一個名字空間,那麼由於字典的key的不重複性,那麼當我進行a=2的時候,會把字典裡面key為'a'的value給更新掉,但是在外面還是列印為1,這說明,兩者所在的不是同一個名字空間。在不同的名字空間,列印的也就自然不是同一個a。

因此對於一個模組而言,內部是可能存在多個名字空間的,每一個名字空間都與一個作用域相對應。作用域就可以理解為一段程式的正文區域,在這個區域裡面定義的變數是有作用的,然而一旦出了這個區域,就無效了。

對於作用域這個概念,至關重要的是要記住它僅僅是由源程式的文字所決定的。在Python中,一個變數在某個位置是否起作用,是由其在文字位置是否唯一決定的。因此,Python是具有靜態作用域(詞法作用域)的,而名字空間則是作用域的動態體現。一個由程式文字定義的作用域在Python執行時就會轉化為一個名字空間、即一個PyDictObject物件。也就是說,在函式執行時,會為建立一個名字空間,這一點在以後剖析函式時會詳細介紹。

我們之前說Python在對Python原始碼進行編譯的時候,對於程式碼中的每一個block,都會建立一個PyCodeObject與之對應。而當進入一個新的名字空間、或者說作用域時,我們就算是進入了一個新的block了。相信此刻你已經明白了,而且根據我們使用Python的經驗,顯然函式、類都是一個新的block,當Python執行的時候會它們建立各自的名字空間。

所以名字空間是名字、或者變數的上下文環境,名字的含義取決於名稱空間。更具體的說,一個變數名對應的變數值什麼,在Python中是不確定的,需要名字空間來決定。

位於同一個作用域中的程式碼可以直接訪問作用域中出現的名字,即所謂的"直接訪問",也就是不需要通過屬性引用的訪問修飾符:.

class A:
    a = 1


class B:
    b = 2
    print(A.a)  # 1
    print(b)  # 2

比如:B裡面想訪問A裡面的內容,比如通過A.屬性的方式,表示通過A來獲取A裡面的屬性。但是訪問B的內容就不需要了,因為都是在同一個作用域,所以直接訪問即可。

訪問名字這樣的行為被稱為名字引用,名字引用的規則決定了Python程式的行為。

a = 1

def foo():
    a = 2
    print(a)  # 2

foo()
print(a)  # 1

還是對於上面的程式碼,如果我們把函式裡面的a=2給刪掉,那麼顯然作用域裡面已經沒有a這個變數的,那麼再執行程式會有什麼後果呢?從Python層面來看,顯然是會尋找外部的a。因此我們可以得到如下結論:

  • 作用域是層層巢狀的,顯然是這樣,畢竟python虛擬機器操作的是PyFrameObject物件,而PyFrameObject物件也是巢狀的,當然還有PyCodeObject
  • 內層的作用域是可以訪問外層作用域的
  • 外層作用域無法訪問內層作用域,儘管我們沒有試,但是想都不用想,如果把外層的a=1個去掉,那麼最後面的print(a)鐵定報錯。因為外部的作用域算是屬於頂層了(先不考慮builtin)
  • 查詢元素會依次從當前作用域向外查詢,也就是查詢元素對應的作用域是按照從小往大、從裡往外的方向前進的,到了最外層還沒有,就真沒有了(先不考慮builtin)

LGB規則

我們說函式、類是有自己的作用域的,但是模組對應的原始檔本身也有相應的作用域。比如:

# a.py
name = "夏色祭"
age = -1


def foo():
    return 123

class A:
    pass

由於這個檔案本身也有自己的作用域(顯然是global作用域),所以Python直譯器在執行a.py這個檔案的時候,也會為其建立一個名字空間,而顯然這個名字空間就是global名字空間。它裡面的變數是全域性的,或者說是模組級別的,在當前的檔案內可以直接訪問。

而函式也會有一個作用域,這個作用域稱為local作用域(對應local名字空間);同時Python自身還定義了一個最頂層的作用域,也就是builtin作用域(比如:dir、range、open都是builtin裡面的)。這三個作用域在python2.2之前就存在了,所以那時候Python的作用域規則被稱之為LGB規則:名字引用動作沿著local作用域(local名字空間)、global作用域(global名字空間)、builtin作用域(builtin名字空間)來查詢對應的變數。

而獲取名字空間,Python也提供了相應的內建函式:

  • locals函式: 獲取當前作用域的local名字空間, local名字空間也稱為區域性名字空間
  • globals函式: 獲取當前作用域的global名字空間, global名字空間也稱為全域性名字空間

對於global名字空間來說,它對應一個字典,並且這個字典是全域性唯一的,全域性變數都儲存在這裡面。

name = "夏色祭"
age = -1


def foo():
    name = "神樂mea"
    age = 38


print(globals())  # {..., 'name': '夏色祭', 'age': -1, 'foo': <function foo at 0x0000020BF60851F0>}

裡面的...表示省略了一部分輸出,我們看到建立的全域性變數都在裡面了。而且foo也是一個變數,它指向一個函式物件,我們說foo也對應一個PyCodeObject。但是在解釋到def foo的時候,便會根據這個PyCodeObject物件建立一個PyFunctionObject物件,然後將foo和這個函式物件繫結起來。當我們呼叫foo的時候,會根據PyFunctionObject物件再建立PyFrameObject物件、然後執行,這些留在介紹函式的時候再細說。總之,我們看到foo也是一個全域性變數,全域性變數都在global名字空間中。

global名字空間全域性唯一,它是程式執行時全域性變數和與之繫結的物件的容身之所,你在任何一個地方都可以訪問到global名字空間。正如,你在任何一個地方都可以訪問相應的全域性變數一樣。

此外,我們說名字空間是一個字典,變數和變數指向的值會以鍵值對的形式存在裡面。那麼換句話說,如果我手動的往這個global名字空間裡面新增一個鍵值對,是不是也等價於定義一個全域性變數呢?

globals()["name"] = "夏色祭"
print(name)  # 夏色祭


def f1():
    def f2():
        def f3():
            globals()["age"] = -1
        return f3
    return f2


f1()()()
print(age)  # -1

我們看到確實如此,通過往global名字空間裡面插入一個鍵值對完全等價於定義一個全域性變數。並且我們看到global名字空間是全域性唯一的,你在任何地方呼叫globals()得到的都是global名字空間,正如你在任意地方都可以訪問到全域性變數一樣。所以即使是在函式中像global名字空間中插入一個鍵值對,也等價於定義一個全域性變數、並和物件繫結起來。

  • name = "夏色祭"等價於 globals["name"] = "夏色祭"
  • print(name)等價於print(globals["name"])

對於local名字空間來說,它也對應一個字典,顯然這個字典是就不是全域性唯一的了,每一個作用域都會對應自身的local名字空間。

def f():
    name = "夏色祭"
    age = -1
    return locals()


def g():
    name = "神樂mea"
    age = 38
    return locals()


print(locals() == globals())  # True
print(f())  # {'name': '夏色祭', 'age': -1}
print(g())  # {'name': '神樂mea', 'age': 38}

顯然對於模組來講,它的local名字空間和global名字空間是一樣的,也就是說模組對應的PyFrameObject物件裡面的f_locals和f_builtins指向的是同一個PyDictObject物件。

但是對於函式而言,區域性名字空間和全域性名字空間就不一樣了。而呼叫locals也是獲取自身的區域性名字空間,因此不同的函式的local名字空間是不同的,而呼叫locals函式返回結果顯然取決於呼叫它的位置。但是globals函式的呼叫結果是一樣的,獲取的都是global名字空間,這也符合"函式內找不到某個變數的時候會去找全域性變數"這一結論。

所以我們說在函式裡面查詢一個變數,查詢不到的話會找全域性變數,全域性變數再沒有會查詢內建變數。本質上就是按照自身的local空間、外層的global空間、內建的builtin空間的順序進行查詢。因此local空間會有很多個,因為每一個函式或者類都有自己的區域性作用域,這個區域性作用域就可以稱之為該函式的local空間;但是global空間則全域性唯一,因為該字典儲存的是全域性變數,無論你在什麼地方,通過globals拿到的永遠全域性變數對應的名字空間,向該空間中新增鍵值對,等價於建立全域性變數。

對於builtin名稱空間,它也是一個字典。當local空間、global空間都沒有的時候,會去builtin空間查詢。

name = "夏色祭"
age = -1


def f1():
    name = "神樂mea"
    # local空間有"name"這個key, 直接從區域性名字空間獲取
    print(name)
    # 但是當前的local空間沒有"age"這個key, 所以會從global空間查詢
    # 從這裡也能看出為什麼函式也能訪問到global空間了
    # 如果函式內訪問不到的話, 那麼它怎麼能夠在區域性變數找不到的時候去找全域性變數呢
    print(age)

    # 但是local空間、global空間都沒有"int"這個key, 所以要去builtin空間查找了
    print(int)

    # "xxx"的話, 三個空間都沒有, 那麼結果只能是NameError了
    print(xxx)


f1()
"""
神樂mea
-1
<class 'int'>

...
File "D:/satori/1.py", line 18, in f1
    print(xxx)
NameError: name 'xxx' is not defined
"""

問題來了,builtin名字空間如何獲取呢?答案是通過builtins模組。

import builtins

# 我們呼叫int、str、list顯然是從內建作用域、也就是builtin名稱空間中查詢的
# 即使我們只通過list也是可以的, 因為local空間、global空間沒有的話, 最終會從builtin空間中查詢,
# 但如果是builtins.list, 那麼就不兜圈子了, 表示: "builtin空間,就從你這獲取了"
print(builtins.list is list)  # True

builtins.dict = 123
# 將builtin空間的dict改成123,那麼此時獲取的dict就是123,因為是從內建作用域中獲取的
print(dict + 456)  # 579

str = 123
# 如果是str = 123,等價於建立全域性變數str = 123,顯然影響的是global空間,而查詢顯然也會先從global空間查詢
print(str)  # 123
# 但是此時不影響內建作用域
print(builtins.str)  # <class 'str'>

這裡提一下Python2當中,while 1比while True要快,為什麼?

因為True在Python2中不是關鍵字,所以它是可以作為變數名的,那麼python在執行的時候就要先看local空間和global空間中有沒有True這個變數,有的話使用我們定義的,沒有的話再使用內建的True,而1是一個常量直接載入就可以。所以while True它多了符號查詢這一過程,但是在Python3中兩者就等價了,因為True在python3中是一個關鍵字,所以會直接作為一個常量來載入。

這裡再提一下函式的local空間

我們說:globals["name"] = "夏色祭"等價於定義一個全域性變數name = "夏色祭",那麼如果是在函式裡面執行了locals["name"] = "夏色祭",是不是等價於建立區域性變數name = "夏色祭"呢?

def f1():
    locals()["name "] = "夏色祭"
    try:
        print(name)
    except Exception as e:
        print(e)

f1()  # name 'name' is not defined

我們說對於全域性變數來講,變數的建立是通過向字典新增鍵值對的方式實現的。因為全域性變數會一直在變,需要使用字典來動態維護。但是對於函式來講,內部的變數是通過靜態方式訪問的,因為其區域性作用域中存在哪些變數在編譯的時候就已經確定了,我們通過PyCodeObject的co_varnames即可獲取內部都有哪些變數。

所以雖然我們說查詢是按照LGB的方式查詢,但是訪問函式內部的變數其實是靜態訪問的,不過完全可以按照LGB的方式理解。

所以名字空間可以說是Python的靈魂,因為它規定了Python變數的作用域,使得Python對變數的查詢變得非常清晰。

LEGB規則

我們上面說的LGB是針對Python2.2之前的,那麼Python2.2開始,由於引入了巢狀函式,顯然最好的方式應該是內層函式找不到應該首先去外層函式找,而不是直接就跑到global空間、也就是全局裡面找,那麼此時的規則就是LEGB。

a = 1

def foo():
    a = 2

    def bar():
        print(a)
    return bar


f = foo()
f()
"""
2
"""

呼叫f,實際上呼叫的是bar函式,最終輸出的結果是2。如果按照LGB的規則來查詢的話。bar函式的作用域沒有a、那麼應該到全局裡面找,列印的應該是1才對。但是我們之前說了,作用域僅僅是由文字決定的,函式bar位於函式foo之內,所以bar函式定義的作用域內嵌與函式foo的作用域之內。換句話說,函式foo的作用域是函式bar的作用域的直接外圍作用域,所以首先是從foo作用域裡面找,如果沒有那麼再去全局裡面找。而作用域和名字空間是對應的,所以最終列印了2。

因此在執行f = foo()的時候,會執行函式foo中的def bar():語句,這個時候Python會將a=2與函式bar對應的函式物件捆綁在一起,將捆綁之後的結果返回,這個捆綁起來的整體稱之為閉包。

所以:閉包 = 內層函式 + 引用的外層作用域

這裡顯示的規則就是LEGB,其中E成為enclosing,代表直接外圍作用域這個概念。

global表示式

有一個很奇怪的問題,最開始學習python的時候,筆者也為此困惑了一段時間,下面我們來看一下。

a = 1

def foo():
    print(a)

foo()
"""
1
"""

首先這段程式碼列印1,這顯然是沒有問題的,但是下面問題來了。

a = 1

def foo():
    print(a)
    a = 2

foo()
"""
Traceback (most recent call last):
  File "C:/Users/satori/Desktop/love_minami/a.py", line 8, in <module>
    foo()
  File "C:/Users/satori/Desktop/love_minami/a.py", line 5, in foo
    print(a)
UnboundLocalError: local variable 'a' referenced before assignment
"""

這裡我僅僅是在print下面,在當前作用域又新建了一個變數a,結果就告訴我區域性變數a在賦值之前就被引用了,這是怎麼一回事,相信肯定有人為此困惑。

弄明白這個錯誤的根本就在於要深刻理解兩點:

  • 一個賦值語句所定義的變數在這個賦值語句所在的作用域裡都是可見的
  • 函式中的變數是靜態儲存、靜態訪問的, 內部有哪些變數在編譯的時候就已經確定

在編譯的時候,因為存在a = 2這條語句,所以知道函式中存在一個區域性變數a,那麼查詢的時候就會在區域性空間中查詢。但是還沒來得及賦值,就print(a)了,所以報錯:區域性變數a在賦值之前就被引用了。但如果沒有a = 2這條語句則不會報錯,因為知道區域性作用域中不存在a這個變數,所以會找全域性變數a,從而列印1。

更有趣的東西隱藏在位元組碼當中,我們可以通過反彙編來檢視一下:

import dis

a = 1


def g():
    print(a)

dis.dis(g)
"""
  7           0 LOAD_GLOBAL              0 (print)
              2 LOAD_GLOBAL              1 (a)
              4 CALL_FUNCTION            1
              6 POP_TOP
              8 LOAD_CONST               0 (None)
             10 RETURN_VALUE
"""

def f():
    print(a)
    a = 2

dis.dis(f)
"""
 12           0 LOAD_GLOBAL              0 (print)
              2 LOAD_FAST                0 (a)
              4 CALL_FUNCTION            1
              6 POP_TOP

 13           8 LOAD_CONST               1 (2)
             10 STORE_FAST               0 (a)
             12 LOAD_CONST               0 (None)
             14 RETURN_VALUE
"""

中間的序號代表位元組碼的偏移量,我們看第二條,g的位元組碼是LOAD_GLOBAL,意思是在global名字空間中查詢,而f的位元組碼是LOAD_FAST,表示在local名字空間中查詢名字。這說明Python採用了靜態作用域策略,在編譯的時候就已經知道了名字藏身於何處。

因此上面的例子表明,一旦作用域有了對某個名字的賦值操作,這個名字就會在作用域中可見,就會出現在local名字空間中,換句話說,就遮蔽了外層作用域中相同的名字。

但有時我們想要在函式裡面修改全域性變數呢?當然Python也為我們精心準備了global關鍵字,比如函式內部出現了global a,就表示我後面的a是全域性的,你要到global名字空間裡面找,不要在local空間裡面找了

a = 1

def bar():
    def foo():
        global a
        a = 2
    return foo

bar()()
print(a)  # 2

但是如果外層函式裡面也出現了a,我們想找外層函式裡面的a而不是全域性的a,該怎麼辦呢?Python同樣為我們準備了關鍵字: nonlocal,但是nonlocal的時候,必須確保自己是內層函式。

a = 1

def bar():
    a = 2
    def foo():
        nonlocal a
        a = "xxx"
    return foo

bar()()
print(a)  # 1
# 外界依舊是1

屬性引用與名稱引用

屬性引用實質上也是一種名稱引用,其本質都是到名稱空間中去查詢一個名稱所引用的物件。這個就比較簡單了,比如a.xxx,就是到a裡面去找xxx,這個規則是不受LEGB作用域限制的,就是到a裡面查詢,有就是有、沒有就是沒有。

這個比較簡單,但是有一點我們需要注意,那就是我們說屬性查詢會按照LEGB的規則,但是僅僅限制在自身所在的模組內。舉個栗子:

# a.py
print(name)
# b.py
name = "夏色祭"
import a

關於模組的匯入我們後面系列中會詳細說,總之目前在b.py裡面執行的import a,你可以簡單認為就是把a.py裡面的內容拿過來執行一遍即可,所以這裡相當於print(name)。

但是執行b.py的時候會提示變數name沒有被定義,可是把a導進來的話,就相當於print(name),而我們上面也定義name這個變量了呀。顯然,即使我們把a匯入了進來,但是a.py裡面的內容依舊是處於一個模組裡面。而我們也說了,名稱引用雖然是LEGB規則,但是無論如何都無法越過自身的模組的,print(name)是在a.py裡面的,而變數name被定義在b.py中,所以是不可能跨過模組a的作用域去訪問模組b裡面的內容的。

所以模組整體也有一個作用域,就是該模組的全域性作用域,每個模組是相互獨立的。所以我們發現每個模組之間作用域還是劃分的很清晰的,都是相互獨立的。

關於模組,我們後續會詳細說。總之通過.的方式本質上都是去指定的名稱空間中查詢對應的屬性。

屬性空間

我們知道,自定義的類中如果沒有__slots__,那麼這個類的例項物件都會有一個屬性字典。

class Girl:

    def __init__(self):
        self.name = "夏色祭"
        self.age = -1


g = Girl()
print(g.__dict__)  # {'name': '夏色祭', 'age': -1}

# 對於查詢屬性而言, 也是去屬性字典中查詢
print(g.name, g.__dict__["name"])

# 同理設定屬性, 也是更改對應的屬性字典
g.__dict__["gender"] = "female"
print(g.gender)  # female

當然模組也有屬性字典,屬性查詢方面,本質上和上面的類的例項物件是一致的。

import builtins

print(builtins.str)  # <class 'str'>
print(builtins.__dict__["str"])  # <class 'str'>

另外global空間裡面是儲存了builtin空間的指標的:

# globals()["__builtins__"]位元組等價於import builtins
print(globals()["__builtins__"])  # <module 'builtins' (built-in)>

import builtins
print(builtins)  # <module 'builtins' (built-in)>

# 但我們說globals函式是在什麼地方呢? 顯然是在builtin空間中
# 所以
print(globals()["__builtins__"].globals()["__builtins__"].
      globals()["__builtins__"].globals()["__builtins__"].
      globals()["__builtins__"].globals()["__builtins__"])  # <module 'builtins' (built-in)>

print(globals()["__builtins__"].globals()["__builtins__"].
      globals()["__builtins__"].globals()["__builtins__"].
      globals()["__builtins__"].globals()["__builtins__"].list("abc"))  # ['a', 'b', 'c']

小結

Python 中,一個名字(變數)可見範圍由 "作用域" 決定,而作用域由語法靜態劃分,劃分規則提煉如下:

  • .py檔案(模組)最外層為全域性作用域
  • 遇到函式定義,函式體形成子作用域
  • 遇到類定義,類定義體形成子作用域
  • 名字僅在其作用域以內可見
  • 全域性作用域對其他所有作用域可見
  • 函式作用域對其直接子作用域可見,並且可以傳遞(閉包)

與"作用域"相對應, Python 在執行時藉助 PyDictObject 物件儲存作用域中的名字,構成動態的"名字空間" 。這樣的名字空間總共有 4 個:

  • 區域性名字空間(builtin): 不同的函式,區域性名字空間不同
  • 全域性名字空間(global): 全域性唯一
  • 閉包名字空間(enclosing)
  • 內建名字空間(builtin)
  • 在查詢名字時會按照LEGB規則查詢, 但是注意: 無法跨越檔案本身。就是按照自身檔案的LEGB, 如果屬性查詢都找到builtin空間了, 那麼證明這已經是最後的倔強。如果builtin空間再找不到, 那麼就只能報錯了, 不可能跑到其它檔案中找

python虛擬機器的執行框架

當Python啟動後,首先會進行執行時環境的初始化。注意這裡的執行時環境,它和上面說的執行環境是不同的概念。執行時環境是一個全域性的概念,而執行時環境是一個棧幀,是一個與某個code block相對應的概念。現在不清楚兩者的區別不要緊,後面會詳細介紹。關於執行時環境的初始化是一個非常複雜的過程,我們後面將用單獨的一章進行剖析,這裡就假設初始化動作已經完成,我們已經站在了Python虛擬機器的門檻外面,只需要輕輕推動一下第一張骨牌,整個執行過程就像多米諾骨牌一樣,一環扣一環地展開。

首先Python虛擬機器執行PyCodeObject物件中位元組碼的程式碼為Python/ceval.c中,主要函式有兩個:PyEval_EvalCodeEx 是通用介面,一般用於函式這樣帶引數的執行場景; PyEval_EvalCode 是更高層封裝,用於模組等無引數的執行場景。

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

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_EvalCodeWithName 函式,初始化棧幀物件並呼叫PyEval_EvalFrame 和PyEval_EvalFrameEx函式進行處理。棧幀物件將貫穿程式碼物件執行的始終,負責維護執行時所需的一切上下文資訊。而PyEval_EvalFramePyEval_EvalFrameEx函式最終呼叫 _PyEval_EvalFrameDefault 函式,虛擬機器執行的祕密就藏在這裡。

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

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

_PyEval_EvalFrameDefault函式是虛擬機器執行的核心,這一個函式加上註釋大概在3100行左右。可以說程式碼量非常大,但是邏輯並不難理解。

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
{	
    /*
    該函式首先會初始化一些變數,PyFrameObject物件中的PyCodeObject物件包含的資訊不用說,還有一個重要的動作就是初始化堆疊的棧頂指標,使其指向f->f_stacktop
    */
    //......
    co = f->f_code;
    names = co->co_names;
    consts = co->co_consts;
    fastlocals = f->f_localsplus;
    freevars = f->f_localsplus + co->co_nlocals;
    next_instr = first_instr;
    if (f->f_lasti >= 0) {
        assert(f->f_lasti % sizeof(_Py_CODEUNIT) == 0);
        next_instr += f->f_lasti / sizeof(_Py_CODEUNIT) + 1;
    }
    stack_pointer = f->f_stacktop;
    assert(stack_pointer != NULL);
    f->f_stacktop = NULL;       
    //......
}
    /*
    PyFrameObject物件中的f_code就是PyCodeObject物件,而PyCodeObject物件裡面的co_code域則儲存著位元組碼指令和位元組碼指令引數
    python執行位元組碼指令序列的過程就是從頭到尾遍歷整個co_code、依次執行位元組碼指令的過程。在Python的虛擬機器中,利用三個變數來完成整個遍歷過程。
    首先co_code本質上是一個PyBytesObject物件,而其中的字元陣列才是真正有意義的東西。也就是說整個位元組碼指令序列就是c中一個普普通通的陣列。
    因此遍歷的過程使用的3個變數都是char *型別的變數
    1.first_instr:永遠指向位元組碼指令序列的開始位置
    2.next_instr:永遠指向下一條待執行的位元組碼指令的位置
    3.f_lasti:指向上一條已經執行過的位元組碼指令的位置
    */

那麼這個一步一步的動作是如何完成的呢?其實就是一個for迴圈加上一個巨大的switch case結構。

PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
{	
    //......
    	why = WHY_NOT;
    //......
    // 逐條取出位元組碼來執行
    for (;;) {
        // 讀取下條位元組碼
        // 位元組碼位於: f->f_code->co_code, 偏移量由 f->f_lasti 決定
        opcode, oparg = read_next_byte_code(f);
        //opcode是指令,我們說Python在Include/opcode.h中定義了130個指令
        //如果opcode是NULL,則說明位元組碼讀取完畢可
        if (opcode == NULL) {
            break;
        }
		
        //判斷該指令屬於什麼操作,然後執行相應的邏輯
        switch (opcode) {
            // 載入常量
            case LOAD_CONST:
                // ....
                break;
            // 載入名字
            case LOAD_NAME:
                // ...
                break;
            // ...
        }
    }
}

在這個執行架構中,對位元組碼一步一步的遍歷是通過幾個巨集來實現的:

#define INSTR_OFFSET()  \
    (sizeof(_Py_CODEUNIT) * (int)(next_instr - first_instr))

#define NEXTOPARG()  do { \
        _Py_CODEUNIT word = *next_instr; \
        opcode = _Py_OPCODE(word); \
        oparg = _Py_OPARG(word); \
        next_instr++; \
    } while (0)

Python的位元組碼有的是帶有引數的,有的是沒有引數的,而判斷位元組碼是否帶有引數是通過HAS_AGR這個巨集來實現的。注意:對於不同的位元組碼指令,由於存在是否需要指令引數的區別,所以next_instr的位移可以是不同的,但無論如何,next_instr總是指向python下一條要執行的位元組碼。

Python在獲得了一條位元組碼指令和其需要的引數指令之後,會對位元組碼利用switch進行判斷,根據判斷的結果選擇不同的case語句,每一條指令都會對應一個case語句。在case語句中,就是Python對位元組碼指令的實現。所以這個switch語句非常的長,函式總共3000行左右,這個switch就佔了2400行,因為指令有130個,比如:LOAD_CONST、LOAD_NAME、YIELD_FROM等等,而每一個指令都要對應一個case語句。

在成功執行完一條位元組碼指令和其需要的指令引數之後,Python的執行流程會跳轉到fast_next_opcode處,或者for迴圈處。不管如何,Python接下來的動作就是獲取下一條位元組碼指令和指令引數,完成對下一條指令的執行。通過for迴圈一條一條地遍歷co_code中包含的所有位元組碼指令,然後交給for迴圈裡面的switch語句,如此周而復始,最終完成了對Python程式的執行。

不過在程式碼的最開始(我們擷取)的地方,有個叫做why的神祕變數,它指示了在退出這個巨大的for迴圈時Python執行引擎的狀態。因為Python執行引擎不一定每一次執行都正確無誤(或者說我們寫的程式碼會出現異常),很有可能在執行到某條位元組碼的時候出現了錯誤,這也就是我們在Python中經常看到的異常--Exception。所以在Python退出了執行引擎的時候,就需要知道執行引擎到底是因為什麼原因結束了對位元組碼指令的執行。是正常結束、還是因為有錯誤而導致執行不下去了等等,why則義無反顧地承擔起這一重任。關於why這一變數在虛擬機器中的詳細作用,我們在分析異常機制的時候再詳細介紹。

但是why的取值範圍我們是可以直接在ceval.c中看到的,其實也就是Python結束位元組碼執行時候的狀態。

enum why_code {
        WHY_NOT =       0x0001, /* No error 沒有錯誤 */
        WHY_EXCEPTION = 0x0002, /* Exception occurred 發生錯誤 */
        WHY_RETURN =    0x0008, /* 'return' statement return語句*/
        WHY_BREAK =     0x0010, /* 'break' statement break語句*/
        WHY_CONTINUE =  0x0020, /* 'continue' statement continue語句*/
        WHY_YIELD =     0x0040, /* 'yield' operator yield操作*/
        WHY_SILENCED =  0x0080  /* Exception silenced by 'with' 異常被with語句解除*/
};

儘管只是簡單的分析,但是相信大家也能瞭解Python執行引擎的大體框架,在Python的執行流程進入了那個巨大的for迴圈,取出第一條位元組碼交給裡面的switch語句之後,第一張多米諾骨牌就已經被推倒,命運不可阻擋的降臨了。一條接一條的位元組碼像潮水一樣湧來,浩浩蕩蕩,橫無際涯。

我們這裡通過反編譯的方式演示一下

指令分為很多種,我們這裡就以簡單的順序執行為例,不涉及任何的跳轉指令,看看Python是如何執行位元組碼的。

pi = 3.14
r = 3
area = pi * r ** 2

對它們反編譯之後,得到的位元組碼指令如下:

  1           0 LOAD_CONST               0 (3.14)
              2 STORE_NAME               0 (pi)

  2           4 LOAD_CONST               1 (3)
              6 STORE_NAME               1 (r)

  3           8 LOAD_NAME                0 (pi)
             10 LOAD_NAME                1 (r)
             12 LOAD_CONST               2 (2)
             14 BINARY_POWER
             16 BINARY_MULTIPLY
             18 STORE_NAME               2 (area)
             20 LOAD_CONST               3 (None)
             22 RETURN_VALUE

第一列是原始碼的行號,第二列是指令的偏移量(或者說指令對應的索引),第三行是運算元(或者操作碼, 它們在巨集定義中代表整數),第四行的含義我們具體分析的時候說(至於後面的括號則相當於一個提示)

  • 0 LOAD_CONST: 表示載入一個常量(壓入"執行時棧"),後面的0 (3.14)表示從常量池中載入索引為0的物件,3.14表示載入的物件是3.14(所以最後面的括號裡面的內容實際上起到的是一個提示作用,告訴你載入的物件是什麼)。
  • 2 STORE_NAME: 表示將LOAD_CONST得到的物件用一個名字儲存、或者繫結起來。0 (pi)表示使用符號表(co_varnames)中索引為0的名字(符號),且名字為"pi"。
  • 4 LOAD_CONST和6 STORE_NAME顯然和上面是一樣的,只不過後面的索引變成了1,表示載入常量池中索引為1的物件、符號表中索引為1的符號(名字)。另外從這裡我們也能看出,一行賦值語句實際上對應兩條位元組碼(載入常量、與名字繫結)
  • 8 LOAD_NAME表示載入符號表中pi對應的值,10 LOAD_NAME表示載入符號表中r對應的值,12 LOAD_CONST表示載入2這個常量2 (2)表示常量池中索引為2的物件是2
  • 14 BINARY_POWER表示進行冪運算,16 BINARY_MULTIPLY表示進行乘法運算,18 STORE_NAME表示用符號表中索引為2的符號(area)儲存上一步計算的結果,20 LOAD_CONST表示將None載入進來,22 RETURN_VALUE將None返回。雖然它不是在函式裡面,但也是有這一步的。

我們通過幾張圖展示一下上面的過程:

Python 虛擬機器剛開始執行時,準備好棧幀物件用於儲存執行上下文,關係如下(省略部分資訊)

由於 next_instr 初始狀態指向位元組碼開頭,虛擬機器開始載入第一條位元組碼指令: 0 LOAD_CONST 0 (3.14) 。位元組碼分為兩部分,分別是 操作碼 ( opcode )和 運算元 ( oparg ) 。LOAD_CONST 指令表示將常量載入進執行時棧,常量下標由運算元給出。LOAD_CONST 指令在 _PyEval_EvalFrameDefault 函式 switch 結構的一個 case 分支中實現:

TARGET(LOAD_CONST) {
    //通過GETITEM從consts(常量池)中載入索引為oparg的物件(常量)
    //所以0 LOAD_CONST 0 (3.14)分別表示: 
    //位元組碼指令的偏移量、運算元、物件在常量池中的索引(即這裡的oparg)、物件的值(物件的值、或者說常量的值其實是dis模組幫你解析出來的)
    PyObject *value = GETITEM(consts, oparg);
    //增加引用計數
    Py_INCREF(value);
    //壓入執行時棧, 這個執行時棧是位於棧幀物件尾部, 我們一會兒會說
    PUSH(value);
    FAST_DISPATCH();
}

接著虛擬機器接著執行 2 STORE_NAME 0 (pi) 指令,從符號表中獲取索引為0的符號、即pi,然後將棧頂元素3.14彈出,再把符號"pi"和整數物件3.14繫結起來儲存到local名字空間

case TARGET(STORE_NAME): {
    	    //從符號表中載入索引為oparg的符號	
            PyObject *name = GETITEM(names, oparg);
    	    //從棧頂彈出元素	
            PyObject *v = POP();
            //獲取名字空間namespace
            PyObject *ns = f->f_locals;
            int err;
            if (ns == NULL) {
                //如果沒有名字空間則報錯, 這個tstate是和執行緒密切相關的, 我們後面會說
                _PyErr_Format(tstate, PyExc_SystemError,
                              "no locals found when storing %R", name);
                Py_DECREF(v);
                goto error;
            }
    		//將符號和物件繫結起來放在ns中
            if (PyDict_CheckExact(ns))
                err = PyDict_SetItem(ns, name, v);
            else
                err = PyObject_SetItem(ns, name, v);
            Py_DECREF(v);
            if (err != 0)
                goto error;
            DISPATCH();
        }

你可能會問,變數賦值為啥不直接通過名字空間,而是到臨時棧繞一圈?主要原因在於: Python 位元組碼只有一個運算元,另一個運算元只能通過臨時棧給出。 Python 位元組碼設計思想跟 CPU精簡指令集類似,指令儘量簡化,複雜指令由多條指令組合完成。

同理,r = 2對應的兩條指令也是類似的。

然後8 LOAD_NAME 0 (pi)、10 LOAD_NAME 1 (r)、12 LOAD_CONST 2 (2),表示將符號pi指向的值、符號r指向的值、常量2壓入執行時棧。

然後14 BINARY_POWER表示進行冪運算,16 BINARY_MULTIPLY表示進行乘法運算。

其中, BINARY_POWER 指令會從棧上彈出兩個運算元(底數 3 和 指數 2 )進行 冪運算,並將結果 9 壓回棧中; BINARY_MULTIPLY 指令則進行乘積運算 ,步驟也是類似的。

case TARGET(BINARY_POWER): {
    		//從棧頂彈出元素, 這裡是指數2
            PyObject *exp = POP();
            //我們看到這個是TOP, 所以其實它不是彈出底數3, 而是獲取底數3, 所以3這個元素依舊在棧裡面
            PyObject *base = TOP();
    	    //進行冪運算
            PyObject *res = PyNumber_Power(base, exp, Py_None);
            Py_DECREF(base);
            Py_DECREF(exp);
            //將冪運算的結果再設定回去, 所以原來的3被計算之後的9給替換掉了
            SET_TOP(res);
            if (res == NULL)
                goto error;
            DISPATCH();
        }

        case TARGET(BINARY_MULTIPLY): {
            //同理這裡也是彈出元素9
            PyObject *right = POP();
            //獲取元素3.14
            PyObject *left = TOP();
            //乘法運算
            PyObject *res = PyNumber_Multiply(left, right);
            Py_DECREF(left);
            Py_DECREF(right);
            //將運算的結果28.26將原來的3.14給替換掉
            SET_TOP(res);
            if (res == NULL)
                goto error;
            DISPATCH();
        }

最終執行指令18 STORE_NAME 2 (area),會從符號表中載入索引為2的符號、即area,再將"area"和浮點數28.26繫結起來放到名字空間中。

整體的執行流程便如上面幾張圖所示,當然位元組碼指令有很多,我們說它們定義在Include/opcode.h中,有130個。比如:除了LOAD_CONST、STORE_NAME之外,還有LOAD_FAST、LOAD_GLOBAL、STORE_FAST,以及if語句、迴圈語句所使用的跳轉指令,運算使用的指令等等等等,這些在後面的系列中會慢慢遇到。

PyFrameObject中的動態記憶體空間

上面我們提到了一個執行時棧,我們說載入常量的時候會將常量(物件)從常量池中獲取、並壓入執行時棧,當計算或者使用變數儲存的時候,會將其從棧裡面彈出來。那麼這個執行時棧所需要的空間都儲存在什麼地方呢?

PyFrameObject中有這麼一個屬性f_localsplus(可以回頭看一下PyFrameObject的定義),我們說它是動態記憶體,用於"維護區域性變數+cell物件集合+free物件集合+執行時棧所需要的空間",因此可以看出這段記憶體不僅僅使用來給棧使用的,還有別的物件使用。

PyFrameObject*
PyFrame_New(PyThreadState *tstate, PyCodeObject *code,
            PyObject *globals, PyObject *locals)
{	
    //本質上呼叫了_PyFrame_New_NoTrack
    PyFrameObject *f = _PyFrame_New_NoTrack(tstate, code, globals, locals);
    if (f)
        _PyObject_GC_TRACK(f);
    return f;
}


PyFrameObject* _Py_HOT_FUNCTION
_PyFrame_New_NoTrack(PyThreadState *tstate, PyCodeObject *code,
                     PyObject *globals, PyObject *locals)
{	
    //上一級的棧幀, PyThreadState指的是執行緒物件
    PyFrameObject *back = tstate->frame;
    //當前的棧幀
    PyFrameObject *f;
    //builtin
    PyObject *builtins;
	/*
	...
	...
	...
	...
	
	*/
    else {
        Py_ssize_t extras, ncells, nfrees;
        ncells = PyTuple_GET_SIZE(code->co_cellvars);
        nfrees = PyTuple_GET_SIZE(code->co_freevars);
        //這四部分便構成了PyFrameObject維護的動態記憶體區,其大小由extras確定
        extras = code->co_stacksize + code->co_nlocals + ncells +
            nfrees;
        
    /*
	...
	...
	...
	...
	
	*/
        f->f_code = code;
        //計算初始化執行時,棧的棧頂,所以沒有加上stacksize
        extras = code->co_nlocals + ncells + nfrees;
        //f_valuestack維護執行時棧的棧底
        f->f_valuestack = f->f_localsplus + extras;
        for (i=0; i<extras; i++)
            f->f_localsplus[i] = NULL;
        f->f_locals = NULL;
        f->f_trace = NULL;
    }
    //f_stacktopk維護執行時棧的棧頂
    f->f_stacktop = f->f_valuestack;
    f->f_builtins = builtins;
    Py_XINCREF(back);
    f->f_back = back;
    Py_INCREF(code);
    Py_INCREF(globals);
    f->f_globals = globals;
    /* Most functions have CO_NEWLOCALS and CO_OPTIMIZED set. */
    if ((code->co_flags & (CO_NEWLOCALS | CO_OPTIMIZED)) ==
        (CO_NEWLOCALS | CO_OPTIMIZED))
        ; /* f_locals = NULL; will be set by PyFrame_FastToLocals() */
    else if (code->co_flags & CO_NEWLOCALS) {
        locals = PyDict_New();
        if (locals == NULL) {
            Py_DECREF(f);
            return NULL;
        }
        f->f_locals = locals;
    }
    else {
        if (locals == NULL)
            locals = globals;
        Py_INCREF(locals);
        f->f_locals = locals;
    }
	
    //設定一些其他屬性,返回返回該棧幀
    f->f_lasti = -1;
    f->f_lineno = code->co_firstlineno;
    f->f_iblock = 0;
    f->f_executing = 0;
    f->f_gen = NULL;
    f->f_trace_opcodes = 0;
    f->f_trace_lines = 1;

    return f;
}

可以看到,在建立PyFrameObject物件時,額外申請的"執行時棧"對應的空間並不完全是給執行時棧使用的,有一部分是給"PyCodeObject物件中儲存的那些區域性變數"、"co_freevars"、"co_cellvars"(co_freevars、co_cellvars是與閉包有關的內容,後面章節會剖析)使用的,而剩下的才是給真正執行時棧使用的。

並且這段連續的空間是由四部分組成,並且順序是"區域性變數"、"Cell物件"、"Free物件"、"執行時棧"。

小結

這次我們深入了 Python 虛擬機器原始碼,研究虛擬機器執行位元組碼的全過程。虛擬機器在執行PyCodeObject物件裡面的位元組碼之前,需要先根據PyCodeObject物件建立棧幀物件 ( PyFrameObject ),用於維護執行時的上下文資訊。然後在PyFrameObject的基礎上,執行位元組碼。

PyFrameObject 關鍵資訊包括:

  • f_locals: 區域性名字空間
  • f_globals: 全域性名字空間
  • f_builtins: 內建名字空間
  • f_code: PyCodeObject物件
  • f_lasti: 上條已執行指令的編號, 或者說偏移量、索引都可以
  • f_back: 該棧幀的上一級棧幀、即呼叫者棧幀
  • f_localsplus: 區域性變數 + co_freevars + co_cellvars + 執行時棧, 這四部分需要的空間

棧幀物件通過 f_back 串成一個"棧幀呼叫鏈",與 CPU 棧幀呼叫鏈有異曲同工之妙。我們還藉助 inspect 模組成功取得棧幀物件(底層是通過sys模組),並在此基礎上輸出整個函式呼叫鏈。

Python虛擬機器的程式碼量不小,但是核心並不難理解,主要是_PyEval_EvalFrameDefault裡面的一個巨大的for迴圈,準確的說for迴圈裡面的那個巨型switch語句。其中的switch語句,case了每一個操作指令,當出現什麼指令就執行什麼操作。

另外我們提到執行時環境,這個執行時環境非常複雜,因為Python啟動是要建立一個主程序、在程序內建立一個主執行緒的。所以還涉及到了程序和執行緒的初始化,在後面的系列中我們會詳細說,包括GIL的問題。這裡我們就先假設執行時環境已經初始化好了,我們直接關注虛擬機器執行位元組碼的流程即可。