Erlang/OTP 24 亮点
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 编译器
- 改进的错误消息
- 改进的接收优化
- EEP-53:进程别名
- EEP-48:edoc 的文档块
- gen_tcp 中的套接字支持
- EEP-56:Supervisor 自动关闭
- Edwards 曲线数字签名算法
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 report,hotspot 或 speedscope 之类的工具来分析结果。
$ hotspot perf.data
在上面,我们可以看到,我们得到的结果与使用 eprof
时大致相同,尽管有趣的是并不完全相同。我将把其中的原因留给读者去发现 :)
通过这种在分析时几乎没有开销的方式,我们可以运行以前由于分析时间太长而无法运行的场景。对于那些勇敢的人来说,甚至可以在生产环境中运行始终开启的分析!
使用 perf 可以完成的工作才刚刚开始。在 PR-4676 中,我们将添加帧指针支持,这将在分析时提供更准确的调用帧,并且最终目标是在使用 perf report 和 hotspot 分析 perf 记录时,能够映射到 Erlang 源代码行,而不是仅映射到函数。
改进的错误消息 #
Erlang 的错误消息往往会受到很多(合理的)批评,因为它很难理解。已经添加了两个很棒的新功能,以帮助用户理解为什么会出现错误。
警告和错误中的列号 #
感谢 Richard Carlsson 和 Hans 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 LS 或 flycheck 使用 VSCode 或 Emacs 时,您还可以获得更窄的警告/错误指示符,例如在使用 Erlang LS 的 Emacs 中。
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:call
,gen_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/3
和 recon: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_tcp 和 inet 函数的参数使用。
然后,你可以使用 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 自动关闭 #
在为管理连接(如 ssl 或 ssh)的应用程序创建 supervisor 层次结构时,有时需要从内部终止该 supervisor 层次结构。套接字上发生某些事件,应该触发与连接关联的进程的正常关闭。
通常,这将通过使用 supervisor:terminate_child/2 完成。但是,这有两个问题。
- 它要求子进程知道需要终止的子进程的 ID 和要与之通信的 supervisor 的 PID。当 supervisor 中只有一个进程时,这很简单,但是当 supervisor 下面还有 supervisor 时,这会变得越来越难确定。
- 调用 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
...] ^ ^