这是一个关于我们 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 标准库中最复杂的函数。

简单来说,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 字符串,可以是 call、line、return 或 exception
- 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)
所有这些所做的就是
- 继承自 Bdb 并编写一个简单的构造函数,用于初始化基类和跟踪。
- 添加一个 set_breakpoint 方法,该方法使用 Bdb 设置断点并跟踪我们的断点。
- 覆盖 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 调试器尚未为生产做好准备。

优化调试器
有三种主要方法可以减少调试器开销
- 尽可能限制本地跟踪: 与全局跟踪相比,本地跟踪非常昂贵,因为每行代码的事件数量要多得多。
- 优化“call”事件并更快地将控制权返回给解释器: “call”事件中的主要工作是决定是否进行跟踪。
- 优化“line”事件并更快地将控制权返回给解释器: “line”事件中的主要工作是决定我们是否命中断点。
因此,我们fork了 Bdb,减少了功能集,简化了代码,针对热代码路径进行了优化,并获得了令人印象深刻的结果。 但是,我们仍然不满意。 因此,我们再次尝试,将代码迁移并优化到 .pyx,并使用 Cython 编译它。 最终结果(如下所示)仍然不够好。 因此,我们最终深入研究了 CPython 的源代码,并意识到我们无法使跟踪速度足够快以用于生产。

放弃 Bdb,转而使用字节码操作
在从标准调试方法的反复试验周期中获得最初的失望之后,我们决定研究一个不太明显的选择:字节码操作。
Python 解释器分两个主要阶段工作
- 将 Python 源代码编译为 Python 字节码: 这种(人类)无法读取的格式针对高效执行进行了优化,并且通常缓存在我们都喜欢的 .pyc 文件中。
- 在解释器循环中迭代字节码: 这一次执行一条指令。
这是我们选择的模式:使用 字节码操作 设置 非中断断点,而没有全局开销。 这是通过在内存中找到表示我们感兴趣的源代码行的字节码,并在相关指令之前插入函数调用来完成的。 这样,解释器不必做任何额外的工作来支持我们的断点。
这种方法并非神奇。 这是一个快速示例。
我们从一个非常简单的函数开始
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 在您购买门票时获得折扣,让他们知道您是从我们的社区了解到的该活动。
评论已关闭。