談談 Python 程式的執行原理

發表於2016-08-13

這篇文章準確說是『Python 原始碼剖析』的讀書筆記,整理完之後才發現很長,那就將就看吧。

1. 簡單的例子

先從一個簡單的例子說起,包含了兩個檔案 foo.py 和 demo.py

執行這個程式

輸出結果

同時,該檔案目錄多出一個 foo.pyc 檔案

2. 背後的魔法

看完程式的執行結果,接下來開始一行行解釋程式碼。

2.1 模組

Python 將 .py 檔案視為一個 module,這些 module 中,有一個主 module,也就是程式執行的入口。在這個例子中,主 module 是 demo.py。

2.2 編譯

執行 python demo.py 後,將會啟動 Python 的直譯器,然後將 demo.py 編譯成一個位元組碼物件 PyCodeObject。

有的人可能會很好奇,編譯的結果不應是 pyc 檔案嗎,就像 Java 的 class 檔案,那為什麼是一個物件呢,這裡稍微解釋一下。

在 Python 的世界中,一切都是物件,函式也是物件,型別也是物件,類也是物件(類屬於自定義的型別,在 Python 2.2 之前,int, dict 這些內建型別與類是存在不同的,在之後才統一起來,全部繼承自 object),甚至連編譯出來的位元組碼也是物件,.pyc 檔案是位元組碼物件(PyCodeObject)在硬碟上的表現形式。

在執行期間,編譯結果也就是 PyCodeObject 物件,只會存在於記憶體中,而當這個模組的 Python 程式碼執行完後,就會將編譯結果儲存到了 pyc 檔案中,這樣下次就不用編譯,直接載入到記憶體中。pyc 檔案只是 PyCodeObject 物件在硬碟上的表現形式。

這個 PyCodeObject 物件包含了 Python 原始碼中的字串,常量值,以及通過語法解析後編譯生成的位元組碼指令。PyCodeObject 物件還會儲存這些位元組碼指令與原始程式碼行號的對應關係,這樣當出現異常時,就能指明位於哪一行的程式碼。

2.3 pyc 檔案

一個 pyc 檔案包含了三部分資訊:Python 的 magic number、pyc 檔案建立的時間資訊,以及 PyCodeObject 物件。

magic number 是 Python 定義的一個整數值。一般來說,不同版本的 Python 實現都會定義不同的 magic number,這個值是用來保證 Python 相容性的。比如要限制由低版本編譯的 pyc 檔案不能讓高版本的 Python 程式來執行,只需要檢查 magic number 不同就可以了。由於不同版本的 Python 定義的位元組碼指令可能會不同,如果不做檢查,執行的時候就可能出錯。

下面所示的程式碼可以來建立 pyc 檔案,使用方法

例如

2.4 位元組碼指令

為什麼 pyc 檔案也稱作位元組碼檔案?因為這些檔案儲存的都是一些二進位制的位元組資料,而不是能讓人直觀檢視的文字資料。

Python 標準庫提供了用來生成程式碼對應位元組碼的工具 dis。dis 提供一個名為 dis 的方法,這個方法接收一個 code 物件,然後會輸出 code 物件裡的位元組碼指令資訊。

執行上面這段程式碼可以輸出 demo.py 編譯後的位元組碼指令

2.5 Python 虛擬機器

demo.py 被編譯後,接下來的工作就交由 Python 虛擬機器來執行位元組碼指令了。Python 虛擬機器會從編譯得到的 PyCodeObject 物件中依次讀入每一條位元組碼指令,並在當前的上下文環境中執行這條位元組碼指令。我們的程式就是通過這樣迴圈往復的過程才得以執行。

2.6 import 指令

demo.py 的第一行程式碼是 import foo。import 指令用來載入一個模組,另外一個載入模組的方法是 from xx import yy。用 from 語句的好處是,可以只複製需要的符號變數到當前的名稱空間中(關於名稱空間將在後面介紹)。

前文提到,當已經存在 pyc 檔案時,就可以直接載入而省去編譯過程。但是程式碼檔案的內容會更新,如何保證更新後能重新編譯而不入舊的 pyc 檔案呢。答案就在 pyc 檔案中儲存的建立時間資訊。當執行 import 指令的時候,如果已存在 pyc 檔案,Python 會檢查建立時間是否晚於程式碼檔案的修改時間,這樣就能判斷是否需要重新編譯,還是直接載入了。如果不存在 pyc 檔案,就會先將 py 檔案編譯。

2.7 絕對引入和相對引入

前文已經介紹了 import foo 這行程式碼。這裡隱含了一個問題,就是 foo 是什麼,如何找到 foo。這就屬於 Python 的模組引入規則,這裡不展開介紹,可以參考 pep-0328

2.8 賦值語句

接下來,執行到 a = [1, 'python'],這是一條賦值語句,定義了一個變數 a,它對應的值是 [1, ‘python’]。這裡要解釋一下,變數是什麼呢?

按照[維基百科](“https://en.wikipedia.org/wiki/Variable_(computer_science“) 的解釋

變數是一個儲存位置和一個關聯的符號名字,這個儲存位置包含了一些已知或未知的量或者資訊。

變數實際上是一個字串的符號,用來關聯一個儲存在記憶體中的物件。在 Python 中,會使用 dict(就是 Python 的 dict 物件)來儲存變數符號(字串)與一個物件的對映。

那麼賦值語句實際上就是用來建立這種關聯,在這個例子中是將符號 a 與一個列表物件 [1, 'python'] 建立對映。

緊接著的程式碼執行了 a = 'a string',這條指令則將符號 a 與另外一個字串物件 a string 建立了對映。今後對變數 a 的操作,將反應到字串物件 a string 上。

2.9 def 指令

我們的 Python 程式碼繼續往下執行,這裡執行到一條 def func(),從位元組碼指令中也可以看出端倪 MAKE_FUNCTION。沒錯這條指令是用來建立函式的。Python 是動態語言,def 實際上是執行一條指令,用來建立函式(class 則是建立類的指令),而不僅僅是個語法關鍵字。函式並不是事先建立好的,而是執行到的時候才建立的。

def func() 將會建立一個名稱為 func 的函式物件。實際上是先建立一個函式物件,然後將 func 這個名稱符號繫結到這個函式上。

Python 中是無法實現 C 和 Java 中的過載的,因為過載要求函式名要相同,而引數的型別或數量不同,但是 Python 是通過變數符號(如這裡的 func)來關聯一個函式,當我們用 def 語句再次建立一個同名的函式時,這個變數名就繫結到新的函式物件上了。

2.10 動態型別

繼續看函式 func 裡面的程式碼,這時又有一條賦值語句 a = 1。變數 a 現在已經變成了第三種型別,它現在是一個整數了。那麼 Python 是怎麼實現動態型別的呢?答案就藏在具體儲存的物件上。變數 a 僅僅只是一個符號(實際上是一個字串物件),型別資訊是儲存在物件上的。在 Python 中,物件機制的核心是型別資訊和引用計數(引用計數屬於垃圾回收的部分)。

用 type(a),可以輸出 a 的型別,這裡是 int

b = 257 跳過,我們直接來看看 print(a + b),print 是輸出函式,這裡略過。這裡想要探究的是 a + b

因為 ab 並不儲存型別資訊,因此當執行 a + b 的時候就必須先檢查型別,比如 1 + 2 和 “1” + “2” 的結果是不一樣的。

看到這裡,我們就可以想象一下執行一句簡單的 a + b,Python 虛擬機器需要做多少繁瑣的事情了。首先需要分別檢查 ab 所對應物件的型別,還要匹配型別是否一致(1 + “2” 將會出現異常),然後根據物件的型別呼叫正確的 + 函式(例如數值的 + 或字串的 +),而 CPU 對於上面這條語句只需要執行 ADD 指令(還需要先將變數 MOV 到暫存器)。

2.11 名稱空間 (namespace)

在介紹上面的這些程式碼時,還漏掉了一個關鍵的資訊就是名稱空間。在 Python 中,類、函式、module 都對應著一個獨立的名稱空間。而一個獨立的名稱空間會對應一個 PyCodeObject 物件,所以上面的 demo.py 檔案編譯後會生成兩個 PyCodeObject,只是在 demo.py 這個 module 層的 PyCodeObject 中通過一個變數符號 func 巢狀了一個函式的 PyCodeObject。

名稱空間的意義,就是用來確定一個變數符號到底對應什麼物件。名稱空間可以一個套一個地形成一條名稱空間鏈,Python 虛擬機器在執行的過程中,會有很大一部分時間消耗在從這條名稱空間鏈中確定一個符號所對應的物件是什麼。

在 Python中,名稱空間是由一個 dict 物件實現的,它維護了(name,obj)這樣的關聯關係。

說到這裡,再補充一下 import foo 這行程式碼會在 demo.py 這個模組的名稱空間中,建立一個新的變數名 foofoo 將繫結到一個 PyCodeObject 物件,也就是 foo.py 的編譯結果。

2.11.1 dir 函式

Python 的內建函式 dir 可以用來檢視一個名稱空間下的所有名字元號。一個用處是檢視一個名稱空間的所有屬性和方法(這裡的名稱空間就是指類、函式、module)。

比如,檢視當前的名稱空間,可以使用 dir(),檢視 sys 模組,可以使用 dir(sys)。

2.11.2 LEGB 規則

Python 使用 LEGB 的順序來查詢一個符號對應的物件

locals -> enclosing function -> globals -> builtins

locals,當前所在名稱空間(如函式、模組),函式的引數也屬於名稱空間內的變數

enclosing,外部巢狀函式的名稱空間(閉包中常見)

globals,全域性變數,函式定義所在模組的名稱空間

builtins,內建模組的名稱空間。Python 在啟動的時候會自動為我們載入很多內建的函式、類,比如 dict,list,type,print,這些都位於 __builtins__ 模組中,可以使用 dir(__builtins__) 來檢視。這也是為什麼我們在沒有 import 任何模組的情況下,就能使用這麼多豐富的函式和功能了。

介紹完名稱空間,就能理解 print(a) 這行程式碼輸出的結果為什麼是 a string 了。

2.12 內建屬性 __name__

現在到了解釋 if __name__ == '__main__' 這行程式碼的時候了。當 Python 程式啟動後,Python 會自動為每個模組設定一個屬性 __name__ 通常使用的是模組的名字,也就是檔名,但唯一的例外是主模組,主模組將會被設定為 __main__。利用這一特性,就可以做一些特別的事。比如當該模組以主模組來執行的時候,可以執行測試用例。而當被其他模組 import 時,則只是乖乖的,提供函式和功能就好。

2.13 函式呼叫

最後兩行是函式呼叫,這裡略去不講。

3. 回顧

講到最後,還有些內容需要再回顧和補充一下。

3.1 pyc 檔案

Python 只會對那些以後可能繼續被使用和載入的模組才會生成 pyc 檔案,Python 認為使用了 import 指令的模組,屬於這種型別,因此會生成 pyc 檔案。而對於只是臨時用一次的模組,並不會生成 pyc 檔案,Python 將主模組當成了這種型別的檔案。這就解釋了為什麼 python demo.py 執行完後,只會生成一個 foo.pyc 檔案。

如果要問 pyc 檔案什麼時候生成,答案就是在執行了 import 指令之後,from xx import yy 同樣屬於 import 指令。

3.2 小整數物件池

在 demo.py 這裡例子中,所用的整數特意用了一個 257,這是為了介紹小整數物件池的。整數在程式中的使用非常廣泛,Python 為了優化速度,使用了小整數物件池,避免為整數頻繁申請和銷燬記憶體空間。

Python 對小整數的定義是 [-5, 257),這些整數物件是提前建立好的,不會被垃圾回收。在一個 Python 的程式中,所有位於這個範圍內的整數使用的都是同一個物件,從下面這個例子就可以看出。

id 函式可以用來檢視一個物件的唯一標誌,可以認為是記憶體地址

對於大整數,Python 使用的是一個大整數物件池。這句話的意思是:

每當建立一個大整數的時候,都會新建一個物件,但是這個物件不再使用的時候,並不會銷燬,後面再建立的物件會複用之前已經不再使用的物件的記憶體空間。(這裡的不再使用指的是引用計數為0,可以被銷燬)

3.3 字串物件緩衝池

如果仔細思考一下,一定會猜到字串也採用了這種類似的技術,我們來看一下

沒錯,Python 的設計者為一個位元組的字元對應的字串物件 (PyStringObject) 也設計了這樣一個物件池。同時還有一個 intern 機制,可以將內容相同的字串變數轉換成指向同一個字串物件。

intern 機制的關鍵,就是在系統中有一個(key,value)對映關係的集合,集合的名稱叫做 interned。在這個集合中,記錄著被 intern 機制處理過的 PyStringObject 物件。不過 Python 始終會為字串建立 PyStringObject 物件,即便在interned 中已經有一個與之對應的 PyStringObject 物件了,而 intern 機制是在字串被建立後才起作用。

關於 intern 函式 可以參考官方文件,更多擴充套件閱讀:

http://stackoverflow.com/questions/15541404/python-string-interning

值得說明的是,數值型別和字串型別在 Python 中都是不可變的,這意味著你無法修改這個物件的值,每次對變數的修改,實際上是建立一個新的物件。得益於這樣的設計,才能使用物件緩衝池這種優化。

Python 的實現上大量採用了這種記憶體物件池的技術,不僅僅對於這些特定的物件,還有專門的記憶體池用於小物件,使用這種技術可以避免頻繁地申請和釋放記憶體空間,目的就是讓 Python 能稍微更快一點。更多內容可以參考這裡

如果想了解更快的 Python,可以看看 PyPy

3.4 import 指令

前文提到 import 指令是用來載入 module 的,如果需要,也會順道做編譯的事。但 import 指令,還會做一件重要的事情就是把 import 的那個 module 的程式碼執行一遍,這件事情很重要。Python 是解釋執行的,連函式都是執行的時候才建立的。如果不把那個 module 的程式碼執行一遍,那麼 module 裡面的函式都沒法建立,更別提去呼叫這些函式了。

執行程式碼的另外一個重要作用,就是在這個 module 的名稱空間中,建立模組內定義的函式和各種物件的符號名稱(也就是變數名),並將其繫結到物件上,這樣其他 module 才能通過變數名來引用這些物件。

Python 虛擬機器還會將已經 import 過的 module 快取起來,放到一個全域性 module 集合 sys.modules 中。這樣做有一個好處,即如果程式的在另一個地方再次 import 這個模組,Python 虛擬機器只需要將全域性 module 集合中快取的那個 module 物件返回即可。

你現在一定想到了 sys.modules 是一個 dict 物件,可以通過 type(sys.modules) 來驗證

3.5 多執行緒

demo.py 這個例子並沒有用到多執行緒,但還是有必要提一下。

在提到多執行緒的時候,往往要關注執行緒如何同步,如何訪問共享資源。Python 是通過一個全域性直譯器鎖 GIL(Global Interpreter Lock)來實現執行緒同步的。當 Python 程式只有單執行緒時,並不會啟用 GIL,而當使用者建立了一個 thread 時,表示要使用多執行緒,Python 直譯器就會自動啟用 GIL,並建立所需要的上下文環境和資料結構。

Python 位元組碼直譯器的工作原理是按照指令的順序一條一條地順序執行,Python 內部維護著一個數值,這個數值就是 Python 內部的時鐘,如果這個數值為 N,則意味著 Python 在執行了 N 條指令以後應該立即啟動執行緒排程機制,可以通過下面的程式碼獲取這個數值。

執行緒排程機制將會為執行緒分配 GIL,獲取到 GIL 的執行緒就能開始執行,而其他執行緒則必須等待。由於 GIL 的存在,Python 的多執行緒效能十分低下,無法發揮多核 CPU 的優勢,效能甚至不如單執行緒。因此如果你想用到多核 CPU,一個建議是使用多程式

3.6 垃圾回收

在講到垃圾回收的時候,通常會使用引用計數的模型,這是一種最直觀,最簡單的垃圾收集技術。Python 同樣也使用了引用計數,但是引用計數存在這些缺點:

  • 頻繁更新引用計數會降低執行效率
  • 引用計數無法解決迴圈引用問題

Python 在引用計數機制的基礎上,使用了主流垃圾收集技術中的標記——清除分代收集兩種技術。

關於垃圾回收,可以參考

http://hbprotoss.github.io/posts/pythonla-ji-hui-shou-ji-zhi.html

4. 參考文獻

相關文章