给一个跑3次循环的函数装上sys.settrace,逐个看它触发的call/line/return事件序列,理解pdb单步调试的底层机制和代价。
pdb、coverage.py这类工具,底层都是靠这一个函数实现的。
代码events = [] def tracer(frame, event, arg): if frame.f_code.co_name == "traced_fn": events.append((event, frame.f_lineno)) return tracer def traced_fn(n): total = 0 for i in range(n): total += i return total sys.settrace(tracer) traced_fn(3) sys.settrace(None)
n=3,循环体理应跑3次,但事件数不是"3次"这么简单。
事件序列('call', 35) ('line', 36) ('line', 37) ('line', 38) ('line', 37) ('line', 38) ('line', 37) ('line', 38) ('line', 37) ('line', 39) ('return', 39) 总事件数: 11
拆解:call 事件在函数刚被调用、还没执行第一行时触发一次;之后每执行一条新的源码行,都会触发一次line事件——第37行(for i in range(n))和第38行(total += i)交替出现了4次和3次,因为for循环每次判断"还有下一个i"都要回到第37行,n=3时循环体跑了3趟(第38行出现3次),但第37行的判断跑了4趟(前3趟成功进入循环体,第4趟判断失败跳出循环,直接走到第39行);最后return 事件在函数要返回之前触发一次。
tracer函数里有一句if frame.f_code.co_name == "traced_fn"的过滤。
sys.settrace(tracer),tracer会被Python解释器对之后调用的每一个函数触发(不止是traced_fn),如果不加这句按函数名过滤,events列表会被print、events.append本身这些调用也一起记录进去,甚至陷入递归膨胀。这正是为什么pdb这类调试器一旦挂上,程序会明显变慢——sys.settrace是一种全局逐行钩子,Python解释器每执行一行源码代码都要检查一次"现在是不是在被追踪",这份检查代价是逐行叠加的,跟被调试代码本身跑了多少行直接成正比。
每一步都是本文上面用sys.settrace跑出来的原始事件序列。
sys.settrace:记录一次函数调用的call/line/return事件序列