相对于顺序程序,处理并发程序里的错误涉及一种完全不同的思考方式。设想一个只有单一顺序进程的系统。如果这个进程挂了,麻烦可能就大了,因为没有其他进程能够帮忙。出于这个原因,顺序语言把重点放在故障预防上,强调进行防御式编程。在Erlang里,我们有大量的进程可供支配,因此任何单进程故障都不算特别重要。通常只需编写少量的防御性代码,而把重点放在编写纠正性代码上。我们采取各种措施检测错误,然后在错误发生后纠正它们。
一:错误处理的理念
并发
Erlang
程序里的错误处理建立在
远程检测
和
处理错误
的概念之上。和在发生错误的进程里处理错误不同,我们选择让进程崩溃,然后在其他进程里纠正错误。在设计容错式系统时就假设错误会发生,进程会崩溃,机器会出故障。我们的任务是在错误发生后检测出来,可能的话还要纠正它们。同时要避免让系统的用户注意到任何的故障,或者在错误修复过程中遭受服务中断。因为重点在补救而不是预防上,所以系统里几乎没有防御性代码,只有在错误发生后清理系统的代码。这就意味着我们将把注意力放在如何检测错误,如何识别问题来源,以及如何保持系统处于稳定状态上。检测错误和找出故障原因内建于Erlang虚拟机底层的功能,也是
Erlang
编程语言的一部分。标准OTP
库提供了构建互相监视的进程组和在检测到错误时采取纠正措施的功能,
Erlang关于构建容错式软件的理念可以总结成两个容易记忆的短句:“让其他进程修复错误”和“任其崩溃”。
1.让其他进程修复错误
我们安排一些进程来互相监控各自的健康状况。如果一个进程挂了,其他某个进程就会注意
到并采取纠正措施。要让一个进程监控另一个,就必须在它们之间创建一个连接
(link)或监视(monitor
)。如果被连接或监视的进程挂了,监控进程就会得到通知。监控进程可以实现跨机器的透明运作,因此运行在某一台机器上的进程可以监视运行在不同机器上进程的行为。这是编写容错式系统的基础。不能在一台机器上构建容错式系统,因为崩溃的可能是整台机器,所以至少需要两台机器。一台机器负责计算,其他的机器负责监控它,并在第一台机器崩溃时接管计算。这可以作为顺序代码错误处理的延伸。虽然可以捕捉顺序代码里的异常并尝试纠正错误,
但如果失败了或者整台机器出了故障,就要让其他进程来修复错误。
2.任其崩溃
在
Erlang
里,我们会把应用程序构建成两个部分:一部分负责解决问题,另一部分负责在错误发生时纠正它们。负责解决问题的部分会尽可能地少用防御性代码,并假设函数的所有参数都是正确的,程序也会正常运行。 纠正错误的部分往往是通用
的,因此同一段错误纠正代码可以用在许多不同的应用程序里。 举个例子,如果数据库的某个事务出了错,就简单地中止该事务,让系统把数据库恢复到出错之前的状态。如果操作系统里的某个进程崩溃了,就让操作系统关闭所有打开的文件或套接字,然后让系统恢复到某个稳定状态。这么做让任务有了清楚的区分。编写解决问题的代码和修复错误的代码,但两者不会交织在一起。代码的体积可能会因此显著变小。
3.为何要崩溃
让程序在出错时立即崩溃通常是一个很好的主意。事实上,它有不少优点。
(1)不必编写防御性代码来防止错误,直接崩溃就好。(2)不必思考应对措施,而是选择直接崩溃,别人会来修复这个错误。(3) 不会使错误恶化,因为无需在知道出错后进行额外的计算。(4) 如果在错误发生后第一时间举旗示意,就能得到非常好的错误诊断。在错误发生后继续运行经常会导致更多错误发生,让调试变得更加困难。(5) 编写错误恢复代码时不用担心崩溃的原因,只需要把注意力放在事后清理上。(6) 它简化了系统架构,这样我们就能把应用程序和错误恢复当成两个独立的问题来思考,而不是一个交叉的问题。
二:错误处理的术语含义
理解错误处理的最佳方式是快速浏览这些名词的定义,通过更直观的方式理解相关的概念。下面就是一些相关的名词:
(1) 进程进程有两种:普通进程 和 系统进程 。 spawn 创建的是普通进程。普通进程可以通过执行内置函数process_flag(trap_exit, true) 变成系统进程。(2) 连接进程可以互相连接。如果A 和 B 两个进程有连接,而 A 出于某种原因终止了,就会向 B 发送一个错误信号,反之亦然。(3) 连接组进程P 的 连接组 是指与 P 相连的一组进程。(4) 监视监视和连接很相似,但它是单向的。如果A 监视 B ,而 B 出于某种原因终止了,就会向 A 发送一个“宕机”消息,但反过来就不行了。(5) 消息和错误信号进程协作的方式是交换消息 或 错误信号 。消息是通过基本函数 send 发送的,错误信号则是进程崩溃或进程终止时自动发送的。错误信号会发送给终止进程的连接组。(6) 错误信号的接收当系统进程收到错误信号时,该信号会被转换成{'EXIT', Pid, Why} 形式的消息。 Pid是终止进程的标识,Why 是终止原因(有时候被称为 退出原因 )。如果进程是无错误终止,Why就会是原子 normal ,否则 Why 会是错误的描述。 当普通进程收到错误信号时,如果退出原因不是normal ,该进程就会终止。当它终止时,同样会向它的连接组广播一个退出信号。(7) 显式错误信号任何执行exit(Why) 的进程都会终止(如果代码不是在 catch 或 try 的范围内执行的话),并向它的连接组广播一个带有原因 Why 的退出信号。进程可以通过执行exit(Pid, Why) 来发送一个“虚假”的错误信号。在这种情况下, Pid 会收到一个带有原因Why 的退出信号。调用 exit/2 的进程则不会终止(这是有意如此的)。(8) 不可捕捉的退出信号系统进程收到摧毁信号 (kill signal )时会终止。摧毁信号是通过调用 exit(Pid, kill)生成的。这种信号会绕过常规的错误信号处理机制,不会被转换成消息。摧毁信号只应该用在其他错误处理机制无法终止的顽固进程上。这些定义可能看上去很复杂,但通常不必深入理解这些机制的工作原理也能编写出容错式代码。系统在错误处理方面的默认行为是尝试“做正确的事”。
三:创建连接
假设有一组互不相关的进程,如下图a
所示。虚线代表了连接。
为了创建连接,我们会调用基本函数
link(Pid)
,它会在调用进程和
Pid
之间创建一个连接。因此,如果P1
调用
link(P3)
,
P1
和
P3
之间就会建立连接。 P1调用了
link(P3)
,
P3
又调用了
link(P10)
,以此类推,最终得到了上图b
所展示的情形。请注意,P1
的连接组只有一个元素(
P3
),而
P3
的连接组有两个元素(
P1
和
P10
),以此类推。
四:同步终止的进程组
通常,你希望创建能够同步终止的进程组。在论证系统行为的时候,这是一个非常有用的不变法则。当多个进程合作解决问题而某处出现问题时,有时候我们能进行恢复。但如果无法恢复,就会希望之前所做的一切事情都停止下来。它和事务(transaction)这个概念很像:进程要么做它们该做的事,要么全部被杀死。假设我们有一些相互连接的进程而其中的某个进程挂了,比如下图a中的P9。图a展示了P9终止前,各个进程是如何连接的。下图b展示了P9崩溃且所有错误信号都处理完成后还剩下哪些进程。
当
P9
终止时,一个
错误信号
被发送给进程
P4
和
P10
。因为
P4
和
P10
不是系统进程,所以也一起终止了,随后,错误信号被发送给与它们相连的所有进程。最后,错误信号扩散到了所有相连 的进程,整个互连进程组都终止了。 如果P1、
P3
、
P4
、
P9
或
P10
里的任意进程终止,它们就会全部终止。
五:设立防火墙
有时候我们不希望相连的进程全部终止,而是想让系统里的错误停止扩散。下图
对此进行了演示,里面所有的相连进程都会终止,一直到P3
为止。
要实现这一点,
P3
可以执行
process_flag(trap_exit, true)
并转变成一个系统进程(意思是它可以捕捉退出信号)。如上图b
所示,它用双圆来表示。
P9
崩溃之后,错误的扩散会在
P3
处停止,因此P1
和
P3
不会终止。 P3充当了一个
防火墙
,阻止错误扩散到系统里的其他进程中。
六:监视
监视与连接类似,但是有几处明显的区别:
(1) 监视是单向的。如果 A 监视 B 而 B 挂了,就会向 A 发送一个退出消息,反过来则不会如此(别忘了连接是双向的,因此如果A 与 B 相连,其中任何一个进程的终止都会导致另一个进程收到通知)。(2) 如果被监视的进程挂了,就会向监视进程发送一个“宕机”消息,而不是退出信号。这就意味着监视进程即使不是系统进程也能够处理错误。当你想要不对称的错误处理时,可以使用监视,对称的错误处理则适合使用连接。监视通常会被服务器用来监视客户端的行为。
七:基本错误处理函数
下列基本函数被用来操作连接和监视,以及捕捉和发送退出信号:
(1) -spec spawn link(Fun) -> Pid
-spec spawn_link(Mod, Fnc, Args) -> Pid
%% 它们的行为类似于spawn(Fun)和spawn(Mod,Func,Args),
%% 同时还会在父子进程之间创建连接。
(2) -spec spawn_monitor(Fun) -> {Pid, Ref}
-spec spawn_monitor(Mod,Func,Args) -> {Pid, Ref}
%% 它与spawn_link相似,但创建的是监视而非连接。Pid是新创建进程的进程标识符,
%% Ref是该进程的引用。如果这个进程因为Why的原因终止了,
%% 消息{'DOWN',Ref,process,Pid,Why}就会被发往父进程。
(3) -spec process_flag(trap_exit, true)
%% 它会把当前进程转变成系统进程。
%% 系统进程是一种能接收和处理错误信号的进程。
(4) -spec link(Pid) -> true
%% 它会创建一个与进程Pid的连接。连接是双向的。如果进程A执行了link(B),
%% 就会与B相连。实际效果就和B执行link(A)一样。
%% 如果进程Pid不存在,就会抛出一个noproc退出异常。
%% 如果执行link(B)时A已经连接了B(或者相反),这个调用就会被忽略。
(5) -spec unlink(Pid) -> true
%% 它会移除当前进程和进程Pid之间的所有连接
(6) -spec erlang:monitor(process, Item) -> Ref
%% 它会设立一个监视。Item可以是进程的Pid,也可以是它的注册名称。
(7) -spec demonitor(Ref) -> true
%% 它会移除以Ref作为引用的监视。
(8) -spec exit(Why) -> none()
%% 它会使当前进程因为Why的原因终止。如果执行这一语句的子句不在catch语句的范围内,
%% 此进程就会向当前连接的所有进程广播一个带有参数Why的退出信号。
%% 它还会向所有监视它的进程广播一个DOWN消息。
(9) -spec exit(Pid, Why) -> true
%% 它会向进程Pid发送一个带有原因Why的退出信号。执行这个内置函数的进程本身不会终止。
%% 它可以用于伪造退出信号。
%% 可以用这些基本函数来设立互相监视的进程网络,并把它作为构建容错式软件的起点。