Erlang/OTP 24 亮点

2021 年 5 月 12 日 · 作者:Lukas Larsson

Erlang/OTP 24 终于发布了!对我来说,这是一个酝酿了大约 10 年的版本。按照惯例现在,这篇博客文章将介绍我对 Erlang/OTP 中最令人兴奋的改进!

Erlang/OTP 24 包含了来自 60 多位外部贡献者的贡献,总计 1400+ 次提交、300+ 个 PR,以及修改了 50 万(!)行代码。虽然我不确定行数是否应该算作我们已经 vendor 了所有 AsmJit 并重新生成了 wxWidgets 支持。如果忽略 AsmJit 和 wx,仍然添加了 26 万行代码,删除了 32 万行代码,这比我们通常发布的版本多出约 10 万行。

您可以在此处下载描述更改的自述文件:Erlang/OTP 24 自述文件。或者,像往常一样,查看您感兴趣的应用程序的版本说明。例如,这里:Erlang/OTP 24 - Erts 版本说明 - 12.0 版

今年的亮点是:

BeamAsm - Erlang 的 JIT 编译器 #

Erlang/OTP 24 最受期待的功能必须是 JIT 编译器。关于它已经有很多说法了:

甚至在发布之前,WhatsApp 团队就已经展示了它的能力

然而,除了 JIT 带来的性能提升之外,我最兴奋的是运行本机代码而不是解释所带来的好处。我所说的是现在所有 Erlang 程序员都可以使用的本机代码工具,例如与 perf 集成。

例如,在构建 Erlang 小核心的 dialyzer plt 时,以前的分析方法是通过类似 eprof 的东西。

> eprof:profile(fun() ->
    dialyzer:run([{analysis_type,'plt_build'},{apps,[erts]}])
  end).

这会将我的系统上构建 PLT 的时间从大约 1.2 秒增加到 15 秒。最后,您会得到如下内容,它将指导您优化什么。也许可以看看 erl_types:t_has_var*/1 并检查您是否真的需要调用它 13-15 百万次!

> eprof:analyze(total).
FUNCTION                      CALLS        %     TIME [uS / CALLS]
--------                      -----  -------     ---- [----------]
erl_types:t_sup1/2          2744805     1.68   752795 [      0.27]
erl_types:t_subst/2         2803211     1.92   858180 [      0.31]
erl_types:t_limit_k/2       3783173     2.04   913217 [      0.24]
maps:find/2                 4798032     2.14   957223 [      0.20]
erl_types:t_has_var/1      15943238     5.89  2634428 [      0.17]
erl_types:t_has_var_list/1 13736485     7.51  3360309 [      0.24]
------------------------  ---------  ------- -------- [----------]
Total:                    174708211  100.00% 44719837 [      0.26]

在 Erlang/OTP 24 中,我们可以在无需支付 eprof 分析的相当高昂的成本的情况下获得相同的结果。当使用 perf 运行与上面相同的分析时,大约需要 1.3 秒才能运行。

$ ERL_FLAGS="+JPperf true" perf record dialyzer --build_plt \
    --apps erts

然后我们可以使用诸如 perf reporthotspotspeedscope 之类的工具来分析结果。

$ hotspot perf.data

alt text

在上面,我们可以看到,我们得到的结果与使用 eprof 时大致相同,尽管有趣的是并不完全相同。我将把其中的原因留给读者去发现 :)

通过这种在分析时几乎没有开销的方式,我们可以运行以前由于分析时间太长而无法运行的场景。对于那些勇敢的人来说,甚至可以在生产环境中运行始终开启的分析!

使用 perf 可以完成的工作才刚刚开始。在 PR-4676 中,我们将添加帧指针支持,这将在分析时提供更准确的调用帧,并且最终目标是在使用 perf reporthotspot 分析 perf 记录时,能够映射到 Erlang 源代码行,而不是仅映射到函数。

改进的错误消息 #

Erlang 的错误消息往往会受到很多(合理的)批评,因为它很难理解。已经添加了两个很棒的新功能,以帮助用户理解为什么会出现错误。

警告和错误中的列号 #

感谢 Richard CarlssonHans Bolinder 的工作,当您编译 Erlang 代码时,您现在可以在 shell 中获得错误和警告的行和列,以及一个 ^ 符号,准确地显示错误实际发生的位置。例如,如果您编译以下代码:

foo(A, B) ->
  #{ a => A, b := B }.

在 Erlang/OTP 23 和更早的版本中,您会得到

$ erlc t.erl
t.erl:6: only association operators '=>' are allowed in map construction

但是在 Erlang/OTP 24 中,您现在还会得到以下打印输出:

$ erlc test.erl
t.erl:6:16: only association operators '=>' are allowed in map construction
%    6|   #{ a => A, b := B }.
%     |                ^

此行为还扩展到大多数 Erlang 代码编辑器中,因此当您通过 Erlang LSflycheck 使用 VSCode 或 Emacs 时,您还可以获得更窄的警告/错误指示符,例如在使用 Erlang LS 的 Emacs 中。

alt text

EEP-54:改进的 BIF 错误信息 #

在错误信息方面,另一个重大变化是引入了 EEP-54。过去,许多 BIF (内置函数) 会给出非常隐晦的错误消息:

1> element({a,b,c}, 1).
** exception error: bad argument
     in function  element/2
        called as element({a,b,c},1)

在上面的示例中,我们唯一知道的是一个或多个参数无效,但是如果不检查文档,就无法知道是哪一个以及为什么。对于参数可能由于在参数中不可见的因素而导致失败的 BIF 来说,这尤其是一个问题。例如,在下面的 ets:update_counter 调用中:

> ets:update_counter(table, k, 1).
** exception error: bad argument
     in function  ets:update_counter/3
        called as ets:update_counter(table,k,1)

我们不知道该调用失败是因为表根本不存在,还是因为我们要更新的键 k 不存在于表中。

在 Erlang/OTP 24 中,以上两个示例都将具有更清晰的错误消息。

1> element({a,b,c}, 1).
** exception error: bad argument
     in function  element/2
        called as element({a,b,c},1)
        *** argument 1: not an integer
        *** argument 2: not a tuple
2> ets:new(table,[named_table]).
table
3> ets:update_counter(table, k, 1).
** exception error: bad argument
     in function  ets:update_counter/3
        called as ets:update_counter(table,k,1)
        *** argument 2: not a key that exists in the table

看起来好多了,现在我们可以看到问题出在哪里了!标准的日志格式化程序也包含其他信息,因此如果这种类型的错误发生在生产环境中,您将获得额外的错误信息。

1> proc_lib:spawn(fun() -> ets:update_counter(table, k, 1) end).
<0.94.0>
=CRASH REPORT==== 10-May-2021::11:20:35.367023 ===
  crasher:
    initial call: erl_eval:'-expr/5-fun-3-'/0
    pid: <0.94.0>
    registered_name: []
    exception error: bad argument
      in function  ets:update_counter/3
         called as ets:update_counter(table,k,1)
         *** argument 1: the table identifier does
                         not refer to an existing ETS table
    ancestors: [<0.92.0>]

EEP-54 不仅对来自 BIF 的错误消息有用,而且可以被任何想要提供有关其异常的额外信息的应用程序使用。例如,我们一直在努力在 PR-4757 中提供有关 io:format 的更好错误信息。

改进的接收优化 #

自从 Erlang/OTP R14(于 2010 年发布)以来,Erlang 编译器和运行时系统协同工作,以优化类似 gen_server:call 的功能所使用的代码模式,以避免扫描可能很大的邮箱。基本模式如下所示:

call(To, Msg) ->
  Ref = make_ref(),
  To ! {call, Ref, self(), Msg},
  receive
    {reply, Ref, Reply} -> Reply
  end.

编译器可以从中推断出,当创建 Ref 时,该进程的邮箱中不能有包含 Ref 的消息,因此可以在接收 Reply 时跳过所有这些消息。

这在这样的简单场景中一直运行良好,但是一旦您必须使场景稍微复杂一些,它往往会破坏编译器的分析,并且您最终会扫描整个邮箱。例如,在下面的代码中,Erlang/OTP 23 将不会优化接收。

call(To, Msg, Async) ->
  Ref = make_ref(),
  To ! {call, Ref, self(), Msg},
  if
    Async ->
      {ok, Ref};
    not Async ->
      receive
        {reply, Ref, Reply} -> Reply
      end
  end.

所有这些在 Erlang/OTP 24 中都发生了变化!现在,更多的复杂场景都涵盖了优化,并且添加了一个新的编译器标志,以告知用户是否进行了优化。

$ erlc +recv_opt_info test.erl
test.erl:6: Warning: OPTIMIZED: reference used to mark
                                a message queue position
%    6|   Ref = make_ref(),
test.erl:12: Warning: OPTIMIZED: all clauses match reference
                                 created by make_ref/0
                                 at test.erl:6
%   12|       receive

即使是像 multi_call 这样的模式现在也进行了优化,以避免扫描进程的邮箱。

multi_call(ToList, Msg) ->
  %% OPTIMIZED: reference used to mark a message queue position
  Ref = make_ref(),
  %% INFO: passing reference created by make_ref/0 at test.erl:18
  [To ! {call, Ref, self(), Msg} || To <- ToList],
  %% INFO: passing reference created by make_ref/0 at test.erl:18
  %% OPTIMIZED: all clauses match reference
  %%            in function parameter 2
  [receive {reply, Ref, Reply} -> Reply end || _ <- ToList].

仍然有很多地方没有触发此优化。例如,只要任何 make_ref/send/receive 位于不同的模块中,它就不会工作。但是,Erlang/OTP 24 中的新改进使场景数量大大减少,现在我们还有工具可以检查并查看是否触发了优化!

您可以在效率指南中阅读有关此优化和其他优化的更多信息。

EEP-53:进程别名 #

在调用另一个 Erlang 进程时,gen_server:callgen_statem:call 等使用的模式通常如下所示:

call(To, Msg, Tmo) ->
  MonRef = erlang:monitor(process, To),
  To ! {call, MonRef, self(), Msg},
  receive
    {'DOWN',MonRef,_,_,Reason} ->
      {error, Reason};
    {reply, MonRef, Reply}
      erlang:demonitor(MonRef,[flush]),
      {ok, Reply}
    after Tmo ->
      erlang:demonitor(MonRef,[flush]),
      {error, timeout}
  end.

这通常运行良好,除了发生超时的情况。发生超时时,另一端的进程无法知道不再需要答复,因此在完成后仍会发送答复。这会引起各种问题,因为第三方库的用户永远不会知道邮箱中应该存在哪些消息。

人们已经多次尝试使用 Erlang 给您的原语来解决此问题,但最终,大多数人只是在他们的 gen_server 中添加了一个 handle_info,它会忽略任何未知消息。

在 Erlang/OTP 24 中,EEP-53 引入了 alias 功能来解决此问题。alias 是对可用于发送消息的进程的临时引用。在大多数方面,它的工作方式与 PID 相同,只不过别名的生存期与它所代表的进程的生存期无关。因此,当您尝试向已停用的别名发送迟到的答复时,该消息将被丢弃。

进行此更改所需的代码更改非常小,并且已经在 Erlang/OTP 的所有标准行为中在后台使用。在上面的示例代码中唯一需要更改的是必须为 erlang:monitor 提供一个新选项,并且答复引用现在应该是别名而不是调用 PID。也就是说,像这样:

call(To, Msg, Tmo) ->
  MonAlias = erlang:monitor(process, To, [{alias, demonitor}]),
  To ! {call, MonAlias, MonAlias, Msg},
  receive
    {'DOWN', MonAlias, _ , _, Reason} ->
      {error, Reason};
    {reply, MonAlias, Reply}
      erlang:demonitor(MonAlias,[flush]),
      {ok, Reply}
    after Tmo ->
      erlang:demonitor(MonAlias,[flush]),
      {error, timeout}
  end.

您可以在别名文档中阅读有关此功能的更多信息。

EEP-48:edoc 的文档块 #

在 Erlang/OTP 23 中,erl_docgen 被扩展为能够发出 EEP-48 样式的文档。这允许在 Erlang shell 和 Erlang LS 等外部工具中使用 h(lists) 来使用该文档。但是,除了 Erlang/OTP 之外,很少有应用程序使用 erl_docgen 来创建文档,因此 EEP-48 样式的文档对于这些应用程序不可用。直到现在!

Radek Szymczyszyn 已经添加了对 EEP-48 的支持到 edoc 中,这意味着从 Erlang/OTP 24 开始,你可以查看 lists:foldl/3recon:info/1 的文档。

$ rebar3 as docs shell
Erlang/OTP 24 [erts-12.0] [source] [jit]

Eshell V11.2.1  (abort with ^G)
1> h(recon,info,1).
 -spec info(PidTerm) ->
   [{info_type(), [{info_key(), Value}]}, ...]
     when PidTerm :: pid_term().

  Allows to be similar to erlang:process_info/1, but excludes
  fields such as the mailbox, which tend to grow
  and be unsafe when called in production systems. Also includes
  a few more fields than what is usually given (monitors,
  monitored_by, etc.), and separates the fields in a more
  readable format based on the type of information contained.

要了解如何在你的项目中启用此功能,请参阅 Edoc 用户指南中的文档块部分

gen_tcp 中的 socket 支持 #

gen_tcp 模块已获得支持,可以选择使用新的 socket NIF API 而不是之前的 inet 驱动程序。可以通过设置应用程序配置参数(例如:-kernel inet_backend socket)在系统级别配置使用新接口,或者在每个连接的基础上配置(例如:gen_tcp:connect(localhost,8080,[{inet_backend,socket}]))。

如果你这样做,你会注意到 gen_tcp 返回的 Socket 不再是一个端口,而是一个包含(其他内容)PID 和引用的元组。

1> gen_tcp:connect(localhost,8080,[{inet_backend,socket}]).
{ok,{'$inet',gen_tcp_socket,
             {<0.88.0>,{'$socket',#Ref<0.2959644163.2576220161.68602>}}}}

这个数据结构现在和一直以来都是不透明的,因此不应该直接检查,而只能作为其他 gen_tcpinet 函数的参数使用。

然后,你可以使用 inet:i/0 来获取系统中所有打开的套接字的列表

2> inet:i().
Port      Module         Recv Sent Owner    Local Address   Foreign Address    State Type   
esock[19] gen_tcp_socket 0    0    <0.98.0> localhost:44082 localhost:http-alt CD:SD STREAM 

gen_tcp API 应该与旧实现完全向后兼容,因此如果可以,请测试它并将你发现的任何错误报告给我们。

你为什么要测试这个?因为在我们的一些基准测试中,我们获得了高达旧实现 4 倍的吞吐量。在其他基准测试中,吞吐量没有差异,甚至有所下降。所以,像往常一样,你需要自己衡量和检查!

EEP-56:Supervisor 自动关闭 #

在为管理连接(如 sslssh)的应用程序创建 supervisor 层次结构时,有时需要从内部终止该 supervisor 层次结构。套接字上发生某些事件,应该触发与连接关联的进程的正常关闭。

通常,这将通过使用 supervisor:terminate_child/2 完成。但是,这有两个问题。

  1. 它要求子进程知道需要终止的子进程的 ID 和要与之通信的 supervisor 的 PID。当 supervisor 中只有一个进程时,这很简单,但是当 supervisor 下面还有 supervisor 时,这会变得越来越难确定。
  2. 调用 supervisor:terminate_child/2 是一个同步操作。这意味着,如果你在子进程中执行调用,你可能会陷入死锁,因为顶层 supervisor 想要终止子进程,而子进程在调用终止自身时被阻塞。

为了解决这个问题,EEP-56 添加了一种机制,其中子进程可以被标记为重要,如果这样的子进程终止,它可以触发其所属 supervisor 的自动关闭。

这样,子进程可以从内部触发 supervisor 层次结构的关闭,而无需子进程了解任何关于 supervisor 层次结构的信息,也不必冒着在终止期间死锁自身的风险。

你可以在 supervisor 文档中阅读更多关于自动关闭的信息。

Edwards 曲线数字签名算法 #

Erlang/OTP 24 支持 Edwards 曲线数字签名算法EdDSA)。当连接到 TLS 1.3 客户端/服务器或充当 TLS 1.3 客户端/服务器时,可以使用 EdDSA

EdDSA 是一种 椭圆曲线签名算法 (ECDSA),可用于安全通信。ECDSA 的安全性依赖于 强大的密码安全随机数,如果随机数错误地不够安全,可能会导致问题,这在 ECDSA 的几种使用情况中已经发生过(据我们所知,Erlang 中没有发生过)。

EdDSA 的安全性不依赖于强大的随机数。这意味着当你使用 EdDSA 时,即使你的随机数生成器不安全,通信也是安全的。

尽管增加了安全性,但据称 EdDSA 比其他椭圆曲线签名算法更快。如果你有 OpenSSL 1.1.1 或更高版本,那么从 Erlang/OTP 24 开始,你将可以使用此算法!

> crypto:supports(curves).
[...
  c2tnb359v1, c2tnb431r1, ed25519, ed448, ipsec3, ipsec4
 ...]                     ^        ^