消息传递(Message passing)的注意事项

消息传递一直是 Erlang 的核心,虽然有合理的文档记录,但我们避免了过多的细节,以便在实现时给我们更多的自由。不过,没有什么能阻止我们在博客文章中描述它,所以让我们仔细看看吧!

Erlang 进程通过相互发送信号来相互通信 (不要与 Unix 信号混淆)。有许多不同的类型, 消息只是最常见的。实际上,涉及多个进程的所有事情都在内部使用信号:例如,该link/1功能是通过让所涉及的进程来回交互直到它们就链接达成一致来实现的。

这可以帮助我们避免大量的锁定,并且可以自己写一篇有趣的博客文章,但现在我们只需要记住两件事:所有信号(包括消息)在后台持续接收和处理,并且它们有一个明确的顺序

两个进程之间的信号保证按照它们发送的顺序到达。换句话说,如果进程A发送信号1然后再发送2给进程 B,则信号1保证在信号之前到达2。

为什么这很重要?考虑请求-响应习惯用法:

%% Send a monitor signal to `Pid`, requesting a 'DOWN' message
%% when `Pid` dies.
Mref = monitor(process, Pid),
%% Send a message signal to `Pid` with our `Request`
Pid ! {self(), Mref, Request},
receive
    {Mref, Response} ->
        %% Send a demonitor signal to `Pid`, and remove the
        %% corresponding 'DOWN' message that might have
        %% arrived in the meantime.
        erlang:demonitor(Mref, [flush]),
        {ok, Response};
    {'DOWN', Mref, _, _, Reason} ->
        {error, Reason}
end

由于死进程无法发送消息,我们知道响应必须在任何最终'DOWN'消息之前出现,但是如果没有保证顺序, 'DOWN'消息可能会在响应之前到达,我们不知道是否有响应到来,这会使得处理很烦人。

有一个明确的顺序可以为我们省去很多麻烦,而且成本也不高,但保证就到此为止了。如果多个进程向一个公共进程发送信号,即使您“知道”其中一个信号首先发送,它们也可以按任何顺序到达。例如,这一系列事件是合法的并且完全有可能:

  • A发送信号1到B
  • A发送信号2到C
  • C响应信号2,发送信号3到B
  • B接收信号3
  • B接收信号1

幸运的是,很少需要全局订单并且很容易强加给自己(分布式案例之外):只需让所有相关方与一个通用流程同步即可。

发送消息

发送消息很简单:我们尝试找到与进程标识符相关联的进程,如果存在,我们将消息插入其信号队列。

消息总是在被插入队列之前被复制。尽管这听起来很浪费,但它大大减少了垃圾收集 (GC) 延迟,因为 GC 永远不必超越单个进程。过去曾尝试过非复制实现,但结果证明它们并不适合,因为对于 Erlang 旨在构建的那种软实时系统来说,低延迟比纯粹的吞吐量更重要。

默认情况下,消息被直接复制到接收进程的堆中,但是当这不可能(或不希望 – 请参阅message_queue_data标志)时,我们将消息分配到堆之外。

内存分配使这种“堆外”消息稍微昂贵一些,但对于接收大量消息的进程来说,它们非常简洁。我们在复制消息时不需要与接收者交互——​​仅在将消息添加到队列时——并且由于进程可以看到消息的唯一方法是在receive表达式中匹配它们,因此 GC 不需要考虑未匹配的消息,这进一步减少了延迟。

向其他 Erlang 节点上的进程发送消息的工作方式相同,尽管现在存在消息在传输过程中丢失的风险。只要节点之间的分发链接处于活动状态,就可以保证传递消息,但是当链接断开时,它会变得很棘手。

在远程进程(或节点)上使用monitor/2会告诉你什么时候发生这种情况,就像进程死了一样(因为no connection原因),但这并不总是有帮助:在收到消息并在另一个上处理后链接可能已经死了,最后我们所知道的是,在我们得到任何最终响应之前,链接就断开了。

与其他一切一样,没有免费的午餐,您需要决定您的应用程序应该如何处理这些场景

接收消息

有人可能会猜测进程通过receive表达式接收消息,但在这里receive有点用词不当。与所有其他信号一样,该进程在后台连续处理它们,将接收到的消息从 信号队列移动到消息队列。

receive在消息队列中搜索匹配的消息(按照它们到达的顺序),如果没有找到则等待新消息。搜索消息队列而不是信号队列意味着它不必担心发送消息的进程,这大大提高了性能。

这种“有选择地接收”特定消息的能力非常方便:我们并不总是处于可以决定如何处理消息的环境中,并且不得不手动拖拽所有未处理的消息当然很烦人。

不幸的是,将消息搜索扫到地毯下并不会让它消失:

receive
    {reply, Result} ->
        {ok, Result}
end

如果队列中的下一条消息匹配,则上述表达式会立即完成{reply, Result},但如果没有匹配的消息,则必须在放弃之前遍历所有消息。当有大量消息排队时这很昂贵,这对于类似服务器的进程很常见,并且由于 receive表达式几乎可以匹配任何东西,因此几乎无法优化搜索本身。

我们目前做的唯一优化是当我们知道在某个点之前不存在消息时标记搜索的起点。让我们重温一下请求-响应惯用语:

Mref = monitor(process, Pid),
Pid ! {self(), Mref, Request},
receive
    {Mref, Response} ->
        erlang:demonitor(Mref, [flush]),
        {ok, Response};
    {'DOWN', Mref, _, _, Reason} ->
        {error, Reason}
end

由于创建的引用monitor/2是全局唯一的,并且在所述调用之前不存在,并且receive唯一匹配包含所述引用的消息,因此我们不需要查看在此之前收到的任何消息。

即使在消息队列长得离谱的进程上,这也使该习惯用法变得高效,但不幸的是,在一般情况下,我们无法做到这一点。虽然您作为程序员可以确保某个响应必须在其请求之后出现,即使没有参考,例如通过使用您自己的序列号,编译器无法读取您的意图并且必须假设您想要任何匹配的消息.

目前,弄清楚上述优化是否已经启动是相当烦人的。它需要检查 BEAM 组件,即使那样,由于一些烦人的限制,您也不能保证它会正常工作:

  • 我们一次只支持一个消息位置:一个函数创建一个引用,调用另一个使用此优化的函数,然后返回receive第一个引用,最终将搜索整个消息队列。
  • 它只适用于单个函数子句:引用创建和 receive需要彼此相邻,并且您不能有多个函数调用公共receive帮助程序。

我们在即将发布的 OTP 24 版本中解决了这些缺点,并添加了一个编译器选项来帮助您发现它的应用位置:

$ erlc +recv_opt_info example.erl
-module(example).
-export([t/2]).

t(Pid, Request) ->
%% example.erl:5: OPTIMIZED: reference used to mark a
%%                           message queue position
Mref = monitor(process, Pid),
Pid ! {self(), Mref, Request},
%% example.erl:7: INFO: passing reference created by
%%                      monitor/2 at example.erl:5
await_result(Mref).

await_result(Mref) ->
%% example.erl:10: OPTIMIZED: all clauses match reference
%%                            in function parameter 1
receive
{Mref, Response} ->
erlang:demonitor(Mref, [flush]),
{ok, Response};
{'DOWN', Mref, _, _, Reason} ->
{error, Reason}
end.

Related Posts

2021 年你需要知道的关于 Erlang 的一切

今天,我们将看一个相当古老且有些古怪的东西。 你们大多数人可能没有注意到的语言。 虽然 Erlang 不像某些现代编程语言那样流行,但它安静地运行着 WhatsApp 和微信等每天为大量用户提供服务的应用程序。 在这篇文章中,我将告诉你关于这门语言的更多事情、它的历史,以及你是否应该考虑自己学习它。 ## 什么是 Erlang,它在哪里使用? Erl

Read More

Erlang JIT中基于类型的优化

这篇文章探讨了 Erlang/OTP 25 中基于类型的新优化,其中编译器将类型信息嵌入到 BEAM 文件中,以帮助JIT(即时编译器)生成更好的代码。 ## 两全其美 OTP 22 中引入的基于SSA的编译器处理步骤进行了复杂的类型分析,允许进行更多优化和更好的生成代码。然而,Erlang 编译器可以做什么样的优化是有限制的,因为 BEAM 文件必须

Read More

Erlang JIT之路

自从Erlang 存在,就一直有让它更快的需求和野心。这篇博文是一堂历史课,概述了主要的 Erlang 实现以及如何尝试提高 Erlang 的性能。 ## Prolog 解释器 Erlang 的第一个版本是在 1986 年在 Prolog 中实现的。那个版本的 Erlang 对于创建真正的应用程序来说太慢了,但它对于找出Erlang语言的哪些功能有用,哪

Read More