调试专用代码的最小集

不知道别人如何,有段时间我常常想在 code base 里加点儿调试专用的代码(就是那种通常被括在『#if _DEBUG_ ... #endif』之类的宏里面的代码),也许是处于对潜在问题的忧虑,或者纯粹就是让程序看起来高级点儿。不过最后总是发现还是删掉(或者压根不写)它们更整洁。如果善于利用 debugger ,需要额外的调试代码的情况还是不多见的。即使是必需的调试代码,问题解决之后也很少需要用再用到。所以我还是愿意必要时候随手写几个 printf ,然后删掉它们。

把调试专用代码长久留在 code base 里要多留意很多:编码规范、对其它部分的功能和调试的影响,等等。随便写几行日后就删掉的调试用代码则简单的多,不必顾及这些,只要能解决眼前的问题。更重要一点,留在 code base 中的调试代码必须对解决一再被发现的潜在问题真正有价值。只有这类问题,你知道它在未来很可能会被发现,但是现在却属未知。而它被发现的时候,用工具和几行 printf 又无法解决,才值得把调试专用代码留在 code base 中。

迄今为之,考虑到把代码留在 code base 里耗费的精力和调试工具的强大能力,我认为只有一种问题值得事先写一些调试代码:某种内存增长的问题。但是这种内存增长的范围很小,绝大多数内存增长问题都不属这种问题。比如,内存泄漏是内存增长的一个常见原因,但是大多数内存泄漏不需要调试代码。良好的 code review 能去除大多数内存泄漏,统一的内存管理策略(比如普遍的利用局部拷贝和引用计数)也能消除大多数内存泄漏。现代的内存监测工具甚至能把大多数引起内存泄露的代码位置明确标识出来。对大多数内存泄漏来说,良好的设计和强大的工具比调试代码更有用。

真正需要专用调试代码的内存增长问题是进行长时间的操作时内存缓慢但持续地增长,短时间之内幅度不明显,积累起来(比如几十分钟或者几小时之后)不容忽视。这种增长也许不是内存泄露(比如,有的情况里存在问题的长时间操作结束或者被用户中途取消,内存会回复),所以用检测内存泄漏的工具通常无法查明原因。而且,因为是长时间操作,内存检测工具运行的开销经常无法容忍,比如一个运行 20 分钟的 use case ,如果把程序连上内存检测工具,运行时间也许会超过一个小时。只要功能慢慢丰富,code base 复杂度不断增加,一个应用程序迟早会遇到这类问题(一般在 3.0 版本左右吧)。但是当最初写下隐含这类问题的代码的时候,你或者你的前任还在忙于功能测试。就算从一开始就重视性能测试,也很难做到边写大段代码边测试每次耗时 20 分钟的 use case。这种问题用外部工具如同隔靴挠痒,又很难在早期发现,当问题显露的时候,code base 已经相当复杂。因此一开始就得在 code base 里做些未雨绸缪的准备。

准备起来很简单,定义一个 dictionary 类型的数据结构(比如一个 std::map),Key 值是程序里生成的实例比较多的类(或者 struct,如果是 C 语言)名,value 是类的实例个数。维护这个 dictionary 的方法就是在对象的 constructor 和 destructor 里对相应的 value 值加一和减一(C 语言要写一组能接受 struct 名称的 debug 版本的 malloc/free)。然后设计一个操作,让你能在长时间的操作中随时打印这个 dictionary 的内容,比如一个随时可见的按钮或者一个菜单项。而且别忘了把所有这些对用户需求来说多余的代码都用条件编译括起来,别影响 release 版本的性能和外观。

这些调试代码纪录的东西不多,但足以解决问题。如果没有这些信息,解决内存增长就如同大海捞针。一旦有了这些信息,大部分情况下通过显示的实例个数的变化都能找到是哪个对象造成的内存增长,找到这个就如同有了金钥匙,接下来顺藤摸瓜用些简单的断点调试和代码查找就能找到病根。某些工具 —— 特别是在 Java 之类的动态语言领域,可以自动收集这些信息。不过『土办法』有独特的优点。比如,程序里会有很多类或者对象的创建的销毁(或者说『生命周期』)完全被其它类控制,这种类或者对象的实例个数不必被纪录,只需纪录控制它们生命周期的类的实例个数。但是外部工具往往不能区分这点。『土方法』需要你自己动手写点代码,所以很容易区分哪些实例个数是真正需要纪录的而哪些又可以忽略。土方法另一个不可替代的好处是消耗的资源非常少,所以能研究一些本身就消耗资源很多的长时间操作。而那些本身就消耗很多资源的工具遇到这些问题往往没一会儿就让系统僵死了。最后,这个办法对程序设计有一点点(也许是有益的)要求:用 C 或者 C++ 的时候不要直接把 char* 或者 byte* 类型的大块内存到处直接分配和引用,给这些 buffer 封装一个 struct,这样才能在调试中得到更有意义和启发性的名字。

所以,我暂时不打算在下一个项目中写『#if _DEBUG_ ... #endif』,除了上面说的纪录实例个数的代码。

8条回应 to “调试专用代码的最小集”

  1. fuzhou Says:

    这不是未雨绸缪,而是必须的东西。

    我一直坚持的:调试支持是软件代码的一部分。所以正确
    的设计应该是从软件设计的阶段就开始考虑调试支持,而
    不是急匆匆写完一切然后再开发无数牛X的工具做事后分析。

    《梦断代码》说软件设计师的特点是出事了首先考虑怎么
    重现,重现不了的就不管,说白了就是对调试的责任没有
    概念。这么多年来我们无数的人在研究怎么把明明可以用
    100行代码写完的东西用成吨的抽象变成1000行,还美其
    名曰“软件设计”,却从来没人考虑怎么在自己的代码里合
    理地保留一些调试信息。

    • sipoint Says:

      『调试支持是软件代码的一部分。所以正确的设计应该是从软件设计的阶段就开始考虑调试支持,而不是急匆匆写完一切然后再开发无数牛X的工具做事后分析。』

      —— 原则上,我同意,但是必须加上一条:必须有经验和事实证明这些调试代码有用而且确实无法用外部工具替代。事实是,九年里我第一次发现只有如本文提及的纪录实例个数的调试代码是真正有用而且没法通过工具做到的。所以,在有新的发现之前,我会暂时不写纪录实例个数之外的调试代码。

      • fuzhou Says:

        从我的工作领域上说(多线程UI和网络通信),我认为那一条的答案基本上只有一个:大部分情况下内部的信息都比外部工具有效。

        这两个领域的共同问题就是问题难以重现。我这里用了”问题”而不是bug这个术语,是因为客户报上的问题不完全是程序错误引起的,硬件偶发错误也在其中占了一定的比例;另外,这两个领域中的多线程/多进程模型也常常使bug的重现工作超出我们客户可以容忍的时间范围。

        我相信在这两个领域有过经验的人都能同意,多进程/多线程调试环境中目前还没有有效的外部调试工具,别的领域里调试器会很方便,而在这两个领域里调试器几乎毫无作用。一个负责的维护工程师不能以重现不易为理由拒绝bug分析,所以我更看重来自程序内部的信息。

  2. sipoint Says:

    我是同意 fuzhou 的『内部信息比外部信息有用』的观点的。只是,这是一个两难:你不知道应该收集哪些内部信息;或者,如果你知道应该收集什么,你也同时知道了这些信息面对的问题的解决方案;或者,这些信息只是在你知道了问题之后才开始收集,而且对解决其它问题并无作用 —— 那么就无异于我文中已经提到的几行 printf。或者,如果这些信息那么常用,那么已经有工具在收集(比如 crash 的 callstack)。所以,面对种种剔除条件,我最后才发现似乎只有实例数量的信息才真正值得收集并且为之专门在 code base 里保留代码。

    • fuzhou Says:

      说白了我所讲的东西本质上就是log。而在实践中,特别是多进程和多线程应用较多的应用中,指望crash callback用来代替log是绝对不可能的,因为对调试来说最重要的是软件怎么走到这一步的,到了那一步的瞬态虽然也需要,但能回答的问题实在有限。最重要的是:在多进程和多线程应用(包括事件驱动应用)中,断点和调试器本身起到的往往是反作用。

      至于“不知道该加什么”的问题,我只能从我的个人经验上讲:如果是自己写的代码,那么设计师对自己没把握的地方应当要有了解。我们当然不可能未卜先知地知道哪些数据是我们需要的,但我们总该知道哪些数据在调试情况下可能有用,比如大段字符串处理时,最起码我们应该把最后处理的结果打印出来,如果buffer出问题了也有一个机会能看到;又或者代码中大量出现同步操作,在构造同步对象之后和wait操作之前把同步对象的地址打印出来,那么真的出现死锁时无疑能节约大量排查的时间。

      至于应当打印什么信息才有用,我认为这是设计师必须考虑的问题。

      当然我承认它不能解决所有问题,但这种做法能真实地反映程序的执行状态,比调试器之类的方法要有效很多。况且我上面的说法已经说明了,调试器事实上也不能解决我说的影响程序执行流程的问题。它们之间更多地是一种互补关系。

      其实真正实践这个很简单,就是永远不要删除printf。程序员可以用一个debug flag控制打印语句在默认情况下不要出现,但不要用#DEBUG把它们清除出release版本。当我们同意调试信息应当是软件设计的一部分时,我们就不会以效率为借口拒绝。

      • sipoint Says:

        fuzhou 的意见倒是和我两年前的想法极为类似。不过事实和直觉是有些误差。这十年来,用 debugger 解决的 bug 无数。特别是我原来认为用 debugger 只能帮倒忙的多线程问题,如今用起来也得心应手了。

        首先,fuzhou 最后也承认,即使不把 printf 清除出 release,也要有一个开关,平时把它关上。也就是说,纵使留下了 logging 代码,也要知道何时打开。所以,能知道如何重现 bug 仍然是必要条件。也就是说,事先加 logging 代码并不能帮助我们获得太多事后加 logging 代码不能获得的信息。所以我们没有必要在没出问题的时候就加代码。再者,问题解决之后是不是留下那些 printf 呢?似乎删掉可惜。可是这么多年似乎也没看到一行 printf 能解决两个以上的 bug。就算能解决两个 bug 的 printf 占我写过的 printf 的 10% 吧,有些洁癖的我仍然会选择写两次这些 printf 然后每次都删掉它们。

        再者,即便我们幸运的打开了 log,然后碰巧发生了 bug。这种没有针对性的的 log 往往对解决 bug 也帮助有限,里面充斥了大量的干扰信息。事实是,在一次生成的大量 log 重寻找 bug 的原因,往往还不如花经历研究如何重现 bug。两个都要耗费大量的时间,但是后者往往还是快一些。

        总之,我同意只要能真正对诊断程序有帮助的代码,我愿意多加。但是一定要有实际的经历作为凭证,说明真的能起作用。我目前只发现了内存增长一例。其它类型的 bug,包括 fuzhou 提到的死锁等,我遇到过不少,但是用 log 解决的在我的经历中似乎没有。这就给我一个很大的问号。

  3. fuzhou Says:

    我的经验恰恰相反,早先我一直认为调试器作用可以替代log,而正是这几年在Tablet和AD上的经验让我发现调试器对我手上的相当一部分工作而言更多地是阻碍而不是帮助。

    从bug的比例来说,至少在过去的一年多中我手上的bug中可以稳定重现的总数少之又少。

    >>>
    首先,fuzhou 最后也承认,即使不把 printf 清除出 release,也要有一个开关,平时把它关上。也就是说,纵使留下了 logging 代码,也要知道何时打开。
    <<>>>
    所以,能知道如何重现 bug 仍然是必要条件。也就是说,事先加 logging 代码并不能帮助我们获得太多事后加 logging 代码不能获得的信息。
    <<<<

    这是一个完全不讲道理的推理。首先我从来没有说过log的作用在于让重现的变得不再必要,我甚至明白地说过他们是互补的——对维护为主的任务而言,bug的调试过程就是在自己没有发现的情况下从少量蛛丝马迹中获得线索,所以重现永远是不可缺少的。

    其次,从来没有证据证明任何程序都能够事后添加调试。从我上面举的dcpromo的例子应当清楚地表明了确实存在一类程序,它们只能通过事后分析来得到结果。事实上dcpromo的log永远都会输出,而且巨细靡遗。

    至于“洁癖”之类的问题,我认为这和个人的代码风格有关。对我而言,我从不会在代码里添加printf。相反地,我在开始计划一个程序之前考虑的第一个问题就是是否需要log,如果需要该怎么输出(事实上多数情况下我认为都是需要的)。所以需要调试时打印信息时,我会直接使用已经定义好的log接口,用完后我也不会随便删除。

    所以最后总结陈词:

    a) 程序员对是否愿意添加log态度取决于他们的工作领域。越是相对容易重现的问题对log的需求比例就越低;反之则越高。不同的工作经历会对这个问题产生完全不同的理解,这个很正常。

    b) Log和debugger是互补的——再次强调,是****互补的****。它们擅长解决的bug类型区别很大,而且正像调试器不适合一些类型的程序一样,log也同样不适用于很多场合,比如对性能或对时间同步要求极高的程序。

    顺便说一句:内存跟踪是一个很好的用log的场合。但我不同意的是“只有内存跟踪这一例”,比如我说的dcpromo。

    c) 考虑到log本身总是作为代码的一部分而调试器则不是,log的添加规则应当是作为软件设计的一部分加以考虑的。所以合理设计一个log输出的难度并不比设计一个软件功能低。

    d) 指望用log代替调试器是愚蠢的——这一条几乎所有程序员都有共识;但是根据互补的性质,我必须强调,指望调试器代替log同样愚蠢——可惜承认这一条的程序员实在有限。

  4. fuzhou Says:

    编辑错误,缺了一大快:

    关于log的输出问题,我惯于以dcpromo为例。对AD有了解的用户都知道,dcpromo是很典型的一种几乎无法重现的程序。它的作用在于把一台workgroup机器升级成domain controller。在这过程中会涉及数十个Windows模块(DNS、Netlogon、SAM等等)的状态变化以及多台机器的数据库更新,所以中间相当一部分的操作是无法重现的,因为首先重现就意味着要把已经升级完毕的domain controll重新降级成workgroup机器,其次中间的变化由于涉及面过广而在现实状态中无法一步步跟踪。

    所以实际实现中dcpromo时采取的方法是任何情况下均输出log,而且永远巨细靡遗。没错,这些log中确实含有大量无关甚至是无用的信息,但相比于重现而言,分析log对测试而言会方便很多。

    那么这个例子表明了什么?它表明了log的必要性与程序的行为有关。难于重现的行为对log的要求比相对容易重现的行为高很多。比如同样是AD一部分的netlogon,它就允许用户关闭log,因为它的错误多数都是无法用户无法登录,而这种问题重现成功的比率比dcpromo大得多。

发表评论

Fill in your details below or click an icon to log in:

WordPress.com 徽标

您正在使用您的 WordPress.com 账号评论。 注销 /  更改 )

Facebook photo

您正在使用您的 Facebook 账号评论。 注销 /  更改 )

Connecting to %s


%d 博主赞过: