《Effective Debugging:软件和系统调试的66个有效方法》读书笔记-Part2
一篇blog,显得略长,本文对应第5-8章,第1-4章请参考Part 1。
编程技术
代码评审、手工执行代码
要点:
- 检查代码里有没有常见错误;
- 手工执行代码,以验证其是否正确;
- 通过绘图来解析复杂数据结构;
- 在大型纸张和白板上,通过各种颜色的图示来演算复杂问题;
- 在绘图的过程中操作实物,以便更深地投入到正在研究的问题中。
审读代码并与同事讨论
Rubber Duck Technique:橡皮鸭技术,把代码的工作原理解释给别人(或自己)听。
代码评审:Gerrit、GitHub;要注意礼节。
角色演练:可用于通信协议(Bob and Alice游戏)、人机交互以及工作流等场景。
给软件添加调试机制
调试模式是一套功能或机制,需要有相应的代码来支撑。使软件进入调试模式的几个例子:
- 通过编译软件时所用的选项来决定软件是否进入调试模式。如编写C/C++代码时,就可定义DEBUG常量,并根据该常量的值来进行决策;
- 通过命令行选项来决定软件是否以调试模式运行。如Unix系统的sshd就提供
-d
选项,其他很多程序也提供有类似的选项; - 给进程发送信号(signal),令其进入调试模式。旧版BIND域名服务器就是这么做的;
- 通过命令打开调试模式(这种命令可能不会写在开发文档中)。如通过某种很少见的按键组合来开启。在某些版本的Android系统上,连续单击七次build number(版本号)菜单,就可以开启USB调试模式。
调试模式,可让软件进入特定的状态。
libmicrohttpd:小型嵌入式HTTP服务器程序。
可通过调试命令来模拟:网络随机丢包、数据无法写入磁盘、无线信号衰减、实时时钟功能错乱、智能卡读卡器配置不当等情况。
要点:
- 给程序添加一个选项,令其能够进入调试模式;
- 添加相应的调试命令,使调试者能够操控程序的状态、记录其所执行的操作、降低其在运行时的复杂程序、迅速在其用户界面之间跳转,并展示复杂的数据结构;
- 添加命令行、Web以及串行连接等界面及接口,以便对嵌入式设备及服务器进行调试;
- 在调试模式下通过一些命令来模拟那些与外部因素有关的错误。
日志
日志记录机制:Unix的syslog库、Apple的ASL系统日志机制、Windows的ReportEvent API、适用于C++的Boost.Log v2、在node.js环境中,采用lumberjack协议来记录日志,可考虑选用Bunyan及Winston包、在Unix shell命令行界面,可通过logger命令把消息记入日志、在Unix内核(及设备驱动)里面,应该调用printk函数来记录消息。
要点:
- 通过日志记录语句来搭建一套可以持久维护的基础调试平台;
- 应该用现有的日志框架来记录,而不要去重新做一套框架;
- 根据你所关注的话题以及你想要记录的细节来对日志框架进行配置。
单元测试
引入单元测试,UT,纳入VCS,对已有项目代码编写UT时可能需要重构。
推荐阅读书籍:Working Effectivelywith Legacy Code
要点:
- 通过单元测试来检查可疑的例程,以便发现其中的错误;
- 为了提升测试效率,我们要选用合适的单元测试框架、要重构产品代码,使其便于接受测试,并且要使测试的执行得以自动化。
断言
涌现:有些错误并不是单独由某一部分代码所导致,而是在多个代码块相互集成的过程中涌现出来的。
三个条件:
- 前置条件:precondition,必须满足这些条件,算法才能够见效;
- 不变条件:invariant,算法在处理过程中,会令已经处理好的那部分数据满足这些条件;
- 后置条件:postcondition,当算法根据规范把数据处理好之后,这些条件必然成立。
用于排解各种问题的断言的用法:
- 在程序的开头放置断言,以验证CPU架构属性(例如其整数型数据的尺寸)是否符合要求;
- 在例程的入口点放置断言,以验证传入的参数是否具备正确的类型(如果你所使用的编程语言不对参数类型进行检查),并确保它们具备有效(如不为null)且合理的取值;
- 在例程的出口点,验证其结果是否正确;
- 对于经常受到调用或较为复杂的方法来说,可以在其开头与结尾放置断言,以验证类的状态是否能够保持一致;
- 如果要调用某个不应该出错的API例程,那么可以在调用完该例程之后,通过断言来确保这一点;
- 把软件所需的资源加载进来之后,用断言来验证该资源是否得到正确的部署;
- 在对复杂的表达式进行求值之后,用断言来验证其结果是否具备应有的属性或合理取值;
- 在switch语句的default分支里面,加入一条表达式取值为false的断言语句,以捕获未能由其他分支所处理的情况;
- 对数据结构进行初始化之后,用断言语句来验证该结构是否具备应有的取值。
改动受测程序验证推想
要点:
- 手工设定代码中的某些值,以验证哪些取值是正确的,哪些取值是错误的;
- 如果找不到修改代码的正确方法,可以试着用其他的方式来实现它。
尽量缩小正确范例与错误代码之间的差距
要点:逐渐缩减你自己的代码,使其与范例代码相符,或是逐渐修改范例代码,使其与你自己的代码相符,这两种办法都可以用来查找程序出错的原因。
简化可疑代码
TODO
要点:
- 有选择地删除大段代码,使错误变得更加突出;
- 把复杂的语句或函数拆成多个小的部分,以便单独监控或测试其功能;
- 考虑弃用那些可能会出Bug的复杂算法,并改用简单一些的算法来实现。
将可疑代码用另一种编程语言改写
REPL:read-eval-print loop。
如果新代码能够正常运作,此时有两种做法:
- 采用新代码实现相关的功能,并把原来的代码删掉;前提是两种语言结合得较好;
- 参照新代码来修正旧代码。
要点:
- 采用另外一种表达能力更强的语言来改写那些难以修复的代码,以减少可能出现问题的语句数量;
- 把有Bug的代码移植到更好的编程环境中,以便采用更为强大的调试工具来解决其中的问题;
- 把新的实现方式写好之后,可以采用这种方式来完成原有的功能,或是参照它来修改旧的代码。
改善可疑代码的可读性与结构
代码格式化很重要,工具:IDE、clang-format及indent。
其次是重构。
增加代码Bug被暴露的概率。
清除Bug根源,而不仅仅消除其症状
NPE,空指针,一类特别常见的Bug。很多开发者(包括我)都会加上if
条件判断来解决
问题,(很多场景下)问题并没有解决;为啥这个字段(对象、数据)会是null,应该从源头处深入分析。
这类通过if
条件判断的方式解决
问题的做法,可能会引入新的问题:
- 没有清除Bug根源,程序里面可能还会存留一些不那么明显的症状,有些Bug以后或许会以另外一种形式表现出来;
if
条件判断,增加代码复杂度,变得难以理解和修改;
要点:
- 不要采用临时代码来绕开程序的表面症状,而是要查找bug的深层原因并加以修复;
- 尽可能采用通用的办法来处理复杂的情况,而不要只修复其中的某些特例。
编译时调试技术
对生成的代码进行检视
使用静态程序分析工具
工具:GrammaTech的CodeSonar、Coverity Code Advisor、FindBugs、Polyspace Bug Finder,以及种种以lint
结尾的程序。参考代码质量工具。
关于常见的潜在的Bug范例,Java开发使用IDEA会给出很多优化建议,还可以安装Alibaba Java Coding Guidelines插件。
C++程序,Rule of Three(三法则)/Rule of 0(零法则)。
代码分析工具可能会错过开发者能够发现的某些Bug(假阴性, false negative),也可能会对正确代码发出警告(假阳性,false positive)。
把警告消除干净之后,可以趁势调整编译器的选项,令其将所有的警告都视为错误。
要点:
- 某些专用的静态程序分析工具,有可能会找到很多潜在的Bug,其数量要比编译器所能给出的警告更多;
- 配置好编译器,令其能够对程序进行分析,并找出Bug;
- 至少要将一款静态程序分析工具,纳入你的构建流程和CI流程中。
对项目进行配置,令程序能够以固定的方式构建和执行
对调试所用程序库及构建代码时所应执行的检查进行配置
运行时调试技术
测试用例
qmcalc程序:能够根据各种指标来计算C语言代码文件的编写质量,并给出评分,然后把这些评分放在一个以tab制表符为分隔的列表中。
DDT:Defect Driven Testing,缺陷驱动测试。三个步骤:
- 创建测试用例,以便可靠地重现你想要解决的问题;
- 简化测试用例,令其刚好能够重现你想要解决的问题:
- 从头开始构建测试用例,直到bug突然出现;
- 逐渐缩减现有的测试用例,直到bug突然消失。
- 找到问题后,给代码添加对应的单元测试(某个错误)或回归测试(多种因素结合导致)。
针对已解决的问题来添加测试用例,或许并不是多此一举的事情:
- 可能会忘记修复某种特定的情况,而这个测试正好能够在执行代码时帮你找到那种情况;
- 如果有人对合并冲突没有做出正确的处理,那么可能会再度引入同样的错误;
- 以后也可能有人会出现类似的错误;
- 或许还能够捕获到与之相关的其他错误。
测试覆盖率分析工具:适用于C和C++的gcov、适用于Java的JCov,JaCoCo和Clover、适用于.NET的NCover和OpenCover、适用于Python的coverage包,适用于JS的blanket.js。
令软件在遇到问题时尽早退出
迅速而高效地重现问题,有助于提升调试的效率。因此,一旦发现软件有出错的迹象,就应该立刻令其停止运行。即Fail Fast,而不是写一大堆的try…catch语句这种防御式编程。
使程序尽快暴露出其中的错误的方法:
- 添加并启用断言语句,以验证输入给例程的参数以及对API的调用是否正确;
- 对程序库进行配置,令其对自身的用法执行严格的检查;
- 用动态程序分析工具来检查程序所执行的操作;
- 启动Unix Shell时,开启
-e
选项,使得该Shell能够在脚本命令发生错误时(退出状态码非0时)终止。
但是生产系统,Fail Fast就不太合适,线上应用最先保证的应该是程序的恢复能力。当程序发生小错误时,应该容许它继续执行,而不是令整个系统都停止运行。当然,需要做好监控及日志记录等,在下次版本发布时,修复掉这些小问题。
检视应用程序的日志文件
Windows系统的应用程序日志,是以一种不透明的格式来存储的。可以运行Eventvwr.msc
命令,以启动名为Event Viewer的GUI应用程序,并在其中浏览及过滤日志文件,也可以使用Windows PowerShell的GetEventLog命令,或是对应的.NET API来查看。日志分成很多个种类,你可以在Event Viewer左侧的树状列表里面,按照类别来进行浏览。
Unix系统:
- 消息级别:emergency,紧急、alert,警告、informational,信息、debug,调试;
- 消息类型:authorization,身份验证、kernel,内核、mail,邮件、user,用户。
syslogd:或rsyslogd,运行在后台的程序,负责监听系统中的日志消息,并将其记入文件,可以对该程序进行配置,告诉它应该怎样处理特定的消息。与此相关的配置文件为/etc/syslog.conf
(或/etc/rsyslog.conf
,此外,/etc/rsyslog.d
目录里可能还有一些配置文件)。
分析日志记录的几种方式:
- 使用系统自带的GUI事件查看器,并运用其搜索与过滤功能;
- 用编辑器打开并处理日志文件;
- 用Unix工具来过滤、汇总并筛选相关的字段;
- 对日志进行交互式的监控;
- 使用ELK、Logstash、loggly或Splunk这样的日志管理应用程序或服务;
- Windows系统下,可使用Windows Events Command LineUtility(wevtutil)来执行查询并导出日志。
对系统和进程所执行的操作进行性能评测
追踪程序的执行情况
追踪工具的优势:
- 即便正在调试的这款应用程序不提供日志机制,也依然能够获取到一些数据;
- 无需专门去准备Debug版本的软件,况且Debug版本的软件有可能会掩盖最初的问题;
- 与带有图形界面的调试器相比,这种办法更加轻便,从而可以在没有安装相关工具的生产环境中使用。
适用于Unix系统的调用追踪工具包括ltrace(用来追踪对程序库的调用)、strace、ktrace以及truss(这三个工具用来追踪对操作系统的调用);适用于Java程序的工具是JProfile;适用于Windows系统的工具是Process Monitor(用来追踪对DLL的调用,这些调用既包括对操作系统接口的调用,也包括对第三方库接口的调用)。这些工具通常会使用特殊的API或通过代码补丁(Code Patching)技术来把自己挂在待调试的程序与其外部接口之间。
DTrace:Sun公司开发的动态追踪框架,能够以一套统合的机制来全面而自然地监控操作系统、应用程序服务器、运行时环境、程序库、应用程序。可运行在Solaris、OS X、FreeBSD及NetBSD系统上面。对于Linux系统来说,SystemTap及LTTng提供类似功能。
DTrace实现的这套机制,使其能够安全地运用到所有的操作系统内核函数、动态链接库、应用程序函数、特定的CPU指令以及JVM上面。还研发一款安全的解释型语言(D脚本),令开发者能够以此来编写复杂的追踪脚本,脚本不会损害操作系统的功能,且能够在不耗费大量内存的前提下,灵活地将多个函数统合起来,以便对追踪到的数据进行汇总。DTrace整合现有的大多数追踪工具及某些知名的解释型语言,并具备那些工具和语言所拥有的一些技术与奇妙的功能,进而成为一套包罗万象的程序追踪平台。
D脚本语言,和awk、sed,以及其他很多声明式的语言(Declarative Language)类似,由成对的模式与动作所构成的。模式(DTrace叫做谓词)用来指定探测器,也就是用来指定你想要监测的事件。
DTrace预先定义上千种探测器。像是应用程序服务器与运行时环境等系统程序,也可以定义它们各自的探测器,还可以在程序或动态链接库中的任意位置上安插探测器。
DTrace有数十种提供器,可以对性能统计机制、全部的内核函数、锁、系统调用、设备驱动、输入事件与输出事件、进程的创建及终止、网络栈的管理信息库(management information base, MIB)、调度器、虚拟内存操作、用户程序中的函数与任意的代码位置、同步原语、内核的统计机制以及Java虚拟机的操作进行追踪。
在使用谓词时,可为其定义一项动作。如果谓词的条件得以满足,那么DTrace就会执行这项动作。动作也可以定义得复杂一些,如,可在其中设置全局变量或线程的局部变量,可以把数据存放在关联数组中,也可用计数、求最小值、求最大值、求平均值以及量化等函数来对数据进行聚合。
有些函数可以获取资源,有些函数可以释放资源,将这两种函数配套使用,可以轻松地调试资源泄漏问题。
Byteman:能够在无需重新编译代码的前提下,向应用程序的方法或Java的运行时系统中注入Java代码。可以通过清晰而简单的脚本语言来告诉该工具何时应该对原始的Java代码进行转换,并对转换的方式做出规定。使用Byteman有三个好处:
- 无需获得受测程序的源代码,因此不仅可以用它来调试自己所写的程序,而且也可以用它来调试第三方的程序;
- 可以故意注入一些错误的内容或类似的状况,看看程序会做何反应;
- 可以在Byteman脚本中,检查受测程序当前的内部状态与它本来应该处在的状态是否相符,并且在二者不相符时,令该测试用例失败。
Windows Assessment and Deployment Kit:
- Windows Performance Toolkit:
- Windows Performance Recorder:记录组件,可在性能有问题的系统上面,对你认为重要的那些事件进行追踪;
- Windows Performance Analyzer:完全采用Windows系统风格,通过漂亮的图形界面来展示分析结果,并允许对这些表格进行操作。
使用动态程序分析工具
动态分析工具:检测是在程序运行时执行。可对编译后的程序进行修改,向其中注入一些检测例程,可对程序的执行情况进行监控,并把有可能出错的地方汇报给用户。
与静态分析工具相比,动态分析工具更容易检测到实际上发生的错误,因为是直接在程序执行代码时进行追踪,而不是像静态工具只是推测程序有哪些潜在的Bug。但是,动态分析工具只关注程序实际执行的代码,或许会忽略尚未执行到的那些代码路径里面所包含的错误,有可能发生漏报。
动态分析工具的功能:检查程序有没有使用未经初始化的变量、有没有泄漏内存、有没有访问可用内存空间以外的地址;寻找安全漏洞、尚未完全优化的代码、处在受测范围之外的代码(程序测试有疏漏)、隐式的类型转换操作、动态类型不一致、数值溢出、并发错误。
Valgrind:开源的动态分析系统,包括一个功能强大的内存检测组件。
Jalangi:适用于客户端及服务器端的JS程序,可对JS代码进行转化,令其通过API来展示自己的执行情况,从而编写脚本来对其在发生特定事件时的执行情况加以验证,如验证二元算术操作的求值情况。
调试多线程代码
CPU制造商会在芯片所能容纳的前提下,把越来越多的晶体管放入多个核心中,并请求开发者善用这些核心。如果运行在多个CPU内核上的各条执行线程彼此不进行协调,那么它们就会各自按照最快的方式执行。而这样做,会令程序的执行效果显得不那么确定,因为每次运行程序时,这些线程的执行顺序都会和上一次稍有区别。
通过事后调试来分析死锁问题
捕获并重现
用专门的工具来探查死锁与竞争条件问题
静态分析工具:如FindBugs,可分析出不正确的同步元素、未匹配的wait()与notify()、不一致的同步、未加保护的字段以及未能释放的锁等。
动态分析工具:能够分析程序在运行时的行为,从而找到软件中的竞争条件、死锁与API误用等现象,以及造成软件性能下降的原因。如:Intel Inspector。
对程序的内存转储文件进行分析,并据此来调试死锁问题。但是死锁的发生概率很低,很难抓取到这样的一份转储文件。
Valgrind工具包中的Helgrind工具,动态分析工具,能够对加锁的顺序进行建模,并据此分析潜在的死锁问题。如果某算法能够分辨出这种加锁顺序,究竟是一种可以确定的顺序(如通过消息传递机制建起来的固定顺序),还是一种无法确定的顺序(偏序),即便程序没有发生死锁,它也照样能够检测出潜在的死锁问题。能够对以POSIX Threads原语所写的C、C++及Fortran程序进行检查,进而找出其中的并发错误。
把不确定的因素隔离出来,或将其移除
有两种办法能够用来应对这些不确定的因素:
- 把这些行为不确定的代码与其他代码隔开;
- 对这些代码进行适当的实现与配置,令其变得较为确定。
Humble Object模式:谦卑对象模式,一种设计模式,最初被认为是一种帮助单元测试人员将易于测试的行为和难以测试的行为分开的方法。在架构的边界处采用此模式可增加整个系统的可测试性。
移除法是用行为可以预测的实体来替换那些行为不太确定的实体。这些配置当然只应该在调试和测试的时候启用,而不应该带到生产环境中。
要点:
- 把并发代码与其他代码隔开,使我们能够分别针对这两个方面来运用最为合适的调试工具与调试技术;
- 创建一份专供测试与调试所用的配置方案,并通过模拟对象等技术,把代码的行为固定下来,从而令程序在每次执行的时候,都能够展示出同样的效果。
检查资源争用情况,以解决与可伸缩性有关的问题
资源争用问题:如果系统的整体性能(通常以延迟时间或吞吐量来衡量),没有能够随着当前可用的资源(如CPU核心数)而变化,这就有问题。
profiling工具,如Oracle的JFR,Java Flight Recorder,Intel的VTune Amplifier。
JFR的配套工具,JMC,Java Mission Control图形界面。
用性能计数器寻找伪共享问题
OpenMP库:Open Multi-Processing,一套支持跨平台共享内存方式的多线程并发的编程API,使用C,C++和Fortran语言,可以在大多数的处理器体系和操作系统中运行。包括一套编译器指令、库和一些能够影响运行行为的环境变量。—— From wikipedia.
伪共享问题:False Sharing,使用多线程后,程序效率反倒下降。
缓存一致性协议:CPU核心同步协议,用来保证各线程总是能够看到相互一致的内存数据。多个CPU核心在共享内存时,每个核心都有可能把自己频繁使用的那部分内存内容,复制到自身的局部缓存中。如果某个核心要向局部缓存中的某个位置写入数据,而与该位置相对应的那个内存地址,同时也缓存到另外一个核心的局部缓存中,并且那个核心又想要读取该地址中的数据,那么CPU就会触发一套复杂的机制,以确保这两个核心都能在其各自的缓存中看到相同的内容——这就是同步协议所要解决的问题。
一般情况下,每条线程都应该各自操作不同的内存区域,而不应该去干涉其他线程所操作的那块区域。
可以探查CPU的性能计数器:把与CPU性能有关的事件记录下来,如执行的指令数量、缓存未命中的次数。工具包括:Visual Studio的ConcurrencyVisualizer扩展、Intel的VTune Performance Analyzer以及Linux的perf命令等。