Error Handling,不是关于Handle Error的;就像A firewall does NOT protect you from any “fire”。
这不仅仅是修辞上的巧合;error or fire,都是自然语言里的一种比喻,不是一个对问题的数学定义。
这里采用有争议的防火墙发明人、约翰霍普金斯大学心理学专业艺术学士毕业的Marcus Ranum在1995年给出的定义(维基上那个已经被定义成硬件了,明显是cisco派人干的):
“A firewall is the implementation of your Internet security policy.”
这里的关键词,是policy,a set of rules。
====
同样的,Error Handling关注的,其实不是error,而是error之外部分的程序运行安全,right?很多的时候我们有能力简单忽略错误,就像Windows蓝屏时也可以假装没有任何错误发生,继续运行,但可能产生更加灾难性的后果。
所以Error Handling完全是关于把(运行时的)程序Partition成不同的部分,当一些部分无法按照预期履职的时候,优雅的把它们kill掉,而不会波及其他的部分;如果是在生物上这可以比喻成Programmed Cell Death,或者,请允许我卖弄一下单词量,apoptosis。
当然,象蓝屏那样整个系统失去可用性是可能的;但我们近可能提供『更多的可用性』,如果不是特别麻烦的话。
====
在实际编程里,叫Error的东西太多了;还有关于Operational Error vs Exception的区分,但是这个分类的方式不总是work,不会让你写出多好的Error Handling代码,因为它关注了一个局部,没有触及问题的本质。
说到本质没法避免抽象的概念。我这里选择的描述方式,是泛化『通讯』的概念。
(这篇里我们不严格区分对象和组件的概念,实际上两者在设计模型上差别很大,但在语言上形式都一样。)
比如一个对象调用了另一个对象的方法,我们可以理解成是一个对象和另一个对象之间的通讯。
熟悉通讯的同学会知道通讯会有Control Plane/Data Plane的说法,或者叫in-band data和out-of-band data;我举两个例子说明out-of-band data:
- 比如Unix IO最初设计的时候挺美好的,只有open/read/write/close,这里open/close是set up和tear down一个通讯过程,意思是通讯过程有动态性;read/write就是读写数据,这些数据都是in-band data,意思是都是双方业务上要用的数据,而不是为了维护这个通讯通道的数据,这个抽象来看挺完美的,但是很不幸,real world里没有这种便宜事儿,这么简单的抽象能代表一切的。
比如,一个serial port通讯要设置baudrate,通讯双方可能需要实现flow control,因为有一方的处理能力不够快buffer很快就满了,再或者你想修改一下socket的参数;所以通讯双方不可避免的需要交换或设置metadata维护这个通讯,在kernel ABI里,这个就是ioctl,ioctl就相当于通讯双方在交换out-of-band data,或者叫control plane通讯。
- Unix程序最初有stdin, stdout,后来发现错误信息走stdout没法用,Ritchie硬着头皮扔掉了MIT美学贯彻新泽西主义,加了一个stderr,就像今天这个样子;
- 几乎所有的硬件通讯都是这么设计的,比如USB通讯,被虚拟化成不同的channel,在USB的术语里叫做pipe/endpoint,有哪些endpoint呢?control, interrupt, bulk, isochronous;这些都是band,提供了不同的QoS,interrupt有最低的latency,isochronous可以牺牲质量(错码和丢包)但保证延迟和带宽,bulk最接近ip网络的状态,数据是对的但延迟和带宽都不保证的,很显然这些都是跑『业务』数据的,即in-band data,那么维护这些endpoint的control pipe,就可以理解为out-of-band通讯。
====
建立了这个基本概念之后我们就会发现,error,在绝大多数情况下它都不是in-band data,它是out-of-band data,『通讯』被中止、重置、或者其它和通讯有关的错误语义是更重要的,而不是什么除了0越了届或者想要访问的东西被篡改了,这些错误在实际应用中不可避免但是他们没有设计上的重要性。
编程语言在很长一个时期里把error编码成in-band data,这是个误会,我们不要在概念模型上使用这个看法;阻止你使用这个看法的主要原因是你脑子里充满了函数调用的语义和功能,不需要如此,一次函数调用不过是一个通讯session而已。
一个blocking的函数调用不过是caller和callee的一次通讯;
一个round trip就完成的调用,无论同步异步,不过是一个持久的channel通讯的退化,degeneration;
这样思考有助于你在所有的粒度上用同一把『锤子』去解决问题。
====
在最简单的情况下,a/b双方有一个单向stream,error看成out-of-band data;这和用in-band data编码有什么区别呢?
在这样的场景下看不出什么区别,象null-terminated方式结束通讯的设计到处都是,软流控也可能直接使用escape字符,简单的通讯设计可以不用包类型区分,并不是一个问题。
但是如果你的通讯session在『组合』stream呢?或者说这个通讯是存在结构的,无论是stream,还是双方的状态(有态通讯)。
比如,在概念上HTTP有request stream和response stream,(我们只讨论概念模型,不纠结transport是每请求一个tcp还是复用),那么是不是HTTP的通讯可以数学定义成tuple,(req, res),一个HTTP session由两个stream构成,在概念上要较真的话,比如request或者response的任意方在通讯时都可以拆除这个stream,它会发送一个abort stream的原语,但abort一个stream和abort整个HTTP session不是一回事,那是不是还需要再发一个abort session的原语?
可能你觉得HTTP是实用至上,不要太钻牛角尖,这里可以考虑成两个原语合并了,算是一种优化。
这是我举一个通讯例子,而不是程序例子,让大家更容易看清问题本质的原因;实际上所谓的这种合并优化,你在通讯里容易做,包格式和语义随意定义;在代码中难做,因为这是破坏封装的,在代码对象拆除时毫无疑问两个组合对象是顺序拆除;这里不是优化的问题,它揭示了Error Handling的本质。
====
Error Handling是一个fuse-blowing(保险丝熔断)逻辑。
HTTP这样的设计是典型的ladder logic + fuse blowing设计。
ladder logic本来是一个电子硬件上的术语,广泛用于PLC编程的;在软件或者通讯里可以把它理解为一种设计策略:把一个业务行为设计成通讯双方在执行的一个有态协议,步骤是线性的,双方以你一步我一步的方式向最终结果前进;在每一步都明确清楚自己的责任,有唯一的对对方行为的预期;一旦出现问题,采用fuse-blowing方式,全部拆除。
HTTP通常被称为无态协议,这是从使用者角度说的,站在协议的实现者角度看它当然是有态的;它是在爬梯子,只是梯子通常很短,一个来回,如果有100 continue,多一个来回;但任何一方的任何错误,都是fuse-blowing。
(当然HTTP设计的不够好的地方是它的abort不优雅,TLS是发了错误(alert)之后再abort的,HTTP的突然close并不表示成功或失败,开发者还需要其他手段鉴别;另TLS也是ladder logic设计,这是写在rfc文档里的。)
====
在blocking i/o语言里,著名的throw, try/catch/finally,是不是ladder logic + fuse-blowing?
说它是ladder logic可能有人觉得牵强;但函数调用实际上可以看作caller发出请求后等待callee返回,这就是ladder,只是这里每次函数调用是动态的和一次性的;说它是fuse blowing应该没有人会反对。
====
在异步编程里,我最熟悉的node,有些东西会写起来繁琐,但记住每个组件上有且只有一个fuse blow函数就够了,比如node的stream,如果你继承的话,记住那个destroy是fuse blow函数。
在fuse blow的时候清理全部自己buffer和正在执行的任务,阻止所有异步出发尚未返回的东西,就够了,no-brainer。具体实现上有和组件模型相关的内容,有异步过程如何搞synchronize等话题,超纲了,不多说。
====
但是一些语言里把error混到in-band data里的做法,我是很反对的。我的理解,这是fundamentally flawed;忽略了『计算是通过通讯实现的』这样一个问题本质;把error理解成仅有两种情况,communication error和denial of service就够了,你把out-of-band和fuse blown两个概念加入设计,就能对很多棘手的细节问题豁然开朗了。
比如在数据包里象前面提到的USB那样虚拟几个sub-channel,error最大,因为fuse-blown,control/out-of-band次之,再次是各种QoS的in-band data,不要把这些不同的东西混淆起来,就不会在需要拓展或变更时遇到困难。
====
Sum it up
1.『计算是通过通讯实现的』,这是一个powerful & universal的概念模型,无论同步异步,并发与否,都是适用的,而且在任何粒度上适用;
- error,概念上不要理解为in-band data,理解为out-of-band data和fuse-blowing会海阔天空,想想新泽西主义的stderr,这不是个偶然;
- 设计上永远考虑ladder logic + fuse-blowing;如果做不到这一点,那就想想怎么继续细分逻辑粒度才能做到这一点;
- 如果error既不是communication error也不是denial of service,想想它是怎样被引入系统的?