尽早崩溃原则在游戏引擎中的实践
很多程序员喜欢辩解说“尽早崩溃”是最佳实践——在遇到问题的第一时刻报错,报得越早越好,毕竟越接近问题发生的第一现场越方便他们调试。这个说法本身当然是没问题的——如果你用个默认值糊弄一下,或者是写某种容错逻辑来忍耐了破坏假定的行为,那么错误很有可能会蔓延到之后更加远离出问题的地方才爆发,那时丢失了源头和上下文,查错的成本会变得非常高。
如果只考虑程序员,不考虑团队中同样依赖每日版本来工作的策划,美术和测试的话,这个思路是没有问题的。然而跟程序员不同,当发生崩溃时,团队内的其他成员能做的非常有限——以最快速度通知程序员,版本挂了 (The build is broken!!!)。如果坏的地方正好是他们工作的部分,那么他们只好停下来,等待修好才能继续工作。否则要么一直备有一个可靠的老版本 ,要么手动回滚。
现在请摘下程序员的帽子,假设自己是一个负责“测试多人副本,任务和活动”等业务逻辑相关的测试人员,每测一次都要花不少时间进入测试情境,偶尔甚至需要多人一起协同测试。那么一个跟你的工作毫不相关的底层崩溃,所带来的影响就会被迅速放大,很可能得完版本后的几个小时就在反复尝试和等待之中被消耗掉。
这是一种惊人的浪费。
给程序员巨大便利的“尽早崩溃”,对非程序员来说,意味着日常开发中的每一天,都要冒着被“不可控的因素”延误工作的风险。有人说,正确的姿势难道不是让程序员有更好的自律,在提交前做尽可能充分的测试,确保不要搞破坏吗?是的。可是即使是经验丰富的程序员也不能拍胸脯保证自己 bug-free, 更不能保证由若干人提交的若干“不相干”的改动集成到一起就能无缝地良好工作。让非程序员去承担这种因为版本不成熟导致的效率折扣,是既不公平也不高效的。
说到“巨大便利”,不得补充一个前缀——“本机上的”,也就是说,只有崩溃恰好发生在制造这个问题的程序员的机器上 (或可以方便地即时远程调试) 时,这种巨大的便利性才得到体现。考虑到发生在非程序员环境下的崩溃,不少情况下是由于环境配置错误等杂音所致,“有经验的”程序员往往不会浪费他们“宝贵的开发时间”,第一时间赶往现场开始分析和调试(打断自己的工作跑去协助调试,满头大汗弄了半天,发现是环境配置的问题/版本问题/别人代码导致的问题,足以唤醒一个温顺的程序猿内心的洪荒之力了)。更多的,他们会在 IM 上回个消息:“嗯,这个功能我提交前测试是正常的——你的环境干净吗?需要的数据都干净地重新生成了吗?第三方库的二进制文件更新了吗?你们几个人测试的版本一致吗?要不你 Cleanup / 重启 / 重新保存 / 重新建个账号试试?”,试图通过尽可能小的时间开销来帮助诊断和解决问题。长远来看,这些试图节省调试时间的沟通,会让“尽早崩溃”所带来的巨大便利慢慢地挥发殆尽。
一个不那么容易觉察却更为严重的系统性问题是,总是采用“尽早崩溃”的实践的团队所产出的代码库,随着系统内不同模块之间的交互(以及随之而来的各种假定)越来越多,往往倾向于通过更多的断言来让系统变得越来越敏感和脆弱。因为,认真细致地考虑模块间的依赖时序,并系统性地从结构上解决过深的模块间耦合,总是比一个简单的断言要复杂得多。
“尽早崩溃”的主张是如此的简洁有力,以至于我们在那些应当通过改良结构,去除耦合来解决问题的时刻,往往简单地选择了使用断言来做一个时序上的约定。这种显式的指定会把系统的坏气味转化成太多的不必要依赖。的确,问题从表面上看起来变得更简单了——谁破坏了断言,导致了崩溃,谁就修呗——实质上,修来修去,把一个本质上可以剥离的简单交互,变成了严重依赖各种时序和条件的“靠巧合工作”的杂耍系统。
你看,“尽早崩溃”的简单性和便利性,在一些情况下反而成了一个让代码质量退化,鼓励系统熵不断增加的问题机制。那么问题来了,在满足了“必要的时候程序应当尽早崩溃”的基础上,还有什么可以选择的实践吗?
结论和思考
- 当一个和预期不符的错误状态出现时,首先判断该错误是否是在预期程序控制流中的错误,如果它没有破坏程序状态或者可以恢复正常状态,则使用错误码将错误向上传播让业务层的控制流来处理。如当文件访问被拒绝时,返回错误码并让程序继续执行即可,直接崩溃明显属于矫枉过正,针对游戏引擎这种工具类应用来说,异常耐受性是游戏开发效率的保证,尽可能避免崩溃,在发生意外情况时要尽可能的恢复并继续前进;当该错误不在预期的程序控制流中,意味着它破坏了程序状态且无法恢复,那么如果此时让程序继续执行将会出现无法预期的结果,所以此时应当让应用程序崩溃、记录堆栈和上下文信息,保存崩溃日志即可。
- 但是往往当系统严重依赖于脆弱的时序关系时,会可能出现大量无法恢复的错误状态,以致不得不使用大量断言来确保其正常执行,此时主张进行系统重构而不是"尽早崩溃",在定义尽可能少的崩溃状态的前提下,"尽早崩溃"原则才适用。
- 上面说到,如何处理错误状态取决于对该错误的定义,而同一种类型的错误在不同类型的应用程序中的定义可能完全不同。例如游戏引擎编辑器本身需要接收和链接大量用户的自定义代码,当出现自定义代码执行异常时,尽可能不直接崩溃(如在执行周期函数时try-catch,或者只停止脚本语言的runtime而不是终止整个进程),避免反复重启的过程;而如果其它类型程序有异常时一般直接崩溃处理即可。