從底層帶你理解Python中的一些內部機制
下面博文將帶你建立一個位元組碼級別的追蹤API以追蹤Python的一些內部機制,比如類似YIELDVALUE、YIELDFROM操作碼的實現,推式構造列表(List Comprehensions)、生成器表示式(generator expressions)以及其他一些有趣Python的編譯。
以下為譯文
最近我在學習 Python 的執行模型。我對 Python 的一些內部機制很是好奇,比如 Python 是怎麼實現類似 YIELDVALUE、YIELDFROM 這樣的操作碼的;對於 遞推式構造列表(List Comprehensions)、生成器表示式(generator expressions)以及其他一些有趣的 Python 特性是怎麼編譯的;從位元組碼的層面來看,當異常丟擲的時候都發生了什麼事情。翻閱 CPython 的程式碼對於解答這些問題當然是很有幫助的,但我仍然覺得以這樣的方式來做的話對於理解位元組碼的執行和堆疊的變化還是缺少點什麼。GDB 是個好選擇,但是我懶,而且只想使用一些比較高階的介面寫點 Python 程式碼來完成這件事。
所以呢,我的目標就是建立一個位元組碼級別的追蹤 API,類似 sys.setrace 所提供的那樣,但相對而言會有更好的粒度。這充分鍛鍊了我編寫 Python 實現的 C 程式碼的編碼能力。我們所需要的有如下幾項,在這篇文章中所用的 Python 版本為 3.5。
- 一個新的 Cpython 直譯器操作碼
- 一種將操作碼注入到 Python 位元組碼的方法
- 一些用於處理操作碼的 Python 程式碼
一個新的 Cpython 操作碼
新操作碼:DEBUG_OP
這個新的操作碼 DEBUG_OP 是我第一次嘗試寫 CPython 實現的 C 程式碼,我將盡可能的讓它保持簡單。 我們想要達成的目的是,當我們的操作碼被執行的時候我能有一種方式來呼叫一些 Python 程式碼。同時,我們也想能夠追蹤一些與執行上下文有關的資料。我們的操作碼會把這些資訊當作引數傳遞給我們的回撥函式。通過操作碼能辨識出的有用資訊如下:
- 堆疊的內容
- 執行 DEBUG_OP 的幀物件資訊
所以呢,我們的操作碼需要做的事情是:
- 找到回撥函式
- 建立一個包含堆疊內容的列表
- 呼叫回撥函式,並將包含堆疊內容的列表和當前幀作為引數傳遞給它
聽起來挺簡單的,現在開始動手吧!宣告:下面所有的解釋說明和程式碼是經過了大量段錯誤除錯之後總結得到的結論。首先要做的是給操作碼定義一個名字和相應的值,因此我們需要在Include/opcode.h中新增程式碼。
這部分工作就完成了,現在我們去編寫操作碼真正幹活的程式碼。
實現 DEBUG_OP
在考慮如何實現DEBUG_OP之前我們需要了解的是DEBUG_OP提供的介面將長什麼樣。 擁有一個可以呼叫其他程式碼的新操作碼是相當酷眩的,但是究竟它將呼叫哪些程式碼捏?這個操作碼如何找到回撥函式的捏?我選擇了一種最簡單的方法:在幀的全域性區域寫死函式名。那麼問題就變成了,我該怎麼從字典中找到一個固定的 C 字串?為了回答這個問題我們來看看在 Python 的 main loop 中使用到的和上下文管理相關的識別符號__enter__和__exit__。
我們可以看到這兩識別符號被使用在操作碼SETUP_WITH中:
現在,看一眼巨集_Py_IDENTIFIER的定義
嗯,註釋部分已經說明得很清楚了。通過一番查詢,我們發現了可以用來從字典找固定字串的函式_PyDict_GetItemId,所以我們操作碼的查詢部分的程式碼就是長這樣滴。
為了方便理解,對這一段程式碼做一些說明:
- f是當前的幀,f->f_globals是它的全域性區域
- 如果我們沒有找到op_target,我們將會檢查這個異常是不是KeyError
- goto error;是一種在 main loop 中丟擲異常的方法
- PyErr_Clear()抑制了當前異常的丟擲,而DISPATCH()觸發了下一個操作碼的執行
下一步就是收集我們想要的堆疊資訊。
最後一步就是呼叫我們的回撥函式!我們用call_function來搞定這件事,我們通過研究操作碼CALL_FUNCTION的實現來學習怎麼使用call_function。
有了上面這些資訊,我們終於可以搗鼓出一個操作碼DEBUG_OP的草稿了:
在編寫 CPython 實現的 C 程式碼方面我確實沒有什麼經驗,有可能我漏掉了些細節。如果您有什麼建議還請您糾正,我期待您的反饋。
編譯它,成了!
一切看起來很順利,但是當我們嘗試去使用我們定義的操作碼DEBUG_OP的時候卻失敗了。自從 2008 年之後,Python 使用預先寫好的 goto(你也可以從 這裡獲取更多的訊息)。故,我們需要更新下 goto jump table,我們在 Python/opcode_targets.h 中做如下修改。
這就完事了,我們現在就有了一個可以工作的新操作碼。唯一的問題就是這貨雖然存在,但是沒有被人呼叫過。接下來,我們將DEBUG_OP注入到函式的位元組碼中。
在 Python 位元組碼中注入操作碼 DEBUG_OP
有很多方式可以在 Python 位元組碼中注入新的操作碼:
- 使用 peephole optimizer, Quarkslab就是這麼幹的
- 在生成位元組碼的程式碼中動些手腳
- 在執行時直接修改函式的位元組碼(這就是我們將要乾的事兒)
為了創造出一個新操作碼,有了上面的那一堆 C 程式碼就夠了。現在讓我們回到原點,開始理解奇怪甚至神奇的 Python!
我們將要做的事兒有:
- 得到我們想要追蹤函式的 code object
- 重寫位元組碼來注入DEBUG_OP
- 將新生成的 code object 替換回去
和 code object 有關的小貼士
如果你從沒聽說過 code object,這裡有一個簡單的 介紹網路上也有一些相關的文件可供查閱,可以直接Ctrl+F查詢 code object
還有一件事情需要注意的是在這篇文章所指的環境中 code object 是不可變的:
但是不用擔心,我們將會找到方法繞過這個問題的。
使用的工具
為了修改位元組碼我們需要一些工具:
- dis模組用來反編譯和分析位元組碼
- dis.BytecodePython 3.4 新增的一個特性,對於反編譯和分析位元組碼特別有用
- 一個能夠簡單修改 code object 的方法
用dis.Bytecode反編譯 code bject 能告訴我們一些有關操作碼、引數和上下文的資訊。
為了能夠修改 code object,我定義了一個很小的類用來複制 code object,同時能夠按我們的需求修改相應的值,然後重新生成一個新的 code object。
這個類用起來很方便,解決了上面提到的 code object 不可變的問題。
測試我們的新操作碼
我們現在擁有了注入DEBUG_OP的所有工具,讓我們來驗證下我們的實現是否可用。我們將我們的操作碼注入到一個最簡單的函式中:
看起來它成功了!有一行程式碼需要說明一下new_nop_code.co_stacksize += 3
- co_stacksize 表示 code object 所需要的堆疊的大小
- 操作碼DEBUG_OP往堆疊中增加了三項,所以我們需要為這些增加的項預留些空間
現在我們可以將我們的操作碼注入到每一個 Python 函式中了!
重寫位元組碼
正如我們在上面的例子中所看到的那樣,重寫 Pyhton 的位元組碼似乎 so easy。為了在每一個操作碼之間注入我們的操作碼,我們需要獲取每一個操作碼的偏移量,然後將我們的操作碼注入到這些位置上(把我們操作碼注入到引數上是有壞處大大滴)。這些偏移量也很容易獲取,使用dis.Bytecode ,就像這樣 。
基於上面的例子,有人可能會想我們的insert_op_debug會在指定的偏移量增加一個"\x00",這尼瑪是個坑啊!我們第一個DEBUG_OP注入的例子中被注入的函式是沒有任何的分支的,為了能夠實現完美一個函式注入函式insert_op_debug我們需要考慮到存在分支操作碼的情況。
Python 的分支一共有兩種:
- 絕對分支:看起來是類似這樣子的Instruction_Pointer = argument(instruction)
- 相對分支:看起來是類似這樣子的Instruction_Pointer += argument(instruction)
相對分支總是向前的
我們希望這些分支在我們插入操作碼之後仍然能夠正常工作,為此我們需要修改一些指令引數。以下是其邏輯流程:
- 對於每一個在插入偏移量之前的相對分支而言
- 如果目標地址是嚴格大於我們的插入偏移量的話,將指令引數增加 1
- 如果相等,則不需要增加 1 就能夠在跳轉操作和目標地址之間執行我們的操作碼DEBUG_OP
- 如果小於,插入我們的操作碼的話並不會影響到跳轉操作和目標地址之間的距離
- 對於 code object 中的每一個絕對分支而言
- 如果目標地址是嚴格大於我們的插入偏移量的話,將指令引數增加 1
- 如果相等,那麼不需要任何修改,理由和相對分支部分是一樣的
- 如果小於,插入我們的操作碼的話並不會影響到跳轉操作和目標地址之間的距離
下面是實現:
讓我們看一下效果如何:
甚好!現在我們知道了如何獲取堆疊資訊和 Python 中每一個操作對應的幀資訊。上面結果所展示的結果目前而言並不是很實用。在最後一部分中讓我們對注入做進一步的封裝。
增加 Python 封裝
正如您所見到的,所有的底層介面都是好用的。我們最後要做的一件事是讓 op_target 更加方便使用(這部分相對而言比較空泛一些,畢竟在我看來這不是整個專案中最有趣的部分)。
首先我們來看一下幀的引數所能提供的資訊,如下所示:
- f_code當前幀將執行的 code object
- f_lasti當前的操作(code object 中的位元組碼字串的索引)
經過我們的處理我們可以得知DEBUG_OP之後要被執行的操作碼,這對我們聚合資料並展示是相當有用的。
新建一個用於追蹤函式內部機制的類:
- 改變函式自身的co_code
- 設定回撥函式作為op_debug的目標函式
一旦我們知道下一個操作,我們就可以分析它並修改它的引數。舉例來說我們可以增加一個auto-follow-called-functions的特性。
現在我們實現一個 Trace 的子類,在這個子類中增加 callback 和 doreport 這兩個方法。callback 方法將在每一個操作之後被呼叫。doreport 方法將我們收集到的資訊打印出來。
這是一個偽函式追蹤器實現: