« April 2018 | Main

May 08, 2018

断点单步跟踪是一种低效的调试方法

断点单步跟踪的交互式调试器是软件开发史上的一项重大发明。但我认为,它和图形交互界面一样,都是用牺牲效率来降低学习门槛。本质上是一种极其低效的调试方法。

我在年少的时候( 2005 年以前的十多年开发经历)都极度依赖这类调试器,从 Turbo C 到 Visual C++ ,各个版本都仔细用过。任何工具用上十年后熟能生巧是很自然的事。我认为自己已经可以随心所欲用这类工具高效的定位出 bug 了。但在 2005 年之后转向跨平台开发后,或许是因为一开始没能找到 Linux 平台上合适的图形工具,我有了一些时间反思调试方法的问题。GDB 固然强大,但当时的图形交互外壳并不像今天的版本这么完善。当时比较主流的 insight ddd 都有些小问题,用起来不是十分顺手。我开始转换自己平时做开发的方式。除了尽量提高自己的代码质量:写简洁的、明显没有问题的代码之外,多采用不断的代码复核(Code Review),有意识地增加日志输出,来定位 Bug 。

后来开发重心从客户端图形开发逐步转向服务器,更加显露出用调试器中断程序运行的劣势来。对于 C/S 结构的软件,中断一边的代码运行,用人的交互频率单步跟踪运行,而另一边是以机器的交互频率运作,像让软件运行流程保持正常是非常困难的。

这些年的工作中又慢慢加入一些 Windows 下的开发工作。我发现经过了再一个十年的训练,即使偶尔用上交互式调试器,也体会不到什么优势了。往往手指按在跟踪调试按键上机械的操作,脑子里想的却不是眼前看到的屏幕上的代码。往往都没执行到触发 Bug 的位置,已经恍然大悟发现写错的地方了。这种事情多了,自然会对过去的方法质疑,是什么导致了调试器的低效。

有时和人聊天,谈及该怎么定位 Bug 。我总是半开玩笑的说,你就打开编辑器,盯着代码看啊。盯久了,Bug 自然就高亮出来了。这固然是玩笑,但我的理念中,一切调试方法都比不上 Code Review 。无论是自己写的代码,还是半途介入的别人的代码。第一要务就是要先理解程序的总体结构。

程序总是由一段段顺序执行的小片代码段辅以分支结构构成。顺序执行的代码段是很稳定的,它的代码段入口的输入状态决定了输出结果。我们关心的是输入状态是什么,多半可以跳过过程,直接看结果。因为这样一段代码无论多长,都有唯一的执行流程。而分支结构的存在会让执行流依据不同的中间状态做不同的数据处理。考虑代码的正确性时,所有的分支点都需要考虑。是什么条件导致代码会走向这条分支,什么条件导致代码走向那条分支。可以说分支的多少决定了代码的复杂度。现在比较主流的衡量代码复杂度的方法 McCabe 代码复杂度大致就是这样。

一个软件的整体 McCabe 复杂度一定远超人脑可以一次处理的极限。但通常我们可以对软件进行模块划分,高内聚低耦合的结构能减少软件复杂度。一个高内聚的模块,可以和外部隔离,方便我们聚焦到模块内部来分析。当焦点代码的规模足够小的时候,包含一切分支结构的所有流程就能一次性的被大脑处理了。对于用调试器辅助观察程序的执行流程来说,每次用真实的输入数据驱动的执行过程一定是沿唯一的路径运行的。为了定位 Bug ,我们需要设计出可以触发 Bug 的输入状态。对于一个局部模块来说,这并不总是容易的事。但靠大脑分析一个模块则不同,在 McCabe 复杂度不高时,几乎是可以并行的处理所有的执行路径的。也就是说,你在扫描代码的同时,大脑其实是在同时分析所有可能的情况,同时还能对不太重要的分支做剪枝。当然,和所有技能一样,分析速度和能分析的宽度(复杂度)以及剪枝的正确性是需要反复训练才能拓展的。过于依赖交互式调试工具会影响这种训练,大脑受工具的影响,会更关心眼下的状态:目前运行到哪里了,(为了提高调试效率)下个断点设到哪里去,现在这组变量的值是什么…… 而不太关心:如果输入是另外一种情况,程序将怎么运行。因为工具已经把这些没有发生的过程剪掉了,等着你设计另一组输入下次再展示给你。

交互调试工具通常缺乏回溯能力,也就是它们通常反应当下的状态,而不记录过去的。这有些可以通过改进工具来完善,有些则不能。一个常见的场景是,你定下了下一个断点的位置,当调试器停下来的时候,发现状态异常,只能确定问题出在上次断点到当前的位置之间,但想回溯到底发生了什么,某个中间状态是什么,工具却无能为力。而靠大脑推演程序的运行过程的话,一切都是静态图谱,回溯和前行并无太大区别,只是聚焦到时间轴上某个位置而已。这就是为什么受过良好训练的程序员可以一眼看出 Bug 在哪里,而调试器运用高手却需要反复运行两三次才能找到 Bug 的缘故。

在大脑中正确运行程序当然需要足够的训练,比训练使用调试器难的多,但却是值得的。不知道其它同学有没有类似经历:我在中学时代参加信息学竞赛的时候,考卷并不全是编程题,尤其是初赛阶段,一般是纸面考卷,有很多题目都是给出程序和输入,写出输出结果。感谢这段经历,我不得不在初学编程的时候就进行这类训练。初中的时候,每天可以摸到真机的时间是按小时计的,大部分时间还是在传统的学业上。为了编写自己玩的游戏程序,我只能在上课的时候偷偷的在本子上手写代码。写完了后如果没有下课,我会在大脑中模拟运行一下,看看有没有 bug ,能在上机前改过来,就可以更有效的利用每天有限的上机时间。这些经历让我觉得读代码其实没那么枯燥,是提高效率的一种方法。

用 Code Review 作为主要的定位 Bug 的手段,可以促进你写出复杂度更小(更不容易出错)的程序。因为知道以你目前的能力大脑能一次处理的复杂极限在哪。在减少分支方面,我看过 Linus 的一个访谈节目。他谈及代码品位,举了一个很小的例子:一段对链表的处理程序。链表的头部通常和中间的结构不同,头部之外的节点都有一个 next 指针引用下一个节点,而头节点是个例外,是由不同的数据结构引用的。再 Linus 列出的反面例子中,代码判断了头指针是否为空;而在正面例子中,next 指针是用一个指针引用变量实现的,对于头节点,它引用在不同的数据结构变量上,这样就回避了多一次的例外(对于头节点)判断。代码可以一致的处理。在那个只有 5,6 行代码的小片段中,似乎判断语义非常清晰,多一次判断微不足道,但 Linus 强调这是品位选择的问题。我认为,这其实就是将减少代码复杂度提升到书写代码的本能中。

对于中途介入的他人的项目,你无法控制代码的质量。但长期的 Code Review 训练可以帮助你快速切分软件的模块。通常,你需要运用你对相关领域的知识,和同类软件通常的设计模式,预设软件可能的模块划分方式。这个过程需要对领域的理解,不应过度陷入代码实现细节。一上手就开调试器先跑跑软件的大致运行流程是我不太推荐的方法。这样视野太狭窄了,花了不少时间只观察到了局部。其实不必执着于从顶向下还是从下置上。可以先大致看看源代码的文件结构做个模块划分猜测,然后随便挑选一个模块,找到关联的部分再顺藤摸瓜。对于需要构建的项目,摸清程序脉络的时间甚至可以在第一次等待编译构建的时间同步完成,而不需要等待构建完毕在一步步跟踪运行,甚至不需要下载代码到本地,github 这种友好的 web 界面已经可以舒适的在浏览器里阅读了,有个 ipad 就可以舒服的躺在床上进行。

我不太喜欢 C++ 的一个原因是:C++ 代码从一个局部去阅读,很难有唯一的解释。它的代码字面意思很可能对应有多种实际操作含义,确定性不足。函数名重载、操作符重载都是隐藏在局部代码之外的。甚至你看到一个变量名,不去同时翻阅上下文及头文件的话,都很难确定这是一个局部变量还是一个类成员变量(前者的影响范围和后者大为不同,大脑在做分析的时候剪枝的策略完全不同);看到一个变量,原本以为是一个输入值,直到看到最后,发现它还可以做输出,回头一看函数声明,其实它是一个引用量。如果用到模板泛型就更可怕,连数据类型都不确定。只从局部代码无法得知模板实例化之后那些关联的操作到底做了些什么。阅读 C++ 项目往往需要在代码间相互参考,增加了大脑太多的负担。

那么,光靠大脑 Code Review 是不是就够了呢?如果自身能力无限提高,我认为有可能。通过积累经验,我这些年能直接分度阅读的代码复杂程度明显超过往年。但总有人力所不及的时候。这时候最好的方法是加入日志输出作为辅助手段。

试想我们在用交互调试工具时,其实是想知道些什么?无非是程序的运行路径,是不是真的走到了这里,以及程序运行到这里的时候,变量的状态是怎样的,有没有异常情况。日志输出其实在做同样的工作。关键路径上输出一行日志,可以表达程序的运行路径。把重要的变量输出在日志里,可以查询当时的程序运行状态。怎样有效的输出日志自然也是需要训练的技能。不要过于担心日志输出对性能的影响,最终软件有 20% 上下的性能波动对于软件的可维护性来说是微不足道的。

和外挂的调试工具相比,日志具备良好的回溯查询能力。作为 Code Review 的一个辅助,我们大脑其实需要的只是对判断的一个修正:确认程序是否是沿着脑中模拟的路线在行进,内部状态是否一致正常。和调试工具不同,日志不会打断运行过程,对多个程序并行运行的软件,例如 C/S 结构的系统就更为重要了。

其实保留状态信息在交互调试工具中也是非常重要的技巧。我相信很多人和我一样,在调试程序时有时会增加一些临时的全局变量,把一些中间状态写到这些变量中。在交互调试过程中偶尔需要去查看这些状态值。这种临时状态暂存变量,其实也充当了日志的功能。

文本日志的好处是可以利用文本处理工具做信息二次提取。grep awk vim python lua 都是分析日志的好手段。如果日志巨大,且存在在远程机器上,你很可能找不到更有效快捷的手段。很多时候,不断的重新运行有 bug 的程序的代价,是远超一次运行得到详细日志后再对日志做分析的。

那么,学会使用交互调试工具重要吗?我认为依然重要。偶尔用之,也能起到奇效。尤其是程序崩溃的时候,attach 到进程中观察崩溃时的状态。操作系统大多也能 dump 出崩溃时的进程状态供事后分析。这些都需要你会用调试工具。但通过静态状态的草灰蛇线反推出崩溃前到底发生了些什么,却也更需要对代码本身有足够的理解。因为用的时机不多,我认为命令行的 gdb 就足够用了。在分析损坏的栈帧、编写脚本分析一些复杂数据结构方面,命令行版本更具灵活性,应用范围也较广。而交互上的不便,增加的学习成本,都是可以接受的。

May 05, 2018

《冰气时代》末日下的人性考验

劳动节假期前夜,跳票了一年的 Frostpunk 上架了。我的五一假期有一半时间是都在挖煤。我想这是一款堪称艺术品的游戏,因为他能带来娱乐之外的东西,所以我一定要专门为它写一篇 blog 。

出于对开发商的信任,我第一时间就买了这款游戏。毕竟他们的前一个作品——《我的这场战争(This War of Mine)》就充满着人文情怀却不失游戏性,让我沉迷了好几个晚上。我相信新作不会让人失望的。

果不其然,冰气时代依然透着 TWoM 中那种悲天悯人的独特气质,一上手就能感觉到是同一帮人的作品。而且这次画面质量还出奇的好。出场的时候踏着及腰的积雪出去拾煤的小人,一点点在雪地里趟出一条路来。冷风呼啸而过,在屏幕上结出冰来。这些都让人有一种 3A 大作的感觉,完全不像个独立游戏(当然从制作人员名单来看,制作团队整容也作实庞大)。其实即便把画面抹掉,依据其玩法内核做成一个文本游戏,我依然会觉得好玩。

第一盘游戏连带教学在内,我一次就通关了主线剧本。虽然不是很理解游戏的全部机制,但全程都没有什么障碍。我认为这是作为一款游戏,设计的成功之处。游戏并没有手把手的教学,你只要凭直觉玩就好了。关键之处,用提示逐步显示出来,挂在屏幕左侧,不去干扰游戏进程,却可以随时查看。点开后,又有带动画的详细解说。解说文字很精炼,但能点出要点,第一次看的时候容易忽略一些关键点,但后面玩的时候碰到疑惑回头来看就恍然大悟了。

游戏核心策略是控制 npc 的体温和资源产出,体温太低,工作强度太大就容易生病甚至死亡。生病后会减少劳动力增加食物供应的负担,死亡会影响希望值。整个游戏进程是用固定好的事件来驱动的,事件剧本的引入能让玩家的游戏有强烈的目的性,这是现代游戏的必需品。但如果是简单的提出任务,完成任务这种在网游中的常见做法,又会在新鲜期过去后迅速变成枯燥的打工。而这款游戏提供了选择。让你可以在两种解决方案中选择,或是干脆忽略任务(但是会在之后提出更苛刻的目标)。这种三选一的做法,不会让游戏进程的选择过多(多数沙盒游戏的做法)而变得迷茫,又避免了枯燥。另一方面,三选一的任务不断的在强调游戏的主题:作为末日城市的领袖,你怎样在为集体的生存做高瞻远瞩的长期打算的同时,又能满足民众眼下的急迫需求,好维持统治。其实游戏的一开始就借 NPC 之后提点了你:不要总听民众的,要有自己的坚持。

第一次普通难度通关,终局任务:零下 150 度的暴风雪中活下来。将它完成之后,看着系统评价和游戏过程闪回,居然有些感动。最后从暴风雪中救出女儿的父亲、为了加固煤矿而自愿牺牲的工人,都恰到好处的渲染出末日下的悲壮气息。Rimworld 的作者有本游戏设计的书这样总结道:游戏,其实是制造体验的引擎。游戏的作者通过游戏让玩家在游戏过程中不断的产生各种情感体验,无论是焦虑、兴奋、快乐、悲伤、压抑,都是一种体验。冰气时代在这方面可以说是满分。它的成功之处在于,普通难度下,其实游戏的难度很小,对于玩家决策的容错性非常大。其实你怎么玩,都能在终局暴风雪中活下来。无非是你会采取多少非常措施,牺牲多少 npc 罢了。但是它给玩家的层层体验是不断叠加的。尤其是最后为暴风雪准备过程的紧迫感(其实没准备后也没太大关系);暴风雪中每天下降的气温,配合着人民骚动的情绪,让你觉得随时都进行不下去快要崩盘了。直到最后一晚过去,太阳升起,屏幕上的冰晶融化,第一次游戏的玩家多半会长舒一口气。

我一开始被游戏的过程蒙骗了,以为这是一款类似 Factorio 那样的建造生存类沙盒游戏。玩过后两个剧本才醒悟过来,其实这是一款固有剧本的体验游戏,它在游戏的城市建造、劳动力管理这核心机制下,尽全力利用固有的事件脚本,极度渲染末世下的人性。通过让你的不断抉择:针对民众的需求做出回应,在不同的法令选择下走不同的路线,逐步的代入这个世界。这种手法给玩家的代入感甚至比 RPG 那种个人视角讲故事更强烈。

游戏的过程就是不断的选择,我认为这是一个好游戏的特征。你总可以在做不同的事情中选出你认为最合适的。是先加强科研,还是先保障 NPC 的健康;是快点排出侦察队救回更多的劳动力,还是让已有的人民能睡上更暖和的觉;多派点人出去打猎保证食物来源后,还是尽快种植出食物有个 24 小时稳定的食物来源好…… 对于固定剧本的游戏来说,或许存在全局最优解。但其实不同的路都行得通。你需要为上一个选择的结果来决定下一个选择该如何配合。

在普通难度下完成了现有的三个剧本后,我试了一盘困难难度的主线。根据成就系统的要求,我尝试了不死一个人通关。另外,给自己下了一个小目标,尽量不去碰那些违反现实道义的选择:比如极权统治,比如削减口粮、比如 48 小时加班,等等。最终我做到了,但过程并不容易。在中途用了两三次 load/save 重玩了不太满意的几个关键节点。可能困难难度下这才真正是一个策略游戏,开发组是依据这个难度设定的关卡,容错性很小,如果你做出了错误的选择,没能未雨绸缪为未来做准备,都会破坏完美结局。

完成困难主线后,我发现设计者想表达的东西还是非常正面的。只要你决策正确,其实不必有加班、不用喝汤、不用对民众采用非常措施、救下所有的人,为他们提供最好的医疗条件,所有人都可以活下来。反而,如果你强迫大家超时工作,为了节省资源而降低工作场所的条件,用非常措施制裁那些反对你的人,只能收获短期的利益,长远看必有负面效果。开发者把这条最优解法藏了起来,在第一盘体验游戏的过程中几乎没可能走出这条最优解。因为刚接触游戏的玩家不可能了解游戏的机制细节,也不知道预设的事件。那些事件的选项也极具迷惑性。一步错,就步步错,会走到次优的选择路径上(但依然可以带点遗憾的完成游戏)。这种设计方法也很巧妙,让游戏有了再玩一遍的价值。


附上我的困难模式主线通关策略:

开局用双工作站开局攀科技。所有人优先采集木材,其次钢铁,煤够用就好。注意:开局的时候白天其实是不用开炉的,因为游戏是按人民所在位置来判定体温。工作时间人都不在家里,所以烧炉是浪费。晚上时间开炉就好,要尽量利用过载,即使出了二级炉温也可以降到一级用过载达到同样的温度。这样就可以尽可能的减少初期煤的消耗。

一定要重视采集站,争取在第二天就出三个采集站,而不要露天采集。因为采集站提供一级热源,这样工人就更不容易生病。住所一开始是不必要的,把木材留给初期建设。但一旦剧情提出要求,可以答应修满住所,因为可以获得大量希望值,且降低不满。

我在第一次游戏时选的儿童庇护所加科研帮手的法令;在困难难度下发现效率和童工比实在的低太多了。童工可以让第一天多 15 个采集者,这对抵御第一次降温非常重要。童工后面的负面法令就不必点了。其实过了第一次降温,你就不太需要童工了。

第二天开始,就必须开始筹集食物,饿肚子会更容易生病。只要你提前准备,喝汤和加料都不需要。保证工人的健康更重要,因为一旦工人生病,他就不再是劳动力,而且你必须用额外的人手照顾他。初期按剧情一定会发病,所以要提前准备足够的病房。越快让病人重回工作岗位非常重要。

法令方面,初期不要浪费时间。一有机会就开新法令。除了童工外,第二第三出延长工作时间。加班只是过度用的,不要真的用,否则一定会有过劳死。其实延长工作时间也就是一开始在科研上突击一下,过了初期就可以关了。所有损伤工人健康的从长远看都对生产效率有负面影响。

科技方面,加速采集对一开始很重要,然后就应该快速出灯塔。派出侦察队越早越好。因为这意味着越早有劳动力补充和额外的资源收入。在工人分配方面,需要做一些微观控制。比如医疗场所要够,但一旦病人出院,就应该把工程师派出来。科研只在白天工作,而医疗是 24 小时的,所以可以在晚上拉人过去。食物采集只占用半天时间,所以工人可以有小半天时间做别的事情。

因为剧情上有第一波寒流的设定。所以前期一定要尽快把炉温升级。不然会冻出病来。不过在降温前升好并不是必须的,过载其实可以多顶一个晚上。另外,第一次降温是在白天,加热器可以帮你顶过第一个白天。如果细致安排的话,多出来的资源可以尽快出第二支侦察队。

过了第一次降温后的升温期就要考虑出三级科技了。机械化是后期的关键。有了机器人之后,人民就不用去冰冷场所工作,这样可以极大的减少得病的概率。科研场所的温阶是比较高的。猎人小屋不用考虑温阶,所以尽量把所有工人都派出去打猎。食物充足的话,只要 CD 好了就可以用双倍食物加速医疗。而工程师,只要有可能就加工作站,分配进去攀科技。

等工厂出来后,突击钢产量,尽快全机械化。玩到这个阶段如果还没有死过人,基本就正反馈了。伦敦帮出现后不用太担心,走信仰路线还是秩序路线都可以,选必要的法令点就够了,不必一路下去发展到极端政策。我在困难模式下玩的秩序路线,用监狱很快的平息了伦敦帮。其实困难模式还是提供了许多容错性的,比如我就多点了一次抓捕,导致最后有一个犯人到最终的暴风雪阶段才放出来。为了保护这一个人,白占了四个房子的位置,迟迟不敢拆监狱。如果玩的正确,应该可以在暴风雪来临前把所有伦敦帮的人关够时间,然后彻底拆掉监狱的。当然如果不满值控制的很低的话,提前拆掉监狱问题也不大。

只要不死人,不生病,劳动力就很充足,后期难度就不大。工人方面加强食材产量即可,别的工作都是机器人在做。必要的时候多开几个伙房生产。食物可以加快病人恢复,没有病人又可以把医生移到科研上,形成正反馈。另外注意不要浪费你选的路线上的增加希望的技能。希望值越高越好,这样度过终局暴风雪就会越简单。

如果你想像我那样同时完成集中供暖的成就的话,后期最大的麻烦可能在住房空间上。满级的范围下盖满房子也就够住不到 700 人。而全部人活下来的话,大约有 690 人。也就是说,你必须规划好空间,必须挨着建房屋。其它功能建筑都必须安排在外面。这点从一开始就要注意,必须太多的拆迁。像猎人小屋、气球、储藏站都是不需要温度的,一开始就应该拉条路放在最外圈,内圈留下空间规则的排放住宅。游戏中期就应该有意识地重新规划之前不规则的建筑。

关键点存个档,搞错了就读回来重玩。不犯大错误的话,完美通关不是问题。