为 Python 调试构建非中断断点

你有没有想过如何加速调试器? 以下是为 Python 构建调试器时吸取的一些经验教训。
104 位读者喜欢这篇文章。
Real python in the graphic jungle

Jen Wike Huger 拍摄的照片,CC BY-SA; 原照片由 Torkild Retvedt 拍摄

这是一个关于我们 Rookout 团队如何为 Python 构建非中断断点,以及在此过程中学到的一些经验教训的故事。我将在本月在旧金山举行的 PyBay 2019 大会上介绍 Python 调试的详细内容。让我们深入了解一下。

Python 调试的核心:sys.set_trace

有很多 Python 调试器。 其中一些比较流行的包括

  • pdb,Python 标准库的一部分
  • PyDev,Eclipse 和 PyCharm IDE 背后的调试器
  • ipdb,IPython 调试器

尽管选择范围很广,但几乎每个 Python 调试器都基于一个函数:sys.set_trace。而且让我告诉你,sys.settrace 可能是 Python 标准库中最复杂的函数。

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 对象,它是可选的,取决于事件类型

跟踪函数返回自身,因为解释器跟踪两种类型的跟踪函数

  • 全局跟踪函数(每个线程): 此跟踪函数由 sys.settrace 为当前线程设置,并且每当解释器创建一个新的 frame 时(基本上在每次函数调用时)都会调用它。虽然没有记录的方法可以为不同的线程设置跟踪函数,但您可以调用 threading.settrace 为所有新创建的 threading 模块线程设置跟踪函数。
  • 本地跟踪函数(每个帧): 此跟踪函数由解释器设置为全局跟踪函数在帧创建时返回的值。 一旦创建了帧,就没有记录的方法来设置本地跟踪函数。

此机制旨在允许调试器对跟踪哪些帧进行更精细的控制,以减少性能影响。

分三个简单的步骤构建我们的调试器(或者我们以为的)

有了所有这些背景知识,使用自定义跟踪函数编写自己的调试器似乎是一项艰巨的任务。幸运的是,标准 Python 调试器 pdb 构建在 Bdb 之上,而 Bdb 是用于构建调试器的基类。

基于 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. 覆盖 user_line 方法,该方法由 Bdb 在某些用户行上调用。该函数确保它正在为断点调用,获取源位置,并调用已注册的断点

简单的 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

优化调试器

有三种主要方法可以减少调试器开销

  1. 尽可能限制本地跟踪: 与全局跟踪相比,本地跟踪非常昂贵,因为每行代码的事件数量要多得多。
  2. 优化“call”事件并更快地将控制权返回给解释器: “call”事件中的主要工作是决定是否进行跟踪。
  3. 优化“line”事件并更快地将控制权返回给解释器: “line”事件中的主要工作是决定我们是否命中断点。

因此,我们fork了 Bdb,减少了功能集,简化了代码,针对热代码路径进行了优化,并获得了令人印象深刻的结果。 但是,我们仍然不满意。 因此,我们再次尝试,将代码迁移并优化到 .pyx,并使用 Cython 编译它。 最终结果(如下所示)仍然不够好。 因此,我们最终深入研究了 CPython 的源代码,并意识到我们无法使跟踪速度足够快以用于生产。

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 来获取函数的字节码

'|\x00\x00|\x01\x00\x14}\x02\x00|\x02\x00S'

可以使用 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 构建非中断断点使我学到了很多关于编译器、调试器、服务器框架、并发模型等等的知识。 如果您有兴趣了解更多关于字节码操作的信息,Google 的开源 cloud-debug-python 具有用于编辑字节码的工具。


Liran Haimovitch 将在 PyBay 大会上介绍“了解 Python 的调试内部原理”,该大会将于 8 月 17 日至 18 日在旧金山举行。 使用代码 OpenSource35 在您购买门票时获得折扣,让他们知道您是从我们的社区了解到的该活动。

标签
User profile image.
Liran 是数据收集和交付公司 Rookout 的联合创始人兼首席技术官。 他是敏捷、精益和 DevOps 等现代软件方法的倡导者。 Liran 的热情是了解软件的实际工作方式。 当他不考虑代码时,他通常在潜水或徒步旅行。

评论已关闭。

Creative Commons License本作品采用 Creative Commons Attribution-Share Alike 4.0 International License 许可。
© . All rights reserved.