错误和进程

链接

链接是可以在两个进程之间建立的一种特殊关系。当这种关系建立起来,并且其中一个进程因意外抛出、错误或退出而死亡时(参见 错误和异常),另一个链接的进程也会死亡。

从尽快失败以停止错误的角度来看,这是一个有用的概念:如果发生错误的进程崩溃,但依赖于它的进程没有崩溃,那么所有这些依赖的进程现在都必须处理依赖消失的情况。让它们死亡,然后重新启动整个组通常是可接受的替代方案。链接使我们能够做到这一点。

为了在两个进程之间建立链接,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

或者,将其放在图片中

A process receiving an exit signal

但是,此 {'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 存在一样,但它都是作为一个原子操作完成的(这些操作组合成一个单一操作,要么失败要么成功,但没有其他操作)。这通常被认为更安全,您还可以节省一组括号。

Admiral Ackbar

这是一个陷阱!

现在回到链接和进程死亡。错误在进程之间的传播是通过类似于消息传递的进程完成的,但使用一种特殊的类型称为信号的消息。退出信号是“秘密”消息,它们会自动对进程起作用,在操作中杀死它们。

我已经多次提到,为了可靠,应用程序需要能够快速杀死和重新启动进程。现在,链接对于执行杀死部分来说已经足够了。缺少的是重启。

为了重新启动进程,我们需要一种方法来首先知道它死了。这可以通过在链接之上添加一层(蛋糕上美味的糖霜)来完成,使用一个名为系统进程的概念。系统进程基本上是普通进程,不同之处在于它们可以将退出信号转换为常规消息。这是通过在运行的进程中调用 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 是一种最后的手段,当其他所有方法都失败时才使用。

A mouse trap with a beige laptop on top

由于 `kill` 原因永远无法被捕获,因此在其他进程收到消息时,它需要更改为 `killed`。如果没有以这种方式更改,则与之关联的每个其他进程都会因为相同的 `kill` 原因而死亡,进而杀死其邻居,依此类推。就会出现死亡级联。

这也解释了为什么从另一个链接进程接收到的 `exit(kill)` 在接收时看起来像 `killed`(信号被修改,因此不会级联),但在本地捕获时仍看起来像 `kill`。

如果你觉得这一切都很混乱,别担心。许多程序员都有同感。退出信号有点像奇怪的动物。幸运的是,除了上面描述的这些情况之外,没有太多特殊情况。一旦你理解了这些,你就可以毫无问题地理解大多数 Erlang 的并发错误管理。

监控器

好吧,也许你并不想杀死进程。也许你不想在你离开后将世界拖入毁灭。也许你更像个跟踪狂。在这种情况下,监控器可能就是你想要的。

更严肃地说,监控器是一种特殊的链接,有两个区别:

Ugly Homer Simpson parody

当一个进程想要了解另一个进程的情况时,监控器是你的选择,但它们彼此之间并不真正重要。

另一个原因,如上所述,是堆叠引用。这乍一看可能没什么用,但对于编写需要了解其他进程情况的库非常有用。

你看,链接更多是一种组织结构。当您设计应用程序的架构时,您会确定哪个进程将执行哪些工作,以及什么依赖什么。一些进程将监督其他进程,一些进程无法没有孪生进程生存,等等。这种结构通常是固定的,预先知道的。链接对这种情况很有用,不应在结构之外使用。

但是,如果您的 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 并发编程的知识。