Linux中國

在 Python 調試過程中設置不中斷的斷點

整段故事講的是我們在 Rookout 公司的團隊為 Python 調試器開發不中斷斷點的經歷,以及開發過程中得到的經驗。我將在本月於舊金山舉辦的 PyBay 2019 上介紹有關 Python 調試過程的更多細節,但現在就讓我們立刻開始這段故事。

Python 調試器的心臟:sys.set_trace

在諸多可選的 Python 調試器中,使用最廣泛的三個是:

  • pdb,它是 Python 標準庫的一部分
  • PyDev,它是內嵌在 Eclipse 和 Pycharm 等 IDE 中的調試器
  • ipdb,它是 IPython 的調試器

Python 調試器的選擇雖多,但它們幾乎都基於同一個函數:sys.settrace。 值得一提的是, sys.settrace 可能也是 Python 標準庫中最複雜的函數。

![set_trace Python 2 docs page](/data/attachment/album/202003/12/104537tddqil2d9m1ud1mq.png "set_trace Python 2 docs page")

簡單來講,settrace 的作用是為解釋器註冊一個跟蹤函數,它在下列四種情形發生時被調用:

  • 函數調用
  • 語句執行
  • 函數返回
  • 異常拋出

一個簡單的跟蹤函數看上去大概是這樣:

def simple_tracer(frame, event, arg):
  co = frame.f_code
  func_name = co.co_name
  line_no = frame.f_lineno
  print("{e} {f} {l}".format(
e=event, f=func_name, l=line_no))
  return simple_tracer

在分析函數時我們首先關注的是參數和返回值,該跟蹤函數的參數分別是:

  • frame,當前堆棧幀,它是包含當前函數執行時解釋器里完整狀態的對象
  • event,事件,它是一個值可能為 calllinereturnexception 的字元串
  • arg,參數,它的取值基於 event 的類型,是一個可選項

該跟蹤函數的返回值是它自身,這是由於解釋器需要持續跟蹤兩類跟蹤函數:

  • 全局跟蹤函數(每線程):該跟蹤函數由當前線程調用 sys.settrace 來設置,並在解釋器創建一個新的堆棧幀時被調用(即代碼中發生函數調用時)。雖然沒有現成的方式來為不同的線程設置跟蹤函數,但你可以調用 threading.settrace 來為所有新創建的 threading 模塊線程設置跟蹤函數。
  • 局部跟蹤函數(每一幀):解釋器將該跟蹤函數的值設置為全局跟蹤函數創建幀時的返回值。同樣也沒有現成的方法能夠在幀被創建時自動設置局部跟蹤函數。

該機制的目的是讓調試器對被跟蹤的幀有更精確的把握,以減少對性能的影響。

簡單三步構建調試器 (我們最初的設想)

僅僅依靠上文提到的內容,用自製的跟蹤函數來構建一個真正的調試器似乎有些不切實際。幸運的是,Python 的標準調試器 pdb 是基於 Bdb 構建的,後者是 Python 標準庫中專門用於構建調試器的基類。

基於 Bdb 的簡易斷點調試器看上去是這樣的:

import bdb
import inspect

class Debugger(bdb.Bdb):
  def __init__(self):
      Bdb.__init__(self)
      self.breakpoints = dict()
      self.set_trace()

def set_breakpoint(self, filename, lineno, method):
  self.set_break(filename, lineno)
  try :
      self.breakpoints[(filename, lineno)].add(method)
  except KeyError:
      self.breakpoints[(filename, lineno)] = [method]

def user_line(self, frame):
  if not self.break_here(frame):
      return

  # Get filename and lineno from frame
  (filename, lineno, _, _, _) = inspect.getframeinfo(frame)

  methods = self.breakpoints[(filename, lineno)]
  for method in methods:
      method(frame)

這個調試器類的全部構成是:

  1. 繼承 Bdb,定義一個簡單的構造函數來初始化基類,並開始跟蹤。
  2. 添加 set_breakpoint 方法,它使用 Bdb 來設置斷點,並跟蹤這些斷點。
  3. 重載 Bdb 在當前用戶行調用的 user_line 方法,該方法一定被一個斷點調用,之後獲取該斷點的源位置,並調用已註冊的斷點。

這個簡易的 Bdb 調試器效率如何呢?

Rookout 的目標是在生產級性能的使用場景下提供接近普通調試器的使用體驗。那麼,讓我們來看看先前構建出來的簡易調試器表現的如何。

為了衡量調試器的整體性能開銷,我們使用如下兩個簡單的函數來進行測試,它們分別在不同的情景下執行了 1600 萬次。請注意,在所有情景下斷點都不會被執行。

def empty_method():
   pass

def simple_method():
   a = 1
   b = 2
   c = 3
   d = 4
   e = 5
   f = 6
   g = 7
   h = 8
   i = 9
   j = 10

在使用調試器的情況下需要大量的時間才能完成測試。糟糕的結果指明了,這個簡陋 Bdb 調試器的性能還遠不足以在生產環境中使用。

![First Bdb debugger results](/data/attachment/album/202003/12/104544lrnq2nlwir8lqnrz.png "First Bdb debugger results")

對調試器進行優化

降低調試器的額外開銷主要有三種方法:

  1. 儘可能的限制局部跟蹤:由於每一行代碼都可能包含大量事件,局部跟蹤比全局跟蹤的開銷要大得多。
  2. 優化 call 事件並儘快將控制權還給解釋器:在 call 事件發生時調試器的主要工作是判斷是否需要對該事件進行跟蹤。
  3. 優化 line 事件並儘快將控制權還給解釋器:在 line 事件發生時調試器的主要工作是判斷我們在此處是否需要設置一個斷點。

於是我們復刻了 Bdb 項目,精簡特徵、簡化代碼,針對使用場景進行優化。這些工作雖然得到了一些效果,但仍無法滿足我們的需求。因此我們又繼續進行了其它的嘗試,將代碼優化並遷移至 .pyx 使用 Cython 進行編譯,可惜結果(如下圖所示)依舊不夠理想。最終,我們在深入了解 CPython 源碼之後意識到,讓跟蹤過程快到滿足生產需求是不可能的。

![Second Bdb debugger results](/data/attachment/album/202003/12/104549qy01ndc8j800028y.png "Second Bdb debugger results")

放棄 Bdb 轉而嘗試位元組碼操作

熬過先前對標準調試方法進行的試驗-失敗-再試驗循環所帶來的失望,我們將目光轉向另一種選擇:位元組碼操作。

Python 解釋器的工作主要分為兩個階段:

  1. 將 Python 源碼編譯成 Python 位元組碼:這種(對人類而言)不可讀的格式專為執行的效率而優化,它們通常緩存在我們熟知的 .pyc 文件當中。
  2. 遍歷 解釋器循環中的位元組碼: 在這一步中解釋器會逐條的執行指令。

我們選擇的模式是:使用位元組碼操作來設置沒有全局額外開銷的不中斷斷點。這種方式的實現首先需要在內存中的位元組碼里找到我們感興趣的部分,然後在該部分的相關機器指令前插入一個函數調用。如此一來,解釋器無需任何額外的工作即可實現我們的不中斷斷點。

這種方法並不依靠魔法來實現,讓我們簡要地舉個例子。

首先定義一個簡單的函數:

def multiply(a, b):
   result = a * b
   return result

inspect 模塊(其包含了許多實用的單元)的文檔里,我們得知可以通過訪問 multiply.func_code.co_code 來獲取函數的位元組碼:

'|x00x00|x01x00x14}x02x00|x02x00S'

使用 Python 標準庫中的 dis 模塊可以翻譯這些不可讀的字元串。調用 dis.dis(multiply.func_code.co_code) 之後,我們就可以得到:

  4          0 LOAD_FAST               0 (a)
             3 LOAD_FAST               1 (b)
             6 BINARY_MULTIPLY    
             7 STORE_FAST              2 (result)

  5         10 LOAD_FAST               2 (result)
            13 RETURN_VALUE      

與直截了當的解決方案相比,這種方法讓我們更靠近發生在調試器背後的事情。可惜 Python 並沒有提供在解釋器中修改函數位元組碼的方法。我們可以對函數對象進行重寫,不過那樣做的效率滿足不了大多數實際的調試場景。最後我們不得不採用一種迂迴的方式來使用原生拓展才能完成這一任務。

總結

在構建一個新工具時,總會學到許多事情的工作原理。這種刨根問底的過程能夠使你的思路跳出桎梏,從而得到意料之外的解決方案。

在 Rookout 團隊中構建不中斷斷點的這段時間裡,我學到了許多有關編譯器、調試器、伺服器框架、並發模型等等領域的知識。如果你希望更深入的了解位元組碼操作,谷歌的開源項目 cloud-debug-python 為編輯位元組碼提供了一些工具。

via: https://opensource.com/article/19/8/debug-python

作者:Liran Haimovitch 選題:lujun9972 譯者:caiichenr 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國