当前位置: 首页 > 科技观察

Python调试时设置不间断断点

时间:2023-03-12 05:22:56 科技观察

你有没有想过如何让调试器更快?本文分享了我们为Python构建调试器的一些经验。整个故事是关于我们在Rookout的团队为Python调试器开发不间断断点,以及在此过程中吸取的教训。我将在本月于旧金山举行的PyBay2019上介绍有关Python调试过程的更多详细信息,但让我们现在开始讲故事。Python调试器的核心:sys.set_trace在众多可选的Python调试器中,使用最广泛的三个是:pdb,它是Python标准库的一部分PyDev,嵌入在Eclipse和Pycharm等IDE中的调试器ipdb,这是IPython的调试器Python调试器有很多选择,但它们几乎都是基于同一个功能:sys.settrace。值得一提的是,sys.settrace也可能是Python标准库中最复杂的函数。set_tracePython2docspage简单来说,settrace的作用就是为解释器注册一个trace函数,当出现以下四种情况时调用该函数调用语句执行函数返回异常throw一个简单的trace函数看起来像这样:defsimple_tracer(frame,event,arg):co=frame.f_codefunc_name=co.co_nameline_no=frame.f_linenoprint("{e}{f}{l}".format(e=event,f=func_name,l=line_no))returnsimple_tracer在分析函数时,我们首先关注参数和返回值。trace函数的参数是:frame,当前栈帧,是一个对象,包含当前函数执行时解释器的完整状态event,事件,是一个字符串arg,其值可能是call,line,return,orexception,parameter,其值是基于事件类型的,是一个可选的跟踪函数的返回值是它自己,这是由于解释器需要跟踪两类跟踪函数:Globaltracefunction(perthread):这个trace函数由调用sys.settrace的当前线程设置,在解释器创建新的栈帧时被调用(即调用时的函数)。虽然没有开箱即用的方法来为不同的线程设置跟踪函数,但您可以调用threading.settrace为所有新创建的线程模块线程设置跟踪函数。局部跟踪函数(每一帧):解释器将此跟踪函数的值设置为创建帧时全局跟踪函数的返回值。也没有现有的方法可以在创建框架时自动设置本地跟踪功能。这种机制的目的是为了让调试器能够更精确地掌握被跟踪的帧,以减少对性能的影响。通过三个简单步骤构建调试器(我们最初的设想)仅依靠上述内容,构建具有自制跟踪功能的真正调试器似乎不切实际。幸运的是,Python的标准调试器pdb构建在Bdb之上,Bdb是Python标准库中专门用于构建调试器的基类。一个基于Bdb的简单断点调试器如下所示:filename,lineno,method):self.set_break(filename,lineno)try:self.breakpoints[(filename,lineno)].add(method)除了KeyError:self.breakpoints[(filename,lineno)]=[method]defuser_line(self,frame):ifnotself.break_here(frame):return#从frame(filename,lineno,_,_,_)=inspect.getframeinfo(frame)methods=self.breakpoints[(filename,lineno)]formethodinmethods:method(frame)这个调试器类的整个组成是:继承Bdb,定义一个简单的构造函数来初始化基类,开始跟踪。添加了set_breakpoint方法,该方法使用Bdb设置断点并跟踪这些断点。在当前用户行重载Bdb调用的user_line方法。该方法必须在断点时调用,然后获取断点的源位置,调用注册的断点。这个简单的Bdb调试器有多有效?Rookout的目标是在生产级性能使用场景中提供接近普通调试器的体验。那么,让我们看看之前构建的简单调试器是如何执行的。为了衡量调试器的整体性能开销,我们使用了以下两个简单的函数进行测试,分别在不同场景下执行了1600万次。请注意,断点不会在所有情况下都执行。defempty_method():passdefsimple_method():a=1b=2c=3d=4e=5f=6g=7h=8i=9j=10需要大量时间才能完成测试。糟糕的结果表明这个不起眼的Bdb调试器的性能远远不能满足生产使用。首先bdbdebuggerresults优化调试器以减少调试器的额外开销主要有以下三种方式:尽可能限制localtracing:由于每行代码可能包含大量事件,localtracing的开销很大大于全局跟踪。优化调用事件,尽快将控制权交还给解释器:当调用事件发生时,调试器的主要工作是判断是否需要跟踪该事件。优化行事件,尽快将控制权交还给解释器:调试器在行事件发生时的主要工作是判断我们是否需要在此处设置断点。于是我们复现了Bdb工程,精简功能,精简代码,针对使用场景进行了优化。这些工作虽然取得了一些成果,但仍然不能满足我们的需求。于是我们继续做其他的尝试,将代码优化迁移到.pyx并用Cython编译,但结果(如下图)仍然不理想。最终,在深入研究CPython源代码后,我们意识到要使跟踪过程足够快以适应生产是不可能的。第二个Bdb调试器结果放弃Bdb进行字节码操作在使用标准调试方法经历了之前反复试验的失望之后,我们转向了另一种选择:字节码操作。Python解释器的工作主要分为两个阶段:将Python源代码编译成Python字节码:这种(对人类而言)不可读的格式针对执行效率进行了优化,它们通常缓存在我们众所周知的..pyc文件。在解释器循环中遍历字节码:在这一步中,解释器一条一条地执行指令。我们选择的模式是使用字节码操作来设置无全局开销的非中断断点。该方法的实现首先需要在内存中的字节码中找到我们感兴趣的部分,然后在该部分的相关机器指令之前插入一个函数调用。这样,解释器无需任何额外工作即可实现我们的不间断断点。这个方法不依赖魔法来实现,我们简单举个例子。首先定义一个简单的函数:defmultiply(a,b):result=a*breturnresult在inspect模块(包含很多有用的单元)的文档中,我们知道可以访问multiply.func_code.co_code来获取函数的字节码:'|\x00\x00|\x01\x00\x14}\x02\x00|\x02\x00S'这些不可读的字符串可以使用Python标准库中的dis模块进行翻译。调用dis.dis(multiply.func_code.co_code)后,我们得到:40LOAD_FAST0(a)3LOAD_FAST1(b)6BINARY_MULTIPLY7STORE_FAST2(result)510LOAD_FAST2(result)13RETURN_VALUE和直截了当的这种做法让我们比我们的解决方案更接近调试器幕后发生的事情。不幸的是,Python没有提供在解释器中修改函数字节码的方法。我们可以重写函数对象,但这样做的效率不能满足大多数实际调试场景。最后我们不得不采取迂回的方式使用原生扩展来实现这一点。总结在构建新工具时,您总是会学到很多关于其工作原理的知识。这种追问的过程可以让你的思维跳出枷锁,从而产生意想不到的解决方案。在Rookout团队构建不间断断点期间,我学到了很多有关编译器、调试器、服务器框架、并发模型等的知识。如果你想更深入地了解字节码操作,Google的开源项目cloud-debug-python提供了一些用于编辑字节码的工具。