不能断点调试python
这是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
在查看此函数时,首先想到的是其参数和返回值。 跟踪函数参数为:
- 框架对象,是函数执行时解释器的完整状态
- 事件字符串,可以是call , line , return或exception
- arg对象,它是可选的,取决于事件类型
跟踪函数返回自身是因为解释器跟踪两种跟踪函数:
- 全局跟踪函数(每个线程):该跟踪函数由sys.settrace为当前线程设置,并且在解释器创建新框架时调用(基本上在每个函数调用上)。 虽然没有记录的方法来为其他线程设置跟踪功能,但是您可以调用threading.settrace来为所有新创建的线程模块线程设置跟踪功能。
- 局部跟踪函数(每帧):解释器将此跟踪函数设置为在创建帧时全局跟踪函数返回的值。 创建框架后,没有任何记录的方法来设置本地跟踪功能。
此机制旨在使调试器可以更精确地控制要跟踪的帧,以减少对性能的影响。
只需三个简单步骤即可构建调试器(或者我们认为如此)
在所有这些背景下,使用自定义跟踪功能编写自己的调试器看起来像一项艰巨的任务。 幸运的是,标准的Python调试器pdb是在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继承,并编写一个简单的构造函数来初始化基类并进行跟踪。
- 添加使用Bdb设置断点并跟踪我们的断点的set_breakpoint方法。
- 覆盖Bdb在某些用户行上调用的user_line方法。 该函数确保正在为断点调用该函数,获取源位置并调用已注册的断点
简单的Bdb调试器工作得如何?
淘汰计划旨在将类似调试器的用户体验引入生产级性能和用例。 那么,我们的幼稚断点调试器的性能如何?
为了测试它并衡量整体性能开销,我们编写了两种简单的测试方法,并在多种情况下分别执行了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调试器尚未投入生产。
优化调试器
减少调试器开销的主要方法有以下三种:
- 尽可能限制本地跟踪:与全局跟踪相比,本地跟踪的成本很高,因为每行代码的事件数量更多。
- 优化“呼叫”事件并将控制权更快地返回到解释器: 呼叫事件的主要工作是确定是否跟踪。
- 优化“行”事件并将控制权更快地返回到解释器: 在线事件的主要工作是确定是否达到断点。
因此,我们分叉了Bdb ,减少了功能集,简化了代码,针对热代码路径进行了优化,并获得了令人印象深刻的结果。 但是,我们仍然不满意。 因此,我们对其进行了另一次测试,将代码迁移并优化为.pyx ,然后使用Cython对其进行了编译 。 最终结果(如下所示)仍然不够好。 因此,我们最终深入研究了CPython的源代码,并意识到我们无法使跟踪足够快地用于生产。
拒绝Bdb以支持字节码操作
在最初对标准调试方法的反复试验感到失望之后,我们决定研究一种不太明显的选择:字节码操作。
Python解释器的工作主要分为两个阶段:
- 将Python源代码编译成Python字节码:这种不可读(对人类而言)的格式已针对有效执行进行了优化,并且经常缓存在我们都喜欢的.pyc文件中。
- 在解释器循环中迭代字节码:一次执行一条指令。
这是我们选择的模式:使用字节码操作来设置无中断断点而没有全局开销。 这是通过在内存中找到代表我们感兴趣的源代码行的字节码并在相关指令之前插入一个函数调用来完成的。 这样,解释器不必做任何额外的工作来支持我们的断点。
这种方法不是魔术。 这是一个简单的例子。
我们从一个非常简单的函数开始:
def multiply
( a
, b
) :
result
= a * b
return result
在检查模块(具有几个有用的实用程序)中隐藏的文档中,我们了解到可以通过访问multiple.func_code.co_code来获取函数的字节码:
'| \x 00 \x 00| \x 01 \x 00 \x 14} \x 02 \x 00| \x 02 \x 00S'
可以使用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将在8月17日至18日在旧金山举行的PyBay上展示“ 了解Python的调试内部 原理 ”。 购买机票时,使用代码OpenSource35可以打折,让他们知道您从我们的社区中了解到了该活动。
不能断点调试python