lua的debug_hook接口应用

lua本身并没有提供调试器, 但是提供了一组称为debug_hook的API, 这组API可以用来编写自己的调试器或者一些其他的东西。

这两天的工作主要就是研究了一下debug_hook这组接口, 写了一个极简化的lua调试器和一个lua性能分析器.


lua调试器现在仅支持break point, step, print, coutinue这几个功能。

由于最近写lua已经习惯于不用调试器了, 因为实现的比较简单。

下断点时, 将要断点所在的文件与行号存入表中。

在不考虑效率的前提下, 直接用line mode来hook中每一行的运行,然后在每个line_hook函数中去check当前文件名和行号是否与断点信息中进行匹配, 如果匹配则进入调试模式。

在调试模式下, 可以step/print/continue来控制程序做单步/打印变量/继续运行等操作。

在打印变量时, 首先搜寻局变量是否存在此变量, 如果没有则搜寻上值中是否存在些变量, 如果都没有则提示没有此变量。

然而,作为一个能用的调试器来说, 这个调试器还缺的太多。如:打印表中内容, 修改变量, 支持call stack的切换, 非法断点提示, 运行到指定行, 打印堆栈回溯信息等。

由于平时写代码不太依赖调试器,所以暂时不打算继续完善。这里先把思路记述一下,有需要再继续完善。

在简化版本实现中,仅支持打印如integer/string 这样的非table数据, 如果有table数据的话则只打印一个指针。

可以增加对变量类型的判断,如果是table则递归打印表中内容, 这里可能存在的一个问题是直接去访问一个表会触发__index元方法, 因此一定要根据具体情况考虑是否使用rawget。

修改变量与打印变量类似,修改可能会触发元方法__newindex,因此要具体考虑是否使用rawset来修改。

将一个debug模式下手动输入的表赋于某个变量时, 为了更方便输入, 可以支持将表以字符串的形式输入,然后内部使用load来执行这段字符串,然后将产生的表赋于某个变量。

在call stack切换时使用lua_getstack切换到相应的call stack中,然后调用lua_getinfo就可以得到当前的stack中相应的环境。只是这里仅能支持print变量功能。

如果要增加非法断点提示就比较麻烦了,初步的想法是,为每一个文件维护一个可断行号表, 在对某个文件第一次下断点时,过滤掉这个文件的空白行和注释行, 然后把有效行置入可断行号表,再去查询要下的断点是否是有效行。如果不是有效行, 则可以根据情况进行提示, 或向上/向下偏移断点行号。

运行到指定行则比较简单, 下一个临时断点,到达指定行后删除此断点即可。

打印堆栈回溯信息直接使用luaL_traceback函数即可。

最后就是关于调试器效率的优化了。

直接设置line hook效率比较低,可以首先设置为call hook,每次call hook时判断当前函数是否有被下过断点。如果有断点则设置为line hook。每次进入line hook时除了要判断是否命中断点外,还需要判断当前函数中的断点是否已经全部步过,如果所有断点全部步过,将hook设置为call hook。

由于lua有tailcall支持,因此上述优化在某些情况下并不一定能如想象中的那么顺利, 只有等到实际优化时碰到问题再来解决。


相比极其简单的lua调试器来讲,lua性能分析器我花了将近一天的时间来写。

在linux下有gprof可以在很方便的测度整个程序的性能热点, 在windows下VS自带的性能分析器也非常强大。

但是在写lua想测试性能时,要测哪一个函数的时间开销就得去哪一个函数定义处加时间测量代码。

我希望实现一个非侵入式lua profiler,就像gprof一样。

在《Programming in lua》一书中讲解debug_hook时,演示了一下怎么使用debug_hook(call/ret)来测试函数的调用次数,这给了我很大的启发。

我希望这个profiler可以即统计调用次数,又统计时间开销,。

由于lua自带的debug lua接口时间开销很大,想要精确统计出函数的运行时间就必须用debug_hook的C接口来实现。

大概实现如下:
每一个函数单独维护一张表, 表中存有进入此函数的当前cpu时间, 此函数上次ret时已经运行了多少时间,当函数已经被调用了多少次。

设置call hook和ret hook, 在call hook时更新函数的调用次数及进入此函数的cpu时间,在ret hook时根据当前cpu时间和进入此函数的cpu时间即可更新此函数已经运行的总时间。

实现完之后,放到silly中去用即刻就发现问题了。没错是coroutine和闭包这些高级特性。 silly是用大量coroutine和闭包组成的一整套框架,如果不解决coroutine和闭包在profile中的影响,那这个profiler几乎没法使用。

在《Programming in lua》中, 作者使用函数的指针来作为profile数据的索引, 但是在碰到闭包之后, 这种方法就会显得有些麻烦了。

假如有一个函数A可以生成另一个函数, 如果调用A两次分别生成函数B1和B2, 那么B1和B2的函数地址是不同的,在做profile时表中就会有两个此函数的信息, 但是其实两个函数的定义是一模一样的。而且由于使用了函数指针作为索引,为了不影响被分析代码的GC形为,必须使用weak-table来cache住这些闭包的性能数据。但是闭包函数的生命周期一般较短(这取决于代码作者),因此有可能来不及打印此函数的性能数据,此项数据就被GC干掉了。

为了解决这个问题,我不再使用函数指针作为profile数据的索引,而使用“文件名:行号”来做为函数性能分析数据的索引,这样不管有多少个相同函数定义的闭包,其执行时间都是算到同一个函数定义的性能数据上。

coroutine可以使用yield/resume来让出/恢复运行,但是显然从让出到运行这断时间并不能算在这个函数的执行时间开销上, 所以怎么跳过这断开销非常重要。

想要解决这个问题只能在coroutine.yield和coroutine.resume这两个api上做文章。

由于在C代码中调用yield之后下次再被resume时,会直接走到lua代码中,并不会回到上次调用yield的C代码处。为此我不得不使用lua对C接口实现的lprofiler做了一个wrapper。

在lua实现的profiler.start代码中将coroutine.yield和coroutine.resume备份并换成自定义yield/resume函数。
在执行自定义yiled/resume之前调用lprofiler.yield,更新当前call stack上所有函数的运行总时间。在执行自定义yield/resume之后调用lprofiler.resume来更新当前call stack上的所有函数开始执行的cpu时间。

如果多个coroutine共用一个函数,那么每一个函数定义对应于一个性能分析数据就不能再满足需求了.
如co1和co2共用一个函数, 当co1执行到50%时yield让出,如果co2接着执行就会再次时入此函数的call hook, 会重新刷新此函数的函数进入时间,这样当co1执行完成之后,得到的时间开销仅是实际开销的一半。

为了避免这个问题,也为了可以针对某一个具体coroutine进行profile。

可以为每一个coroutine单独存储每个函数的性能分析数据。 由于使用coroutine来做为索引, 因此一定要使用weak-table来存储与coroutine相关的性能分析数据。

在打印性能分析报告时,可根据实际情况,合并所有coroutine中的相同函数定义的时间开销。

由于coroutine一旦被回收其性能分析数据均会被GC掉,因此打印性能分析报告的时机一定要选择好。

如果最终确定不需要对单个coroutine进行profile,可以仅仅为每个coroutine维护所执行函数的开始执行cpu时间。这样即可避免当coroutine被GC后,相关的profile数据丢失问题。



评论

  1. […] 终于开发任务完成了, 又有一段时间可以搞自己的东西了.由于很多UI上的bug都不是100%必现的,而lua的调试手段一般就是加print来打印一些变量的值。当出现bug时我们需要打印一下当时一些变量的值就非常的不方便,于是就琢磨着写了一个简单的lua调试器.这样在出现bug时就可以拿lua调试器直接attach上去来打印当时的call stack以及以些变量的值等需要的调试信息,可以大大减少了bug重现的需要。 […]

  2. 请问您实现的这个profiler放到github上了吗?能参看下吗?谢谢

  3. 在这里有一份最新的实现

发表评论