《深度剖析CPython解釋器》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():
    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_globals指向的是同一個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 個:

  • 局部名字空間(local): 不同的函數,局部名字空間不同
  • 全局名字空間(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)
{   
    //......   
    co = f->f_code;
    names = co->co_names;
    consts = co->co_consts;
    fastlocals = f->f_localsplus;
    freevars = f->f_localsplus + co->co_nlocals;
    //......
	
    // 逐條取出字節碼來執行
    for (;;) {
        if (_Py_atomic_load_relaxed(eval_breaker)) {
            // 讀取下條字節碼
            // 字節碼位於: f->f_code->co_code, 偏移量由 f->f_lasti 決定
            opcode = _Py_OPCODE(*next_instr);
            //opcode是指令,我們說Python在Include/opcode.h中定義了121個指令
            if (opcode == SETUP_FINALLY ||
                opcode == SETUP_WITH ||
                opcode == BEFORE_ASYNC_WITH ||
                opcode == YIELD_FROM) {
                goto fast_next_opcode; 
            }

        fast_next_opcode:
            //......
            //判斷該指令屬於什么操作,然后執行相應的邏輯
            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行,因為指令有121個,比如:LOAD_CONST、LOAD_NAME、YIELD_FROM等等,而每一個指令都要對應一個case語句。

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

盡管只是簡單的分析,但是相信大家也能了解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 虛擬機剛開始執行時,准備好棧幀對象用於保存執行上下文,關系如下(省略部分信息)。另外,圖中有地方畫錯了,圖中的co_varnames應該改成co_names。我們說對於函數來說是通過co_varnames獲取符號表(local空間里面局部變量的存儲位置,一個靜態數組),因為函數有哪些局部變量在編譯時已經確定,會靜態存儲在符號表co_varnames中。但我們這里是對模塊進行反編譯、不是函數,而模塊的符號是全局的,local空間和global空間是同一個,使用字典來維護,所以它的co_varnames是一個空元組。但co_names是可以獲取到所有的符號的,因此這里把co_names理解為符號表即可,但我們知道全局變量是存在字典里面的。

由於 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中,有121個。比如:除了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的問題。這里我們就先假設運行時環境已經初始化好了,我們直接關注虛擬機執行字節碼的流程即可。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM