错误和进程
链接
链接是可以在两个进程之间建立的一种特殊关系。当这种关系建立起来,并且其中一个进程因意外抛出、错误或退出而死亡时(参见 错误和异常),另一个链接的进程也会死亡。
从尽快失败以停止错误的角度来看,这是一个有用的概念:如果发生错误的进程崩溃,但依赖于它的进程没有崩溃,那么所有这些依赖的进程现在都必须处理依赖消失的情况。让它们死亡,然后重新启动整个组通常是可接受的替代方案。链接使我们能够做到这一点。
为了在两个进程之间建立链接,Erlang 提供了原始函数 link/1,它接受 Pid 作为参数。调用此函数时,它将在当前进程与由 Pid 标识的进程之间建立链接。要删除链接,请使用 unlink/1。当其中一个链接的进程崩溃时,会发送一种特殊类型的消息,其中包含有关发生情况的信息。如果进程因自然原因死亡(即完成其函数的运行),则不会发送此类消息。我将首先介绍这个新函数作为 linkmon.erl 的一部分。
myproc() -> timer:sleep(5000), exit(reason).
如果您尝试以下调用(并在每次 spawn 命令之间等待 5 秒),您应该看到 shell 仅在两个进程之间建立链接时才因“reason”而崩溃。
1> c(linkmon). {ok,linkmon} 2> spawn(fun linkmon:myproc/0). <0.52.0> 3> link(spawn(fun linkmon:myproc/0)). true ** exception error: reason
或者,将其放在图片中
但是,此 {'EXIT', B, Reason}
消息不能像往常一样用 try ... catch
捕获。需要使用其他机制来做到这一点。我们将在后面看到它们。
重要的是要注意,链接用于建立应该一起死亡的更大进程组。
chain(0) -> receive _ -> ok after 2000 -> exit("chain dies here") end; chain(N) -> Pid = spawn(fun() -> chain(N-1) end), link(Pid), receive _ -> ok end.
此函数将接受一个整数 N,启动 N 个进程,这些进程相互链接。为了能够将 N-1 参数传递给下一个“链”进程(它调用 spawn/1
),我将调用包装在匿名函数中,以便它不再需要参数。调用 spawn(?MODULE, chain, [N-1])
会执行类似的任务。
在这里,我将有许多进程相互链接,当它们的每个后续进程退出时,它们会死亡。
4> c(linkmon). {ok,linkmon} 5> link(spawn(linkmon, chain, [3])). true ** exception error: "chain dies here"
如您所见,shell 确实接收来自其他进程的死亡信号。这是一个关于已生成的进程和链接下降的绘制表示。
[shell] == [3] == [2] == [1] == [0] [shell] == [3] == [2] == [1] == *dead* [shell] == [3] == [2] == *dead* [shell] == [3] == *dead* [shell] == *dead* *dead, error message shown* [shell] <-- restarted
在运行 linkmon:chain(0)
的进程死亡后,错误会沿着链接链传播,直到 shell 进程本身因其而死亡。崩溃可能发生在任何链接的进程中;因为链接是双向的,您只需要其中一个进程死亡,其他进程就会随之死亡。
注意:如果您想从 shell 中杀死另一个进程,您可以使用函数 exit/2,它以这种方式调用:exit(Pid, Reason)
。如果您愿意,可以尝试一下。
注意:链接不能叠加。如果您对相同的两个进程调用 link/1
15 次,它们之间只存在一个链接,并且对 unlink/1
的一次调用就足以将其拆除。
重要的是要注意,link(spawn(Function))
或 link(spawn(M,F,A))
分多个步骤完成。在某些情况下,进程可能在链接建立之前死亡,然后引发意外行为。出于这个原因,函数 spawn_link/1-3 已添加到语言中。它接受与 spawn/1-3
相同的参数,创建一个进程并将其链接,就好像 link/1
存在一样,但它都是作为一个原子操作完成的(这些操作组合成一个单一操作,要么失败要么成功,但没有其他操作)。这通常被认为更安全,您还可以节省一组括号。
这是一个陷阱!
现在回到链接和进程死亡。错误在进程之间的传播是通过类似于消息传递的进程完成的,但使用一种特殊的类型称为信号的消息。退出信号是“秘密”消息,它们会自动对进程起作用,在操作中杀死它们。
我已经多次提到,为了可靠,应用程序需要能够快速杀死和重新启动进程。现在,链接对于执行杀死部分来说已经足够了。缺少的是重启。
为了重新启动进程,我们需要一种方法来首先知道它死了。这可以通过在链接之上添加一层(蛋糕上美味的糖霜)来完成,使用一个名为系统进程的概念。系统进程基本上是普通进程,不同之处在于它们可以将退出信号转换为常规消息。这是通过在运行的进程中调用 process_flag(trap_exit, true)
来完成的。没有什么比一个例子更能说明问题了,所以我们来举个例子。我将使用开头的一个系统进程来重新执行链示例。
1> process_flag(trap_exit, true). true 2> spawn_link(fun() -> linkmon:chain(3) end). <0.49.0> 3> receive X -> X end. {'EXIT',<0.49.0>,"chain dies here"}
啊!现在事情变得有趣了。回到我们的图纸,发生的事情更像是这样
[shell] == [3] == [2] == [1] == [0] [shell] == [3] == [2] == [1] == *dead* [shell] == [3] == [2] == *dead* [shell] == [3] == *dead* [shell] <-- {'EXIT,Pid,"chain dies here"} -- *dead* [shell] <-- still alive!
这就是允许快速重新启动进程的机制。通过编写使用系统进程的程序,很容易创建一个进程,其唯一作用是在某些内容死亡时进行检查,并在每次失败时重新启动它。我们将在下一章中进一步介绍这一点,届时我们将真正应用这些技术。
现在,我想回到 异常章节 中看到的异常函数,并展示它们在处理捕获退出的进程时的行为。首先,让我们设置一些基础来进行实验,但没有系统进程。我将依次展示未捕获的抛出、错误和退出在相邻进程中的结果。
- 异常来源:
spawn_link(fun() -> ok end)
- 未捕获结果:- 无 -
- 捕获结果:{'EXIT', <0.61.0>, normal}
- 进程正常退出,没有问题。请注意,这看起来有点像
catch exit(normal)
的结果,只是在元组中添加了一个 PID 来了解哪个进程失败了。 - 异常来源:
spawn_link(fun() -> exit(reason) end)
- 未捕获结果:** exception exit: reason
- 捕获结果:{'EXIT', <0.55.0>, reason}
- 进程已因自定义原因终止。在这种情况下,如果没有任何捕获的退出,进程将崩溃。否则,您将收到上述消息。
- 异常来源:
spawn_link(fun() -> exit(normal) end)
- 未捕获结果:- 无 -
- 捕获结果:{'EXIT', <0.58.0>, normal}
- 这成功地模拟了一个进程正常终止。在某些情况下,您可能希望将进程作为程序正常流程的一部分杀死,而没有任何异常情况发生。这就是执行此操作的方法。
- 异常来源:
spawn_link(fun() -> 1/0 end)
- 未捕获结果:Error in process <0.44.0> with exit value: {badarith, [{erlang, '/', [1,0]}]}
- 捕获结果:{'EXIT', <0.52.0>, {badarith, [{erlang, '/', [1,0]}]}}
- 错误 (
{badarith, Reason}
) 从未被try ... catch
块捕获,并冒泡成一个 'EXIT'。此时,它的行为与exit(reason)
完全相同,但带有堆栈跟踪,提供了有关发生情况的更多详细信息。 - 异常来源:
spawn_link(fun() -> erlang:error(reason) end)
- 未捕获结果:Error in process <0.47.0> with exit value: {reason, [{erlang, apply, 2}]}
- 捕获结果:{'EXIT', <0.74.0>, {reason, [{erlang, apply, 2}]}}
- 与
1/0
几乎相同。这是正常的,erlang:error/1
就是为了让你做到这一点。 - 异常来源:
spawn_link(fun() -> throw(rocks) end)
- 未捕获结果:Error in process <0.51.0> with exit value: {{nocatch, rocks}, [{erlang, apply, 2}]}
- 捕获结果:{'EXIT', <0.79.0>, {{nocatch, rocks}, [{erlang, apply, 2}]}}
- 因为
throw
从未被try ... catch
捕获,所以它冒泡成一个错误,进而冒泡成一个 EXIT。如果没有捕获退出,进程将失败。否则,它可以很好地处理它。
这就是关于常用异常的情况。一切正常:一切顺利。异常情况发生:进程死亡,不同的信号在周围传递。
然后是 exit/2
。这是 Erlang 进程中枪的等效项。它允许进程从远处安全地杀死另一个进程。以下是一些可能的调用。
- 异常来源:
exit(self(), normal)
- 未捕获结果:** exception exit: normal
- 捕获结果:{'EXIT', <0.31.0>, normal}
- 当不捕获退出时,
exit(self(), normal)
的行为与exit(normal)
相同。否则,您将收到与监听来自死亡外部进程的链接时获得的相同格式的消息。 - 异常来源:
exit(spawn_link(fun() -> timer:sleep(50000) end), normal)
- 未捕获结果:- 无 -
- 捕获结果:- 无 -
- 这基本上是调用
exit(Pid, normal)
。此命令没有任何用处,因为进程不能使用normal
作为参数远程杀死。 - 异常来源:
exit(spawn_link(fun() -> timer:sleep(50000) end), reason)
- 未捕获结果:** exception exit: reason
- 捕获结果:{'EXIT', <0.52.0>, reason}
- 这是外部进程自行终止 reason。看起来与外部进程在自身上调用
exit(reason)
一样。 - 异常来源:
exit(spawn_link(fun() -> timer:sleep(50000) end), kill)
- 未捕获结果:** exception exit: killed
- 捕获结果:{'EXIT', <0.58.0>, killed}
- 令人惊讶的是,消息从死亡进程更改为生成者。生成者现在收到
killed
而不是kill
。这是因为kill
是一个特殊的退出信号。稍后将详细介绍此内容。 - 异常来源:
exit(self(), kill)
- 未捕获结果:** exception exit: killed
- 捕获结果:** exception exit: killed
- 哎呀,看看。看来这个实际上是不可能捕获的。让我们检查一下。
- 异常来源:
spawn_link(fun() -> exit(kill) end)
- 未捕获结果:** exception exit: killed
- 捕获结果:{'EXIT', <0.67.0>, kill}
- 现在这让人困惑。当另一个进程用
exit(kill)
杀死自己,而我们没有捕获退出时,我们自己的进程会因killed
原因死亡。但是,当我们捕获退出时,事情不会那样发生。
虽然您可以捕获大多数退出原因,但可能存在您希望强行杀死进程的情况:也许其中一个进程正在捕获退出,但也被困在一个无限循环中,永远不会读取任何消息。kill
原因充当一个特殊的信号,该信号不能被捕获。这确保了您用它终止的任何进程都会真正死亡。通常,kill
是一种最后的手段,当其他所有方法都失败时才使用。
由于 `kill` 原因永远无法被捕获,因此在其他进程收到消息时,它需要更改为 `killed`。如果没有以这种方式更改,则与之关联的每个其他进程都会因为相同的 `kill` 原因而死亡,进而杀死其邻居,依此类推。就会出现死亡级联。
这也解释了为什么从另一个链接进程接收到的 `exit(kill)` 在接收时看起来像 `killed`(信号被修改,因此不会级联),但在本地捕获时仍看起来像 `kill`。
如果你觉得这一切都很混乱,别担心。许多程序员都有同感。退出信号有点像奇怪的动物。幸运的是,除了上面描述的这些情况之外,没有太多特殊情况。一旦你理解了这些,你就可以毫无问题地理解大多数 Erlang 的并发错误管理。
监控器
好吧,也许你并不想杀死进程。也许你不想在你离开后将世界拖入毁灭。也许你更像个跟踪狂。在这种情况下,监控器可能就是你想要的。
更严肃地说,监控器是一种特殊的链接,有两个区别:
- 它们是单向的;
- 它们可以堆叠。
当一个进程想要了解另一个进程的情况时,监控器是你的选择,但它们彼此之间并不真正重要。
另一个原因,如上所述,是堆叠引用。这乍一看可能没什么用,但对于编写需要了解其他进程情况的库非常有用。
你看,链接更多是一种组织结构。当您设计应用程序的架构时,您会确定哪个进程将执行哪些工作,以及什么依赖什么。一些进程将监督其他进程,一些进程无法没有孪生进程生存,等等。这种结构通常是固定的,预先知道的。链接对这种情况很有用,不应在结构之外使用。
但是,如果您的 2 个或 3 个不同的库都需要了解一个进程是否还活着,会发生什么呢?如果您要为此使用链接,那么在需要取消链接一个进程时,您很快就会遇到问题。现在,链接不可堆叠,因此,您取消链接一个链接的那一刻,您就会取消链接所有链接,并破坏所有其他库提出的假设。这很糟糕。因此,您需要可堆叠的链接,而监控器就是您的解决方案。它们可以被单独移除。此外,在库中,单向性非常方便,因为其他进程不必了解这些库。
那么监控器长什么样?很简单,让我们设置一个。函数是 erlang:monitor/2,第一个参数是原子 process,第二个参数是 pid。
1> erlang:monitor(process, spawn(fun() -> timer:sleep(500) end)). #Ref<0.0.0.77> 2> flush(). Shell got {'DOWN',#Ref<0.0.0.77>,process,<0.63.0>,normal} ok
每次您监控的进程崩溃时,您都会收到这样的消息。该消息为 `{'DOWN', MonitorReference, process, Pid, Reason}`。该引用用于取消监控该进程。请记住,监控器是可堆叠的,因此可以取消监控多个进程。引用允许您以独特的方式跟踪每个进程。还要注意,与链接一样,有一个原子函数用于在监控时生成进程,spawn_monitor/1-3
3> {Pid, Ref} = spawn_monitor(fun() -> receive _ -> exit(boom) end end). {<0.73.0>,#Ref<0.0.0.100>} 4> erlang:demonitor(Ref). true 5> Pid ! die. die 6> flush(). ok
在这种情况下,我们在另一个进程崩溃之前取消了对它的监控,因此我们没有关于它死亡的任何信息。函数 demonitor/2 也存在,并且提供了一些额外的信息。第二个参数可以是选项列表。只有两个选项,`info` 和 `flush`
7> f(). ok 8> {Pid, Ref} = spawn_monitor(fun() -> receive _ -> exit(boom) end end). {<0.35.0>,#Ref<0.0.0.35>} 9> Pid ! die. die 10> erlang:demonitor(Ref, [flush, info]). false 11> flush(). ok
选项 `info` 会告诉您在尝试删除监控器时它是否存在。这就是表达式 10 返回 `false` 的原因。使用 `flush` 作为选项会从邮箱中删除 `DOWN` 消息(如果存在),导致 `flush()` 在当前进程的邮箱中找不到任何东西。
命名进程
了解了链接和监控器,还有一个问题有待解决。让我们使用 linkmon.erl 模块的以下函数
start_critic() -> spawn(?MODULE, critic, []). judge(Pid, Band, Album) -> Pid ! {self(), {Band, Album}}, receive {Pid, Criticism} -> Criticism after 2000 -> timeout end. critic() -> receive {From, {"Rage Against the Turing Machine", "Unit Testify"}} -> From ! {self(), "They are great!"}; {From, {"System of a Downtime", "Memoize"}} -> From ! {self(), "They're not Johnny Crash but they're good."}; {From, {"Johnny Crash", "The Token Ring of Fire"}} -> From ! {self(), "Simply incredible."}; {From, {_Band, _Album}} -> From ! {self(), "They are terrible!"} end, critic().
现在,让我们假装我们正在商店里购物,寻找音乐。有一些专辑听起来很有趣,但我们并不确定。你决定打电话给你的朋友,评论家。
1> c(linkmon). {ok,linkmon} 2> Critic = linkmon:start_critic(). <0.47.0> 3> linkmon:judge(Critic, "Genesis", "The Lambda Lies Down on Broadway"). "They are terrible!"
由于太阳风暴(我试图在这里找到一些现实的东西),连接断开了。
4> exit(Critic, solar_storm). true 5> linkmon:judge(Critic, "Genesis", "A trick of the Tail Recursion"). timeout
真烦人。我们再也无法获得对这些专辑的评论了。为了让评论家保持存活,我们将编写一个基本的“监督器”进程,其唯一作用是在评论家崩溃时重启它。
start_critic2() -> spawn(?MODULE, restarter, []). restarter() -> process_flag(trap_exit, true), Pid = spawn_link(?MODULE, critic, []), receive {'EXIT', Pid, normal} -> % not a crash ok; {'EXIT', Pid, shutdown} -> % manual termination, not a crash ok; {'EXIT', Pid, _} -> restarter() end.
在这里,重启器将是它自己的进程。它将依次启动评论家的进程,如果评论家因异常原因死亡,`restarter/0` 将循环并创建一个新的评论家。请注意,我添加了一个用于 `{'EXIT', Pid, shutdown}` 的子句,作为一种手动杀死评论家的方法,以防我们将来需要这样做。
我们这种方法的问题是,我们无法找到评论家的 Pid,因此我们无法打电话给他征求他的意见。Erlang 解决这个问题的方案之一是为进程命名。
为进程命名使您可以用一个原子替换不可预测的 pid。然后,此原子可以用作 Pid 一样发送消息。为了给进程命名,使用函数 erlang:register/2。如果进程死亡,它将自动失去其名称,您也可以使用 unregister/1 手动执行此操作。您可以使用 registered/0 获取所有已注册进程的列表,或者使用 shell 命令 `regs()` 获取更详细的列表。在这里,我们可以将 `restarter/0` 函数改写如下:
restarter() -> process_flag(trap_exit, true), Pid = spawn_link(?MODULE, critic, []), register(critic, Pid), receive {'EXIT', Pid, normal} -> % not a crash ok; {'EXIT', Pid, shutdown} -> % manual termination, not a crash ok; {'EXIT', Pid, _} -> restarter() end.
因此,如您所见,`register/2` 将始终为我们的评论家分配名称 'critic',无论 Pid 是什么。接下来,我们需要做的就是消除从抽象函数中传入 Pid 的必要性。让我们试试这个
judge2(Band, Album) -> critic ! {self(), {Band, Album}}, Pid = whereis(critic), receive {Pid, Criticism} -> Criticism after 2000 -> timeout end.
在这里,行 `Pid = whereis(critic)` 用于查找评论家的进程标识符,以便在 `receive` 表达式中与它进行模式匹配。我们希望与这个 pid 匹配,因为这样可以确保我们匹配正确的消息(在邮箱中可能会有 500 个这样的消息)。但这可能是问题的根源。上面的代码假设评论家的 pid 在函数的前两行之间将保持不变。但是,以下情况完全可能发生
1. critic ! Message 2. critic receives 3. critic replies 4. critic dies 5. whereis fails 6. critic is restarted 7. code crashes
或者,这也是可能的
1. critic ! Message 2. critic receives 3. critic replies 4. critic dies 5. critic is restarted 6. whereis picks up wrong pid 7. message never matches
如果我们没有采取正确的措施,另一个进程中出现错误的可能性会导致另一个进程出现错误。在这种情况下,critic 原子的值可以从多个进程中看到。这被称为 *共享状态*。这里的问题是,critic 的值可以被不同的进程几乎同时访问 *和* 修改,从而导致信息不一致和软件错误。这类问题的常用术语是 *竞争条件*。竞争条件特别危险,因为它们取决于事件的时机。在几乎所有现有的并发和并行语言中,这种时机取决于不可预测的因素,例如处理器的繁忙程度、进程的运行位置以及程序正在处理的数据。
不要喝太多酷乐
您可能听说过,Erlang 通常没有竞争条件或死锁,并且使并行代码安全。在许多情况下,这是正确的,但永远不要假设您的代码真的那么安全。命名进程只是并行代码出错的多种方式中的一种。
其他例子包括访问计算机上的文件(以修改它们)、从许多不同的进程更新相同的数据库记录,等等。
幸运的是,如果我们不假设命名进程保持不变,修复上面的代码相对容易。相反,我们将使用引用(使用 `make_ref()` 创建)作为唯一值来识别消息。我们需要将 `critic/0` 函数重写为 `critic2/0`,并将 `judge/3` 重写为 `judge2/2`
judge2(Band, Album) -> Ref = make_ref(), critic ! {self(), Ref, {Band, Album}}, receive {Ref, Criticism} -> Criticism after 2000 -> timeout end. critic2() -> receive {From, Ref, {"Rage Against the Turing Machine", "Unit Testify"}} -> From ! {Ref, "They are great!"}; {From, Ref, {"System of a Downtime", "Memoize"}} -> From ! {Ref, "They're not Johnny Crash but they're good."}; {From, Ref, {"Johnny Crash", "The Token Ring of Fire"}} -> From ! {Ref, "Simply incredible."}; {From, Ref, {_Band, _Album}} -> From ! {Ref, "They are terrible!"} end, critic2().
然后更改 `restarter/0` 以使其适合,通过让它生成 `critic2/0` 而不是 `critic/0`。现在其他函数应该可以正常工作。用户不会看到任何区别。好吧,他们会看到,因为我们重命名了函数并更改了参数数量,但他们不会知道更改了哪些实现细节以及为什么要这样做。他们只会看到他们的代码变得更简单了,他们不再需要在函数调用中传递 pid
6> c(linkmon). {ok,linkmon} 7> linkmon:start_critic2(). <0.55.0> 8> linkmon:judge2("The Doors", "Light my Firewall"). "They are terrible!" 9> exit(whereis(critic), kill). true 10> linkmon:judge2("Rage Against the Turing Machine", "Unit Testify"). "They are great!"
现在,即使我们杀死了评论家,也会立即出现一个新的评论家来解决我们的问题。这就是命名进程的有用之处。如果您尝试在没有注册进程的情况下调用 `linkmon:judge/2`,函数内部的 `!` 运算符将抛出一个 bad argument 错误,确保依赖于命名进程的进程无法在没有它们的情况下运行。
注意:如果您还记得之前的内容,原子可以使用有限的(但数量很多)数量。您永远不应该创建动态原子。这意味着命名进程应该保留给 VM 实例的特定重要服务以及在应用程序运行期间始终存在的进程。
如果您需要命名进程,但它们是暂时的,或者没有任何一个进程是 VM 特定的,那么这可能意味着它们需要作为一个组来表示。如果它们崩溃,将它们链接起来并一起重启可能是明智的选择,而不是尝试使用动态名称。
在下一章中,我们将通过编写一个真实应用程序来实践我们最近获得的关于 Erlang 并发编程的知识。