trying


“Abort,Retry,Fail?” - 也谈错误处理


0. 简要导读

先说明一点,本文不含价值观的引导,只做观点和实践的陈述。想看到俺讨论“返回值和异常哪种更好”的可以退散了,此文不是用来回答这个问题的(那是各种编程规范和团队负责人的事情),只会谈论那些在日常开发中普遍面临的问题和对应的解决方案。


Life is an error-making and an error-correcting process. 
...And if we can be ever so much better — ever so much _slightly_ better — at error correcting than at error making, then we'll make it.

—— From Jonas Salk


1. 错误的相对性讨论

先来个地图炮吧,能在当前的抽象层次下就地处理的,就不是错误,而应被视为业务逻辑的一部分。

这句话暗含的意思是,错误处理并非孤立的流程,而与现场的上下文密切相关。当前层次的错误处理,在更高的一层看来,极有可能只是业务逻辑的一部分

举个例子,即使你的程序因为错误宕机了,对于更高层次的操作系统来说,只是从容地杀掉进程释放资源而已(一个正常的业务处理流程);即使整个操作系统崩溃了,对于更高层次的使用者——用户——来说只是从容地重启操作系统并恢复工作的上下文而已(也是一个正常的业务处理流程)。

所以说,错误处理是相对的,是上下文相关的,是与业务逻辑有机结合的。

那么弄明白这一点有什么价值呢?

理解了错误处理的相对性,我们就不会随手写出调用链上无数嵌套的 if err != nil { return err } 和反复的 catch & rethrow,把同一个错误在系统内到处传播;而是在不同的系统层次上工作时,会审慎地思考当前的情境,编写符合上下文的处理逻辑。


2. 不同时段和不同模块对错误的敏感性不同,这时比一致性更重要的是明确的区分

在同一个系统内,对错误的处理要保持一致,不要随意混用各种不同机制这种话我就不多说了,这里我们谈一下针对不同上下文的不同敏感度的区分问题。

在日常开发中非常普遍的是,对同一类错误始终使用了同样的处理方式,而大部分时候仅仅是出于惯性/惰性/复制粘贴,并不符合当时的情境。

拿手机游戏举个例子,同样是一个重要程度一般的数据文件(如一个对话框内容的本地化后的文本)打开失败,如果是应用的初始化阶段,我们可能会直接停下来,提示用户程序的数据完整性可能有问题,并提示退出,下载和重新安装。这是一个合理的响应,因为我们不知道如果继续运行,还会有多少文件会受影响。但如果程序已经跑起来有段时间了,用户已经在里面付费并正在开心地玩耍了,这时候我们是不是还会冒冒失失地弹个框“数据文件已损坏,请重新安装。”呢?合理的选择是,只要不影响游戏主流程的进行,就把错误默默记录下来,尽量让游戏继续流畅地运行吧,这就是上面提到的“编写符合上下文的处理逻辑”。


3. 注意辨别和区分 Expected Error Handling(期望错误的处理) 和 Unexpected Error Handling(未期望错误的处理)

这两者有什么区别呢?前者是程序员明确知道有可能会发生的具体错误,通常会写下对应的错误处理代码(所谓的“白盒处理”);后者是程序员在编写代码时,对可能发生(但不知道具体是什么)的情况做出的处理(所谓的“黑盒处理”)。前者就不多说了,这里主要说一下后者。

我们知道,Windows 应用程序如果一段时间内失去响应,无法处理窗口消息,Windows 就会弹出对话框提示用户,选择是否终止掉这个无响应的进程,这就是典型的 Unexpected Error Handling。明确考虑了这种错误,并系统性地处理的系统,比完全没有考虑的裸奔系统有着更高的健壮性和可靠性。

正常的业务逻辑处理流程中,对未期望错误的处理这种需求也不少见。比如事务性的操作在 Rollback 的过程中,经常是需要保证不能失败的(可能会造成递归 Rollback 导致栈溢出)。再比如一些 delegate 出去的回调函数(调用时无法得知被调用的函数具体干了些什么),有时不仅要求不能抛出异常,而且一定要在给定的时间内完成,否则就会失效。

对于C#这种具有一个单一的异常基类 (System.Exception) 的语言,有时我们经常看到一些代码懒得按具体异常一个一个处理,直接全部捕获了事的代码,如下所示:

try
{
    ...
    // lines of business logic    
    ...
}
catch (Exception)
{
    // error-handling
}

这种“一网打尽”型的处理方式就是典型的混淆了 Expected Error 和 Unexpected Error 的处理。

这种代码有几个问题,首先,因为条件太过宽泛而不可能做任何具体而有效的处理(除非在内部再根据类型展开);其次,当真正的未期望错误的处理来临时,几乎可以肯定的是,这种临时针对具体业务不会去一一考虑和处理(那样的话就太冗长了);总得来说,这种通吃型代码就像一个黑洞,把路过的所有异常全部收入囊中。如果一个程序中这种黑洞越来越多,不受控制,工程质量就很堪忧了。请注意,这里我不是在说 catch (Exception) {} 不好,尽量不要用,而是一定要知道自己在干什么,审慎地使用。


4. 注意辨别和区分受控环境和非受控环境

这两者有什么区别呢?一个较大规模的项目,代码库往往包含了各种平台组件、第三方库、工具和服务等等。在这一体系中,定义一个的明显的受控边界是很重要的。如果项目本体和第三方的代码任意交织,随意调用而不加约束,那么出了问题时,将很难确认是自己的问题还是别人的问题。打个比方,如果你的项目中不允许使用异常,那么你会允许一个可能随时抛出异常的第三方库在自己的代码中被随便调用吗?

在受控环境中,我们很容易以标准的形式去统一地定义和处理错误。不管是选择使用一张全局共享的错误码表,还是从一个标准的异常基类派生出携带各类信息的异常类体系,都能比较容易地维护一致性。而非受控环境,则往往用于跟各种不同的代码库打交道,应该以尽量考虑服从对方的需求为主,仅在必要时考虑与受控环境内进行传播或转换。有些情况下,这取决于本体对这个外部组件是接口依赖还是实现依赖。相对较重的接口依赖出于方便,往往就不再转换了。

关于边界的定义实际上是一个很紧要的问题,对实际的工程质量影响很大。举个例子,如果在 Host Runtime 上跑了一个 DSL,那么 DSL 的运行错误要如何通知 Host 呢?是不加选择地直接转换成 Host 这边的异常并随时抛出吗?是发一个错误消息后继续执行吗?需要暂时中断并等待处理结果吗?这些问题如果不考虑清楚,并定义明确的规则和边界,整体的工程质量就很难保证。


5. 注意不要混淆防御性编码和错误处理

先来看下面几段代码:

// 下面三个函数都是类 SmartFile 的成员函数
// 假设 m_file 是该类的成员变量

//-----------------------------------------------------------------------------

void SmartFile::foo_A()                
{
    assert(m_file);         // 以断言方式确认 m_file 必须有效
                            // 敏感度最高,如果不满足假定
    m_file->Read_A();       //      debug   -> 在断言处中断执行流程
                            //      release -> access violation crash
    /* 其他逻辑 */
}

//-----------------------------------------------------------------------------

void SmartFile::foo_B() 
{ 
    if (!m_file)            // 以 pre-condition 方式确保 m_file 必须有效
        return;             // 敏感度一般,不满足假定则整体跳过该函数(不被当做错误)

    m_file->Read_B();  

    /* 其他逻辑 */
}

//-----------------------------------------------------------------------------

void SmartFile::foo_C() 
{ 
    if (m_file)                 // 以最小保证为原则,仅在必要时保证 m_file 的有效性
        m_file->Read_C();    // 敏感度最弱,如果不满足,仅相关语句不执行

    /* 其他逻辑 */
}

这几段代码,每种都是日常开发中经常见到的写法,而且在注释中我们可以看到,每一种都有一定的合理性去支撑。可如果在实际项目中不考虑一致性,这几种混着用,维护的人就会很抓狂——SmartFile 这个类到底能不能保证 m_file 在用到时的有效性?如果不能,那什么情况下会无效?如果需要使用而又正好处于无效状态中,应该重置已有对象还是创建新的对象?

在实际工程的庞大业务逻辑中,这些问题往往都不是一眼能看出来的。如果带着这些疑问去写新的代码,轻则会写出很多冗余的判断逻辑,重则很难保证不会破坏原来编写者的初衷和假定。这样随着项目的推进,由于维护者对可能的状态缺乏了解,代码逻辑都会逐渐演变为“碰巧能工作”(happens to work),整个工程活动很快就会演变成“靠偶然编程”(programming by accident)。


什么是 programming by accident?

——连续调用 A B C 无法正常工作,哪位兄弟看一下?
——调一下 B A C 试试?
——还是不行,那调 A B C C 试试?
——好了?嗯,那就提交吧,收工!


6. 在明显没有做到异常安全的环境中,不要使用异常处理

这一条似乎是不言自明的,可是实践中我们还是看到了太多的反例,其中尤以 (之前提到的) 不负责任地“裸调”第三方接口为甚。

看这段代码:

void critical_business_logic(LPCRITICAL_SECTION cs)
{
    ::EnterCriticalSection(cs);

    if (g_p3rdPartyLibInterface)
        g_p3rdPartyLibInterface->RunProc();

    ::LeaveCriticalSection(cs);
}

也许这段代码一直以来都运行得很好,经历过各种线上的考验。但有经验的程序员,看到这段代码的第一时间就会皱起眉头。是的,也许 g_p3rdPartyLibInterface 这个第三方对象上的 RunProc 函数现在工作得很好,但谁能保证在以后维护升级的过程中它始终保证不抛出任何异常?一旦有异常抛出,层层 unwind 直至 Unexpected Error Handling 逻辑处(如果有的话),业务逻辑早就千疮百孔了,能继续跑下去的可能性低到接近零,这个错误就自然退化成一个 application failure 了。


问题很严重,修改起来却很简单。
理论上最好的情况是,大家都能自觉做到编写 exception-safe 的代码,把那些需要显式释放的资源通过 RAII 交给析构函数去做 (对应 C# 的 finally 和 Go 的 defer 等类似机制),这样万一因为更新第三方库什么的,发生了没想到的异常,各种资源还是会井然有序地释放,不会产生破坏性的后果。
如果无法做到 exception-safe 那至少应把第三方库的调用套一层 wrapper,处理各种 Expected Error 和 Unexpected Error,保证不会有异常溜出来。


7. 如果一段代码看起来做到了异常安全,注意新的代码不要破坏这种安全性

这一点和上一点是孪生命题。不要主动写一些破坏异常安全的代码,简而言之,是因为“部分的异常安全”基本上等同于“零异常安全”。尽量维护已经建立起来的异常安全性,一方面是为了不产生 corrupted object (也就是部分字段在 unwinding 时正常清理而新增部分不能),另一方面也是对可能抛出的点考虑和准备得更充分,避免无意中新增的逻辑在 unwinding 的过程中被无意中跳过。


8. 如何优雅地解决“忘了处理返回值”这个问题

在日常开发中,不管团队的水平如何,“忘了处理返回值”都是时常会发生的错误。各种编程指南编程修养什么的已经把“不要忘了检查返回值”说过很多遍了,有的甚至上升到人品的高度,说什么“不检查返回值的程序员都是xxx”之类让人无语的话。可是,靠程序员的记忆,习惯和修养真能保证不出问题吗?我的看法是,这个,基本上,很难。至少我自己,在看自己以往写得代码的时候,不止一次发现某个重要调用没有检查返回值,不禁后背一凉直冒冷汗,哆嗦着补上。实践证明,靠人是靠不住的,尤其是在高密度的脑力活动过后,疲劳时,人脑的可靠性会进一步下降。


那么这个问题应该怎么处理呢?

有同学的第一反应是“用异常吧”,不过结合俺上面的几条来看,这种属于大手术了,弄不好还是伤筋动骨级别的。再一个,仔细地推敲一下,异常只是保证了“在运行时抛出后,如果不响应,将一直 unwind 到程序退出为止”,并不能保证“程序员会主动编写相应的响应逻辑”,况且“没处理的异常”造成的伤害可能比“没检查返回值”还要大,很显然改异常往往是行不通的。

一个看起来挫了点(但是实践上比较有效)的办法是,把错误的返回值设计为一个指针型的输出参数,如下所示:

void this_is_so_important_that_the_error_must_be_checked(eErrorType* err)
{
    assert(err); // 或其他机制保证 err 有效

    if (bad_things_happen)
    {
        *err = ERR_something_is_wrong;
        return;
    }

    *err = ERR_ok;
}

为什么这个方案在实践中比较有效呢?这利用了一点心理学知识——不管是有意无意的忽视,还是选择性懒惰,亦或仅仅是遗忘也罢,都是起源于“函数的返回值可以被调用方忽略”这一事实。如果我们把它搞成指针形式的输出参数,那想要调用成功,不仅需要定义一个 eErrorType err; 形式的变量,而且需要以略不舒服的“传地址”方式 ( &err ) 传进去。这就相当于显式地提醒了调用者——“别忘了检查这个变量哦”。程序员也会考虑性价比的,如果已经“当当当”多敲了一行变量定义和一个别扭的参数,接下来要是不允许他顺便检查一下这个值,他们会觉得很吃亏,而且会觉得如芒在背,浑身不自在的。嗯,那我们的效果就达到了。

有同学会说:“写这种天怒人怨的接口,会被愤怒的程序员拖出去吊打的……有没有看起来正常点儿的办法?”

嗯,一个温和一点儿的办法是,当发生错误时,总是把错误关联上出问题的时间戳/线程id/进程id等信息,通过该系统的某个接口发送到一个收集错误的容器,此容器内保存了一段时间内该系统发生的各种错误,必要时还可以写到数据库去。调用方可以在需要的时候去检查该系统的错误情况。

void this_is_so_important_that_the_error_would_be_collected_into_an_error_container()
{
    assert(theErrorContainer); // 或其他机制保证 theErrorContainer 有效

    if (bad_things_happen)
    {
        theErrorContainer.AppendNewError("Oops!");
        return;
    }

    /* 其他逻辑 */
}

这个机制的好处是,一旦发生错误,除非显式丢弃,否则不会丢失。尤其适用于并发的异步调用,或没有副作用的同步调用。检查密度也可以按需控制,必要时每调必查,无所谓时可过段时间再查一下。

有同学又会说:“这个虽然错误丢不了,可是要是自始至终完全忘了检查那个错误容器了该咋办咩?”

好说好说,说话间俺又掏出一个锦囊,上书金光闪闪的“回调”二字,拆开定睛一看,只见上面蝇头小楷密密写道:“发起调用前,需要先注册错误处理函数(否则调用直接报错退出),出错时调之即可。”

这样做的好处不仅是用一个函数把每次调用都要写的错误处理逻辑收拢到了一处,而且还可以用来控制发生错误时的控制流,如使用该函数返回一个 boolean 来决定,是忽略错误继续执行,还是直接退出结束本次调用。

嗯,关于“忘了处理返回值”,这一次就先说这么多吧。


9. 如何有效地处理应用程序的 crash

看到这个话题,有同学可能会说,crash 了生成 core dump,然后把当时的 log 和其他上下文信息通过错误收集工具发到开发者的服务器上,再使用自动化工具根据 callstack 的调用链分类排序,开发者就可以调试和诊断问题了。嗯,不错,这是教科书式的标准做法,俺点个赞先。

考虑下面两种情况:

  1. 在即将上线的版本里,存在一个非常非常低概率重现的宕机问题(让我们假定在开发周期中只发生过一次,这个假定用以确保你没有机会去验证针对这个问题的猜测和修复是否真的有效),你缺乏足够的信息去确定问题的精确位置,但从事发时的线索可以把范围缩小到某一片代码(这些代码是你不能去掉或关闭的)。那么此时,你的合理的措施是什么?
  2. 你在已经上线的项目中使用了某个外部的第三方服务(假定叫 xyz 服务),该服务除了线上的 API 接口外,还提供了本地的动态链接库文件,用以帮助你的应用程序与其对接。现在该服务升级到了一个新的版本,也同时提供了对应的 dll 给你。你按照往常那样迁移到新版本之后,过了半个月,陆陆续续地收到某几个玩家发来的宕机报告,你一看,问题出在新替换的 dll 里。这时你面对的两难是,你已经无法回到老的版本了(因为依赖了 xyz 的新版本的服务),而从少量的样本中又很难推敲出问题出在什么地方,于是你赶紧发邮件与 xyz 公司沟通,第二天 xyz 公司悠悠地回复了你,咦,没有听说别的 licensee 出现类似的问题哦,要不你们再查查,是不是姿势不对,操作系统没打补丁?眼看宕机报告积少成多,那么此时,你的合理的措施又是什么?

在实践中,任何项目都很难严格保证所有的代码没有任何问题(这也是我们需要 Unexpected Error Handling 的根本原因)。在这种情况下,为了把不幸发生问题时的不良影响降到最低,我们可以在任何我们认为没有把握的代码 (包括新写的扩展模块,集成的第三方库等等,可以认为它们约等于上面提到的非受控环境) 上,加上一个较强的保证,这样在万一发生问题时,得以让程序继续以受限的方式运行或体面地退出。对于游戏服务器而言,不在 core dump 的第一时间崩溃,不仅避免了直接造成巨大的负面影响,也可以有机会通过发紧急公告和把未被破坏的重要信息入库等手段,降低宕机带来的损失。


那么具体应该怎么处理这一类 crash 呢?

这里以 Windows 平台上的 C/C++ 为例,简单说一下。大部分所谓的宕机,实际上是操作系统抛出的 OS 异常,在 Windows 平台上,可以用 __try {} __except () {} 的结构化异常处理机制来捕获并处理的。比如几个最常见的 0xC00000005 Access violation, 0xC00000094 Integer division by zero, 0xC000000fd Stack overflow 等等,已经能囊括最常见的 90% 以上的宕机了。具体的处理很直白,这里就不贴代码了,在这里可以看到一个处理栈溢出并恢复执行的例子。


以下几点补充说明一下:

  • 对应这一类保护过的模块,通常我们都会制作一些运行时开关,当发生问题时,直接用开关把对应的子系统关掉,这就是所谓的“以受限的方式运行”。
  • 在这里添加的宕机保护,并不会妨碍生成需要的 dump 以便开发者诊断,实际上只要拿着当时的异常指针,就可以按需生成 dump 文件。
  • 说到 dump 顺便说一下,dump 并非是只有在宕机时才可以生成,实际上程序运行的任何时候,都可以使用 dbghelp.dll 来生成 dump,在某些情况下,在问题相关的关键点上生成一些额外的 dump 非常有利于对问题的诊断。

本拟再简单聊一下 golang 和 Rust 等新近语言在错误处理方面的考虑,考虑到本文的长度,还是且听下回分解好了,这次先这样吧。

(完)
Gu Lu
2015-02-03

Comments
Write a Comment

知识共享许可协议
本作品由Gu Lu创作,采用知识共享Attribution-NonCommercial-NoDerivatives 4.0 国际许可协议进行许可。