erlang 效率指南

Posted erlang collect

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了erlang 效率指南相关的知识,希望对你有一定的参考价值。

  • 1 简介

    • 1.1 用途

    • 1.2 必要条件

  • 2 erlang性能的7个传说

    • 2.1 尾递归比递归更快

    • 2.2 少用 ++

    • 2.3 string很慢

    • 2.4 修复Dets文件十分缓慢

    • 2.5 BEAM是一个基于堆栈的字节码虚拟机(因此速度较慢)

    • 2.6 若变量不被使用,使用匿名变量(_)更快

    • 2.7 NIF最快

  • 3 常见警告

    • 3.1 Timmer 模块

    • 3.2 list_to_atom/1

    • 3.3 length/1

    • 3.4 setelement/3

    • 3.5 size/1

    • 3.6 split_binary/2

  • 4 binary的构造和匹配

    • 4.1 binary的实现

    • 4.3 构造binaries

    • 4.3 Binaries匹配

    • Refc Binaries(引用计数二进制)

    • Heap Binaries (堆二进制)

    • Sub Binaries(子二进制)

    • Match Context(匹配上下文)

    • 强行复制的情况

    • bin_opt_info 选项

    • 未使用的变量

  • 5 List(列表)处理

    • 5.1 创建列表

    • 5.2 列表遍历

    • 5.3 深度列表平扁化

    • 5.4 列表递归函数

  • 6 functions(函数)

    • 6.1 模式匹配

    • 6.2 函数调用

    • 6.3 递归时的内存

  • 7 表和数据库

    • 7.1 Ets, Dets, Mnesia

    • 7.2 Ets

    • 7.3 Mnesia

    • Select/Match 操作

    • 删除元素

    • 数据获取

    • 非持久化数据存储

    • 顺序表

    • 索引

    • 事务

  • 8 Process(进程)

    • 8.1 创建进程

    • 8.2 进程消息

    • 8.3 SMP虚拟机

    • 初始堆大小

    • 共享丢失

  • 9 驱动程序

    • 9.1 并发

    • 9.2 避免复制bianry

    • 9.3 返回较小的binary

    • 9.3 返回非复制的大binary

  • 10 更进一步

    • 10.1 内存

    • 10.2 系统限制

  • 11 性能

    • 11.1 不要妄测性能

    • 11.2 内存调优

    • 11.3 大型系统

    • 11.4 分析

    • 11.5 工具

    • 11.6 基准测试

  • 12 过期传说

    • 12.1 函数变量很慢

    • 12.2 List遍历很慢

    • 12.3 List相减(--)很慢

  • 笔记


1 简介

1.1 用途

过早的优化是万恶之源 —— D.E. Knuth

高效的代码可以是结构良好且干净的,基于良好的整体架构和良好的算法。高效的代码可以是高度实现的,可以利用不在文档接口中没有明显缺陷的代码。

理想情况下,您的代码只包含第一种类型的有效代码。如果这样做速度太慢,可以对应用程序进行概要分析,找出性能瓶颈所在,并只对瓶颈进行优化。让其他代码尽可能保持整洁干净。

本效率指南并不能真正教你如何写出高效的代码。单它可以为您提供一些建议,告诉您应该避免什么和使用什么,以及了解某些语言特性是如何实现的。本指南不包括关于在任何语言中工作的优化的一般技巧,比如将常见的计算移出循环。

1.2 必要条件

本文假设您熟悉Erlang编程语言和OTP概念。

2 erlang性能的7个传说

有些真理只在一定时间期限内有效,也许是因为“信息”在人与人之间的传播要发布说明中所说的传播更快,例如尾递归调用更快

2.1 尾递归比递归更快

构建列表(list)时,用尾递归拼装后再用lists:reverse/1,比按正常顺序递归拼装更快,因为递归比尾递归耗费更多的内存

在R12B之前是对的,后续版本做了优化,已经很难说哪个更好了,详情可看Erlang's Tail Recursion is Not a Silver Bullet,所以用能让代码更简洁的方法就好(通常就是正递归版本)

2.2 少用 ++

++运算符在某种程度上是不受欢迎的,它的声誉很差。它可能与以下代码有关,这是反转列表的最无效的方法:

naive_reverse([H|T]) ->
naive_reverse(T)++[H];
naive_reverse([]) ->
[].

++ 会复制其左操作数,结果被重复复制,从而导致二次复杂度。但如果左操作数很小,也是不差的,如:

naive_but_ok_reverse([H|T], Acc) ->
naive_but_ok_reverse(T, [H]++Acc);
naive_but_ok_reverse([], Acc) ->
Acc.

若要杜绝复制左操作数,应该要这样写:

vanilla_reverse([H|T], Acc) ->
vanilla_reverse(T, [H|Acc]);
vanilla_reverse([], Acc) ->
Acc.

这样做的效率稍微高一些,因为这里没有构造一个list并复制(若编译器没有做 [H]++Acc = [H|Acc]这样的优化,其效率更高)

2.3 string很慢

如果处理不当,字符串处理会很慢。在Erlang中,您需要更多地考虑如何使用字符串并选择适当的表示形式。如果使用正则表达式,请使用STDLIB中的re模块而不是过时的regexp模块。

2.4 修复Dets文件十分缓慢

修复时间仍然与文件中的记录数量成正比,但过去Dets修复的速度要慢得多。现在Dets已被大量重写和改进。

2.5 BEAM是一个基于堆栈的字节码虚拟机(因此速度较慢)

BEAM是基于寄存器的虚拟机。它具有1024个虚拟寄存器,用于保存临时值和在调用函数时传递参数。需要在函数调用后保留的变量将保存到堆栈中。

BEAM是一个线程代码解释器。每个指令都是直接指向可执行C代码的字,使指令分派非常快。

2.6 若变量不被使用,使用匿名变量(_)更快

在R6B版本前是对的,但现在已经被编译器优化过了

类似地,源代码级别上的琐碎转换(如将case语句转换为函数顶层的子句)很少会对生成的代码产生任何影响

2.7 NIF最快

将Erlang代码重写为NIF以使其更快是万不得已的做法, 仅能保证它是危险的,但不能保证效率更高

NIF处理的多会降低虚拟机的响应,做的太少意味着调用NIF和检查参数的开销会吞噬NIF中更快处理的收益。

所以写NIF前,先了解一下吧

3 常见警告

本节列出了一些要注意的模块和BIF,不仅是从性能的角度来看。

3.1 Timmer 模块

需要用到定时器时,用erlang:send_after/3 和 erlang:start_timer/3更好,因为timer模块的定时用了一个进程去做集中管理,但定时器用得很频繁时,很容易出现超载。

timer里面有些函数是没用到timer进程的就很安全,如timer:tc/3 、timer:sleep/1

3.2 list_to_atom/1

原子(Atom)是不会被垃圾回收的,一旦创建就一直存在,若其数量超出限制(默认1048576),则系统就会崩溃

因此,在运行的系统中,将任意输入字符串转换为原子可能很危险。如果仅允许某些定义明确的原子作为输入,则用list_to_existing_atom/1

使用list_to_atom/1构造一个传递给apply/3的参数,是很耗时的,不建议想这样去用:

apply(list_to_atom("some_prefix"++Var), foo, Args)

3.3 length/1

与其他计算长度的函数(tuple_size/1, byte_size/1, bit_size/1,这些消耗的时间是固定的常熟)不同,对list的长度计算是与其长度成正比例消耗的

通常,不必担心length/1的速度,因为它是在C语言中有效实现的。但如果输入列表可能很长,则可能要避免使用它。

3.4 setelement/3

setelement/3会复制它修改的元组。因此,若用其修改元组数据,修改N次就复制N次

元组被复制的规则有一个例外。如果编译器能预知更新顺序级结果,则对setelement/ 3的调用将替换为特殊的破坏性setelement指令。如在以下代码序列中,第一个setelemen/3调用复制该元组并修改第九个元素:

multiple_setelement(T0) ->
T1 = setelement(9, T0, bar),
T2 = setelement(7, T1, foobar),
setelement(5, T2, new_value).

要应用优化,必须满足以下所有条件:

  • 索引必须是整数文字,而不是变量或表达式。

  • 索引必须按降序排列。

  • 在对setelement/3的调用之间,不得存在对另一个函数的调用。

  • 从一个setelement/3调用返回的元组只能在随后的setelement/3调用中使用。

如果不能保证上述条件,则修改大型元组中的多个元素的最佳方法是将元组转换为列表,修改列表,然后将其转换回元组。

3.5 size/1

size/1可以计算binary或tuple的大小

但最好是使用tuple_size/1或byte_size/1,明确类型可以让编译器做更多的优化,也能更好的支持静态分析

3.6 split_binary/2

通常,使用匹配而不是调用split_binary/2函数来拆分二进制文件更为高效。此外,混合使用这两种方式会干扰编译器的优化

%% 推荐
<<Bin1:Num/binary,Bin2/binary>> = Bin.
%% 不推荐
{Bin1,Bin2} = split_binary(Bin, Num).

4 binary的构造和匹配

%% 推荐构造方式
my_list_to_binary([H|T], Acc) ->
my_list_to_binary(T, <<Acc/binary,H>>);
my_list_to_binary([], Acc) ->
Acc.

%% 推荐匹配方式
my_binary_to_list(<<H,T/binary>>) ->
[H|my_binary_to_list(T)];
my_binary_to_list(<<>>) -> [].

4.1 binary的实现

在内部,binaries和bitstrings的实现方式相同。在本节中,它们统称为binaries,这也是在虚拟机源码中的名称。内部实现中,可分为四类:

  • 二进制数据的容器

    • Refc binaries(引用计数二进制文件的缩写)

    • Heap binaries

  • 二进制文件一部分的引用

    • sub binaries

    • match contexts

Refc Binaries(引用计数二进制)

可分为两种:

  • ProcBin = 存储在进程堆上

  • 独立个体,存储在所有进程堆之外

二进制对象可被多个进程的ProcBins引用,该对象包含一个引用计数器,以跟踪引用的数量,以便在最后一个引用消失时可以将其删除。

进程中的所有ProcBin对象都是链表的一部分,因此垃圾收集器可以跟踪它们,并在ProcBin消失时减少二进制中的引用计数器。

Heap Binaries (堆二进制)

堆二进制文件是小型二进制文件,最多64个字节,并直接存储在进程堆中。当进程被垃圾回收或作为消息发送时,它们会被复制。它们不需要垃圾收集器进行任何特殊处理。

Sub Binaries(子二进制)

Sub Binaries是由split_binary/2或进行二进制匹配时产生的,它是对另一个binary(refc或heap)的部分引用,因此,binary的匹配操作是很高效,因为它不需要复制

Match Context(匹配上下文)

Match Context类似于Sub Binaries,是在此之上的优化 例如,它包含一个指向二进制数据的直接指针,每次匹配只会让match context的位置增加

编译器会尽量避免创建sub binaries,而只是创建并保留match context ,但只能在确定该binary不被共享时做此项优化

4.3 构造binaries

binary 或 bitstring的拼接构造是由运行时系统(而不是编译器)特殊优化的,当其处理优化时,只有极少数情况下优化不起作用。

让我们看看下面的代码,并以此来解释它是怎么优化的

Bin0 = <<0>>,                    %% 1
Bin1 = <<Bin0/binary,1,2,3>>, %% 2
Bin2 = <<Bin1/binary,4,5,6>>, %% 3
Bin3 = <<Bin2/binary,7,8,9>>, %% 4
Bin4 = <<Bin1/binary,17>>, %% 5 !!!
{Bin4,Bin3} %% 6
  • 第1行 为Bin0分配heap bianry

  • 第2行 是个拼接操作,但Bin0不会参与拼接操作,一个新的refc binary会被创建来存储Bin0的值,但实际存储大小是refc binary的部分引用的ProcBin的大小,然而binary对象会有额外的分配空间,其大小是Bin1的两倍或256,以两者中较大的为准。这里是256。

  • 第3行 Bin1会参与拼接操作,由于它还剩下252B,所以接下来的3个字符会被直接复制进去

  • 第4行 还剩下249B,有足够的空间继续存储

  • 第5行 注意这里不是用上一行的Bin3,而是用Bin1。Bin4的值很明显能预知为 <<0,1,2,3,17>>。Bin3也能被预知为<<0,1,2,3,4,5,6,7,8,9>>。显然这里不能把17直接写入Bin1的内存空间,否则Bin3就会变为<<0,1,2,3,4,17,6,7,8,9>>。

运行时系统知道Bin1是前面追加操作的结果(且不是最后一个),所以它复制Bin1成为一个新binary且分配额外的存储空间等(这里就不讨论运行时系统如何知道不能写入Bin1。有兴趣可以了解源码中的erl_bits.c文件)。

强行复制的情况

binary拼接操作的优化要求有一个ProcBin和仅一个对ProcBin的引用。因为在拼接时,bianry对象会被重新分配,ProBin里的指针会被更新。如果有多个ProBin对同一个binary对象引用,那么当binary更新时,很难保证更新所有ProBin

因此,要对binary的某些操作进行标记,以便判断进行拼接是否需要复制。在大多数情况下,在回收额外增长的空间时会对bianry进行压缩

%% 当按此形式进行拼接时,仅从上一步操作返回的binary将支持进一步的廉价拼接
Bin = <<Bin0,...>>
%% 接下去若取Bin进行拼接,是廉价的;若取Bin0进行拼接,则会创建新的binary对象

如果binary作为消息发送到进程或端口,则该bianry会进行压缩,且后续任何拼接操作会创建一个新的binary,例如,在下面的代码片段中,Bin1将被复制:

Bin1 = <<Bin0,...>>,
<<X,Y,Z,T/binary>> = Bin1,
Bin = <<Bin1,...>> %% Bin1 will be COPIED

这是因为match context包含直接指向的指针

如果bianries仅保存在一个进程中(包括循环状态或进程字典),则垃圾收回时可以回收多余的空间。但如果只有这样一个bianry,将不会回收,方便后续拼接优化。

4.3 Binaries匹配

让我们回顾下开头的示例

my_binary_to_list(<<H,T/binary>>) ->
[H|my_binary_to_list(T)];
my_binary_to_list(<<>>) -> [].

在函数被调用时,match context就被创建出来并指向该binary的第一个字节,当H被匹配出来后,match context的指针就被更新,指向第二个字节。

看起来这里应该会创建sub binary,但这里编译器看到其马上要被函数所调用,所以就只创建了match context

初始化匹配操作的指令在看到传递值是match contexte而binary时,基本上什么也不做。

当到第二个匹配,即到了匹配最后,match context就会被回收(在下一次GC时被回收,因为它没有指向了)

总之,这函数只需要创建一个match context

请注意,遍历整个binary后,将放弃my_binary_to_list/1中的match context。如果迭代在到达binary尾部之前停止,会发生什么情况?优化是否仍然有效?如:

after_zero(<<0,T/binary>>) ->
T;
after_zero(<<_,T/binary>>) ->
after_zero(T);
after_zero(<<>>) ->
<<>>.

仍然有效,在第二个匹配中编译器会移除sub binary,但在第一个匹配中会创建sub binary,所以此函数会包含一个match context和sub binary

类似下面这样的代码将被编译器优化:

all_but_zeroes_to_list(Buffer, Acc, 0) ->
{lists:reverse(Acc),Buffer};
all_but_zeroes_to_list(<<0,T/binary>>, Acc, Remaining) ->
all_but_zeroes_to_list(T, Acc, Remaining-1);
all_but_zeroes_to_list(<<Byte,T/binary>>, Acc, Remaining) ->
all_but_zeroes_to_list(T, [Byte|Acc], Remaining-1).

编译器在第2、3匹配中不会创建sub binary,并在第1匹配中添加了一条指令,该指令将Buffer从match context转换为sub binary(如果Buffer已经是binary,则不执行任何操作)。

但在更复杂的代码中,我们怎么知道编译器有无优化呢?

bin_opt_info 选项

可以使用bin_opt_info编译选项,会输出关于binary的优化信息

erlc +bin_opt_info Mod.erl

或者通过定义环境变量

export ERL_COMPILER_OPTIONS=bin_opt_info

注意,bin_opt_info最好不要添加到Makefile中,因为所有优化的信息会被打印出来。因此,最好是修改环境变量 打印信息示例:

./efficiency_guide.erl:60: Warning: NOT OPTIMIZED: binary is returned from the function
./efficiency_guide.erl:62: Warning: OPTIMIZED: match context reused

警告信息指向说明使用案例:

after_zero(<<0,T/binary>>) ->
%% BINARY CREATED: binary is returned from the function
T;
after_zero(<<_,T/binary>>) ->
%% OPTIMIZED: match context reused
after_zero(T);
after_zero(<<>>) ->
<<>>.

未使用的变量

count1(<<_,T/binary>>, Count) -> count1(T, Count+1);
count1(<<>>, Count) -> Count.

count2(<<H,T/binary>>, Count) -> count2(T, Count+1);
count2(<<>>, Count) -> Count.

count3(<<_H,T/binary>>, Count) -> count3(T, Count+1);
count3(<<>>, Count) -> Count.

在上面代码中,第一个匹配的第一位都会被忽略,而不是被匹配出来

5 List(列表)处理

5.1 创建列表

最好以末尾为列表,开头为元素去增加列表长度。若使用++,会复制List1成为一个新列表去跟List2拼接

List1 ++ List2

%% ++lists:append/2的实现方式
append([H|T], Tail) ->
[H|append(T, Tail)];
append([], Tail) ->
Tail.

递归构建列表时,要确保将新元素附加到列表的开头,否则会创建成百上千的列表副本

%% 错误写法示范:
bad_fib(N) ->
bad_fib(N, 0, 1, []).
bad_fib(0, _Current, _Next, Fibs) ->
Fibs;
bad_fib(N, Current, Next, Fibs) ->
bad_fib(N - 1, Next, Current + Next, Fibs ++ [Current]).

%% 推荐写法示范:
tail_recursive_fib(N) ->
tail_recursive_fib(N, 0, 1, []).
tail_recursive_fib(0, _Current, _Next, Fibs) ->
lists:reverse(Fibs);
tail_recursive_fib(N, Current, Next, Fibs) ->
tail_recursive_fib(N - 1, Next, Current + Next, [Current|Fibs]).

5.2 列表遍历

%% 简洁遍历方式:
[Expr(E) || E <- List]

%% 编译器转换为:
'lc^0'([E|Tail], Expr) ->
[Expr(E)|'lc^0'(Tail, Expr)];
'lc^0'([], _Expr) -> [].

若列表遍历的结果不被使用,则编译器会做优化,不会去构建列表,如:

[io:put_chars(E) || E <- List],
ok.

%

...
case Var of
... ->
[io:put_chars(E) || E <- List];
... ->
end,
some_function(...),
...

%

_ = [io:put_chars(E) || E <- List],
ok.

编译器会简化成如下:

'lc^0'([E|Tail], Expr) ->
Expr(E),
'lc^0'(Tail, Expr);
'lc^0'([], _Expr) -> [].

5.3 深度列表平扁化

lists:flatten/1会建议一个元素不为列表的列表,因此效率是比较低的,甚至会比++更差 在以下情形,要避免使用lists:flatten/1

  • 把数据发送给端口

  • 一些会处理深度列表的函数,如list_to_binary/1 or iolist_to_binary/1

  • 若你明确知道只有一层时,用lists:append/1

%% 建议
port_command(Port, DeepList)
%% 不建议
port_command(Port, lists:flatten(DeepList))

%% 建议
TerminatedStr = [String, 0], % String="foo" => [[$f, $o, $o], 0]
port_command(Port, TerminatedStr)
%% 不建议
TerminatedStr = String ++ [0], % String="foo" => [$f, $o, $o, 0]
port_command(Port, TerminatedStr)

%% 建议
lists:append([[1], [2], [3]]).
%% 不建议
lists:flatten([[1], [2], [3]]).

5.4 列表递归函数

在上面说过:尾递归比递归更快, 现在这两种递归区别不大,因此还是写更容易理解、更美观的代码就好

注意:在不构造列表返回的递归函数中,尾递归会在恒定的空间中运行,而递归的运行空间与列表长度成正比

%% 不建议
recursive_sum([H|T]) -> H+recursive_sum(T);
recursive_sum([]) -> 0.

%% 建议
sum(L) -> sum(L, 0).
sum([H|T], Sum) -> sum(T, Sum + H);
sum([], Sum) -> Sum.

6 functions(函数)

6.1 模式匹配

编译器会优化functions、case、receive的模式匹配,因此调整匹配的先后顺序,大部分情况下是无用的,下面是少数几种要注意顺序的地方

对binary的匹配,编译器不会去优化顺序,因此把<<>>放在最后会比放在最前快

%% 不建议
atom_map1(one) -> 1;
atom_map1(two) -> 2;
atom_map1(three) -> 3;
atom_map1(Int) when is_integer(Int) -> Int;
atom_map1(four) -> 4;
atom_map1(five) -> 5;
atom_map1(six) -> 6.

不建议的原因是因为有变量Int。它可以匹配任意东西,导致编译器必须生如下的次优代码

  • 首先会去匹配one,two,three(使用一条执行二进制搜索的指令;因此,即使有很多值,效率也很高)三者之一

  • 如果前三个子句都不匹配,则第四个子句匹配,因为变量始终匹配

  • 若输入的是个int,则被匹配

  • 若不是再去匹配,跟上面的one匹配方式是一样的

这里稍微更有效的是吧is_integer这一行放到最后或最前,因为他前后的匹配是同一种方式,没必要分开两次匹配

%% 不建议
map_pairs1(_Map, [], Ys) ->
Ys;
map_pairs1(_Map, Xs, [] ) ->
Xs;
map_pairs1(Map, [X|Xs], [Y|Ys]) ->
[Map(X, Y)|map_pairs1(Map, Xs, Ys)].

%% 建议
map_pairs2(_Map, [], Ys) ->
Ys;
map_pairs2(_Map, [_|_]=Xs, [] ) ->
Xs;
map_pairs2(Map, [X|Xs], [Y|Ys]) ->
[Map(X, Y)|map_pairs2(Map, Xs, Ys)].

第一个变量_Map在所有分支里都是变量,没影响;但Xs在第二匹配里是个变量,所以编译器就不会去优化匹配的顺序,想建议写法那样,则编译器可以自由地重新排列子句

%% 编译器右后后的代码(不建议这样写,不美观且冗余)
explicit_map_pairs(Map, Xs0, Ys0) ->
case Xs0 of
[X|Xs] ->
case Ys0 of
[Y|Ys] ->
[Map(X, Y)|explicit_map_pairs(Map, Xs, Ys)];
[] ->
Xs0
end;
[] ->
Ys0
end.

对于可能最常见的输入列表不为空或非常短的情况,这会稍微快一些。(另一个优点是Dialyzer可以为Xs变量推断出更好的类型)

6.2 函数调用

基于在Solaris / Sparc上运行的基准数据,不同调用的效率如下:

  • 本地调用最快,如foo(),loop()

  • Fun(),apply(Fun, []) 是本地调用的三倍

  • Mod:Name(), apply(Mod, Name, []) 是本地的六倍

Fun(),apply(Fun, [])不涉及任何哈希表查找。其本身包含指向实现该函数的(间接)指针。

Module:Function(Arg1, Arg2)
apply(Module, Function, [Arg1,Arg2])

上面两种写法没啥区别,编译器会做优化

下面这个会稍慢一点,因为编译器不知道他的参数个数

apply(Module, Function, Arguments)

6.3 递归时的内存

编写递归函数时,最好使它们为尾递归,以便它们可以在恒定的内存空间中执行

%% 建议
list_length(List) ->
list_length(List, 0).
list_length([], AccLen) ->
AccLen; % Base case
list_length([_|Tail], AccLen) ->
list_length(Tail, AccLen + 1). % Tail-recursive

%% 不建议
list_length([]) ->
0. % Base case
list_length([_ | Tail]) ->
list_length(Tail) + 1. % Not tail-recursive

7 表和数据库

7.1 Ets, Dets, Mnesia

以下ets的例子也是用于dets、mnesia

Select/Match 操作

Select/match在ets或mnesia中式比較昂貴的操作,它们通常要遍历整个列表。使用时请构造足够精准的匹配,以提高匹配效率。不管怎样,它都会比tab2list高效。下面将为您展示搜索数据的推荐使用案例。无论如何,搜索数据首选select,其次才是match、match_object

在一些情况下,select/match并不需要遍历整张表,例如知道部分主键去搜索order_set表时,或者用索引去搜索mnesia表。若已经知道主键,则没必要用select/match,除非是bag table类型且需要特定键的元素子集

当使用record去进行数据匹配时,可能有很多个字段都是'_',您可以用 =''代替,表示除了指定的那些字段都接受任意值,如:

#person{age = 42, _ = '_'}.

删除元素

如果表中不存在该元素,则删除操作被视为成功,因此删除时没必要去检查元素是否存在,如

%% 建议
ets:delete(Tab, Key)

%% 不建议
case ets:lookup(Tab, Key) of
[] ->
ok;
[_|_] ->
ets:delete(Tab, Key)
end,

数据获取

不要重复取数据

%% 建议
%%% Interface function
print_person(PersonId) ->
%% Look up the person in the named table person,
case ets:lookup(person, PersonId) of
[Person] ->
print_name(Person),
print_age(Person),
print_occupation(Person);
[] ->
io:format("No person with ID = ~p~n", [PersonID])
end.

%%% Internal functions
print_name(Person) ->
io:format("No person ~p~n", [Person#person.name]).

print_age(Person) ->
io:format("No person ~p~n", [Person#person.age]).

print_occupation(Person) ->
io:format("No person ~p~n", [Person#person.occupation]).
%% 不建议
%%% Interface function
print_person(PersonId) ->
%% Look up the person in the named table person,
case ets:lookup(person, PersonId) of
[Person] ->
print_name(PersonID),
print_age(PersonID),
print_occupation(PersonID);
[] ->
io:format("No person with ID = ~p~n", [PersonID])
end.

%%% Internal functionss
print_name(PersonID) ->
[Person] = ets:lookup(person, PersonId),
io:format("No person ~p~n", [Person#person.name]).

print_age(PersonID) ->
[Person] = ets:lookup(person, PersonId),
io:format("No person ~p~n", [Person#person.age]).

print_occupation(PersonID) ->
[Person] = ets:lookup(person, PersonId),
io:format("No person ~p~n", [Person#person.occupation]).

非持久化数据存储

对于非永久性数据存储,与Mnesia local_content表相比,首选Ets表。与Ets写入相比,即使Mnesia的dirty_write操作也是固定消耗。但Mnesia必须检查表是否已复制或具有索引, 因此,Ets写入总是比Mnesia写入快

假设有一个以idno为key的ets表,表中数据如下:

[#person{idno = 1, name = "Adam",  age = 31, occupation = "mailman"},
#person{idno = 2, name = "Bryan", age = 31, occupation = "cashier"},
#person{idno = 3, name = "Bryan", age = 35, occupation = "banker"},
#person{idno = 4, name = "Carl", age = 25, occupation = "mailman"}]

如果你需要返回表的所有数据,那可以用ets:tab2list/1。但通常只是需要表中的部分字段,那tab2list是很消耗内存的。

%% 若你只是需要所有年龄,建议这样写
ets:select(Tab,[{ #person{idno='_', name='_', age='$1', occupation ='_'}, [], ['$1']}]),

%% 不建议
TabList = ets:tab2list(Tab),
lists:map(fun(X) -> X#person.age end, TabList),
%% 若只需要Bryan的年龄,建议:
ets:select(Tab,[{ #person{idno='_',
name="Bryan",
age='$1',
occupation = '_'},
[],
['$1']}]),

%% 不建议
TabList = ets:tab2list(Tab),
lists:foldl(fun(X, Acc) -> case X#person.name of
"Bryan" ->
[X#person.age|Acc];
_ ->
Acc
end
end, [], TabList),

%% 更差劲的写法
TabList = ets:tab2list(Tab),
BryanList = lists:filter(fun(X) -> X#person.name == "Bryan" end,
TabList),
lists:map(fun(X) -> X#person.age end, BryanList),
%% 若你需要Bryan的所有信息,建议
ets:select(Tab, [{#person{idno='_',
name="Bryan",
age='_',
occupation = '_'}, [], ['$_']}]),

%% 不建议
TabList = ets:tab2list(Tab),
lists:filter(fun(X) -> X#person.name == "Bryan" end, TabList),

顺序表

如果表数据的顺序很重要,推荐使用order_set表,对于键字段,始终以Erlang术语顺序遍历ordered_set,对select,match_object和foldl之类的函数的返回值进行排序。first或next也是返回有序的key

注意:ordered_set仅保证按键顺序处理对象。即使未将键包含在结果中,例如ets:select/2之类的函数的结果也会以键顺序显示。

7.2 Ets

ets表都是单主键表(哈希表或根据主键排序的树),即用主键去查询数据。键查询在set表中是O(1),在bag_set中是 O(logN)。与必须扫描整个表的调用相比,首选lookup(键查询)。在前面的示例中,字段idno是表的key,并且仅查找名称的所有查找都会对表进行遍历以查找匹配结果。

一个简单的解决是把name作为key,但name可能不唯一。通常会再创建第二张表,以name为key,以idno为数据,相当于建立name索引。显然,第二张表必须与主表保持一致,Mnesia可以为您做到这一点,但是与使用Mnesia所涉及的开销相比,自己构建索引表更高效。

上诉例子的索引表可以用bag表(主键不唯一)来构建,如下:

[#index_entry{name="Adam", idno=1},
#index_entry{name="Bryan", idno=2},
#index_entry{name="Bryan", idno=3},
#index_entry{name="Carl", idno=4}]

有了这索引表后,查询所有名为Bryan的年龄,可以这样写:

MatchingIDs = ets:lookup(IndexTable,"Bryan"),
lists:map(fun(#index_entry{idno = ID}) ->
[#person{age = Age}] = ets:lookup(PersonTable, ID),
Age
end,
MatchingIDs),

注意这里没用到select/match,而是用lookup,因此,主表中的查找次数得以最小化。

使用索引表时,若主要新增记录,索引表会需要更新,会带来额外的开销。要不要用取决于查询数据与插入数据的频繁度。但是,总之,用lockup查询数据是最高效的。

7.3 Mnesia

索引

如果您经常在不用key进行查询,则使用mnesia:select/match_object会损失性能,因为遍历整个表。您可以改为创建辅助索引,并使用mnesia:index_read来获得更快的访问权限,但是这需要更多的内存。

-record(person, {idno, name, age, occupation}).
...
{atomic, ok} =
mnesia:create_table(person, [{index,[#person.age]},
{attributes,
record_info(fields, person)}]
),
{atomic, ok} = mnesia:add_table_index(person, age),
...

PersonsAge42 =
mnesia:dirty_index_read(person, 42, #person.age),
...

事务

使用事务是一种保证分布式Mnesia数据库保持一致的方法,即使许多不同的进程并行更新。但是,如果您有实时要求,建议使用脏操作而不是事务。使用脏操作时,您会失去一致性保证;但这可以通过只允许一个进程更新表来解决。其他进程更新时必须将更新请求发送到该进程。

...
% Using transaction

Fun = fun() ->
[mnesia:read({Table, Key}),
mnesia:read({Table2, Key2})]
end,

{atomic, [Result1, Result2]} = mnesia:transaction(Fun),
...

% Same thing using dirty operations
...

Result1 = mnesia:dirty_read({Table, Key}),
Result2 = mnesia:dirty_read({Table2, Key2}),
...

8 Process(进程)

8.1 创建进程

Erlang Process是比系统的进程、线程更轻量级的东西 在没有开SMP或非HIPE模式下,一个新进程大概占309个字内存。开SMP或HIPE编译都会导致初始所占内存的增长。可以如下验证:

1> Fun = fun() -> receive after infinity -> ok end end.
#Fun<...>
2> {_,Bytes} = process_info(spawn(Fun), memory).
{memory,1232} %% 这里的单位是B
3> Bytes div erlang:system_info(wordsize).
309

堆区域(包括堆栈)占233个字。垃圾收集器根据需要增加堆大小。

进程的主(外部)循环必须是尾递归的。否则,堆栈会增长,直到进程终止。

%% 错误示范
loop() ->
receive
{sys, Msg} ->
handle_sys_msg(Msg),
loop();
{From, Msg} ->
Reply = handle_msg(Msg),
From ! Reply,
loop()
end,
io:format("Message is processed~n", []).
loop() -> 
receive
{sys, Msg} ->
handle_sys_msg(Msg),
loop();
{From, Msg} ->
Reply = handle_msg(Msg),
From ! Reply,
loop()
end.

初始堆大小

默初始堆大小为233个字,对于支有数十万甚至数百万个进程的Erlang系统而言,是相当保守的。垃圾收集器会根据情况进行收缩扩展

在使用较少进程的系统中,可以通过使用erl的+ h选项或通过使用spawn_opt/4的min_heap_size选项,通过增加进程最小堆大小来提高性能。

双重收益

  • 尽管垃圾收集器会增长堆,但它是逐步增长,且比在生成进程时直接建立更大的堆要昂贵。

  • 如果垃圾收集器比堆上存储的数据量大得多,它会缩小堆。但设置了最小堆大小可以保证持有一定空间而不被回收

注意:加大堆初始大小会导致模拟器使用更多的内存,并且由于垃圾回收的发生频率较低,因此大型二进制文件可以保留更长的时间。

在具有多个进程的系统中,可以将运行时间较短的计算任务派生到具有更高最小堆大小的新进程中。计算完成后,它将计算结果发送到另一个流程并终止。如果进程是常驻的,那么计算完后不一定会立刻触发垃圾回收。如果没有适当的测量,请勿尝试这种优化。

8.2 进程消息

所有在erlang进程间的消息都会被复制,除了通节点的refc binary数据

将消息发送到另一个Erlang节点上的进程时,首先将其编码为Erlang外部格式,然后再通过TCP/I发送。接收方Erlang节点会对消息进行解码并将其分发到正确的进程。

Constant Pool(常量池) 常量(也称为文字)会保存在常量池中;每个加载的模块都有其自己的池。以下函数不会在每次调用时生成元组(只是会在下次GC时将其丢弃),但是元组位于模块的常量池中:

days_in_month(M) ->
element(M, {31,28,31,30,31,30,31,31,30,31,30,31}).

但是,如果将常量发送到另一个进程(或存储在Ets表中),则会将其复制。原因是运行时系统必须能够跟踪对常量的所有引用,以正确卸载包含常量的代码。(当卸载代码时,将会吧常量复制到引用它们的进程的堆中。)在将来的Erlang / OTP版本中可能会删除常量的复制。

共享丢失

以下情况下,不保留共享:

  • 当被发送到另一个进程时

  • 当变量作为创建新进程的参数时

  • 当变量保存到ets表时

共享变量是一个优化。大多数应用程序不发送带有共享子项的消息。

如下所示,将演示一个带有共享子项的例子

kilo_byte() ->
kilo_byte(10, [42]).

kilo_byte(0, Acc) ->
Acc;
kilo_byte(N, Acc) ->
kilo_byte(N-1, [Acc|Acc]).

kilo_byte/1创建了一个深度列表。若调用list_to_binary/1,改列表被平扁化,占用1024B

byte_size(list_to_binary(efficiency_guide:kilo_byte())).
1024

使用erts_debug:size/1 BIF,可以看到深列表仅需要22个字的堆空间:

2> erts_debug:size(efficiency_guide:kilo_byte()).
22

如果忽略共享,则可以使用erts_debug:flat_size/1 BIF来计算深层列表的大小。也是其被已发送到另一个进程或存储在Ets表中时的实际大小

3> erts_debug:flat_size(efficiency_guide:kilo_byte()).
4094

可以验证如果将数据插入到Ets表中,共享将会丢失:

4> T = ets:new(tab, []).
#Ref<0.1662103692.2407923716.214181>
5> ets:insert(T, {key,efficiency_guide:kilo_byte()}).
true
6> erts_debug:size(element(2, hd(ets:lookup(T, key)))).
4094
7> erts_debug:flat_size(element(2, hd(ets:lookup(T, key)))).
4094

在erlang未来版本中,可能会把共享保存下来

8.3 SMP虚拟机

SMP虚拟机(在R11B中引入)通过运行多个Erlang调度程序线程(通常与内核数相同)来利用多核或多CPU计算机。每个调度程序线程都以与非SMP中的Erlang调度程序相同的方式调度Erlang进程

为了通过使用SMP获得性能,您的应用程序在大多数情况下必须具有多个可运行的Erlang进程。否则,Erlang虚拟机当时仍只能运行一个Erlang进程,但是您仍然必须支付锁的开销。尽管Erlang/OTP试图尽可能减少锁开销,但不可能是零。

看起来是并发,实际上还是有顺序的。通常都是一个进程处于活动,其他进程在等待

9 驱动程序

本节简要概述如何编写高效的驱动程序。假定你对驱动程序有很熟悉

9.1 并发

在运行驱动程序中的任何代码之前,运行时系统会先上锁 默认是在驱动程序端上锁,即若有多个端口指向同一个驱动程序,那么只能有一个端口在执行

可以将驱动程序配置为每个端口具有一个锁。

如果以功能方式使用驱动程序(即不保持任何状态,而仅进行一些繁重的计算并返回结果),则可以预先注册多个端口,并可以根据以下情况选择要使用的端口:

-define(PORT_NAMES(),
{some_driver_01, some_driver_02, some_driver_03, some_driver_04,
some_driver_05, some_driver_06, some_driver_07, some_driver_08,
some_driver_09, some_driver_10, some_driver_11, some_driver_12,
some_driver_13, some_driver_14, some_driver_15, some_driver_16}).

client_port() ->
element(erlang:system_info(scheduler_id) rem tuple_size(?PORT_NAMES()) + 1,
?PORT_NAMES()).

如此,只要CPU没有超过16核,则每个端口都不会有锁竞争的问题

9.2 避免复制bianry

基本上有两种方法可以避免给驱动程序发送复制的binary

  • 如果port_control/3的Data参数是binary,则将向驱动程序传递指向bianry的指针,并且不会复制binary。如果Data参数是iolist(binary和list的列表),则将复制iolist中的所有binary。
    因此,如果想将既有的binary和一些额外的数据发送给驱动程序而不复制binary,则必须调用port_control/3两次;一次使用binary,一次使用额外数据。但是,这仅在只有一个进程与端口进行通信时才起作用(因为另一个进程可以在调用之间调用驱动程序)。

  • 实现outputv回调(而不是output回调)。若有outputv回调,Data中的iolist的refc binaries会被 port_command/2转换成引用传给驱动程序

9.3 返回较小的binary

运行时系统可以将最多64个字节的binary表示为heap binary。它们在发送消息时总是被复制,但是如果不将它们发送到其他进程,将更省内存。GC也更轻便

若你知道返回值比较小,建议你使用不需要预先分配binary的函数,如driver_output() 或 erl_drv_output_term(),使用ERL_DRV_BUF2BINARY格式,以允许运行时系统构建heap binary

9.3 返回非复制的大binary

为了避免在将大型binary从驱动程序发送或从驱动程序返回到Erlang进程时复制数据,驱动程序必须首先分配binary,然后以某种方式将其发送到Erlang进程。

以下是使用driver_alloc_binary()发送binary的方法:

  • set_port_control_flags()被调用且使用PORT_CONTROL_FLAG_BINARY标记

  • 单个binary可以被driver_output_binary()发送

  • 使用erl_drv_output_term() or erl_drv_send_term(),binary可以被包含在erlang变量中

10 更进一步

10.1 内存

有效编程的一个好的开始是知道不同数据类型和操作需要多少内存。下面是Erlang一些数据类型在实际运用中所占的内存,仅针对erts-8.0 OTP 19.0.

单位是字,32位系统中1字=4B,64位中1字=8B

数据类型 内存大小
small integer 1字
32位系统取值范围-134217729 < i < 134217728 (28 bits).
64位系统 -576460752303423489 < i < 576460752303423488 (60 bits).
large integer 3 ~ N 字
atom 1字 
原子引用到原子表中,原子表也消耗内存。对于此表中的每个唯一原子,原子文本存储一次。原子表不会被GC
Float 32位:4字
64位:3字
Binary 3 ~ 6字 + Data(可以被共享)
List 1字 + 每元素1字 + 每个元素的大小
String 1字 + 每字符2字(相当于一个数字列表)
Tuple 2字 + 每个元素的大小
small Map 5字 + 所有key,value的大小
Large Map(key>32) N*F字 + 所有key,value的大小
N=Map的key数量 
F=稀疏因子 1.6~1.8 (由于HAMT结构的随机性)
Pid 本地进程标识符1字+其他节点进程标识5字 
进程标识被进程表、节点表引用,也会消耗内存
Port 本地标识1字+其他节点标识5字 
被端口表、节点表引用,也会消耗内存
Reference 32位:本地5字+其他节点7字
64位:本地4字+其他6字 
被节点表引用
Fun 9~13字+本身大小 
被函数表引用
Ets Table 初始768字 + 每行(6字+实际结构大小)
Process 初始338字(初始堆占233字)

10.2 系统限制

Erlang语言规范对进程数、原子数等没有限制,但是出于性能和内存节省的原因,在Erlang实际实现中始终会受到限制

限制项 说明
Processes 默认存活进程上限为262,144,可以在启动节点是通过+P改变
Known nodes 上限等同于原子上限,节点断掉时除了节点名,其他都会被GC
Connected nodes min(原子上限,port上限, socket上限)
Characters in an atom 255
Atom 默认1,048,576,可以通过启动参数 +t 修改
Elements in a tuple 元组元素数量上限=16,777,215(24bit无符号整型)
Binary 32位:536,870,911B
64位:2,305,843,009,213,693,951B
如果超出限制,则位语法构造会失败,并抛出system_limit异常,而任何尝试匹配太大的binary也会失败。此限制从R11B-4开始实施。
在早期的Erlang/OTP版本中,对太大的binary进行操作通常会失败或给出错误的结果。在将来的版本中,其他创建二进制文件的操作(例如list_to_binary/1)也可能会实施相同的限制。
Total amount of data allocated by an Erlang node erlang节点能分配的数据总量
理论上可以用完32/64位的所有地址,但系统进程一般会有所限制
Length of a node name 节点名实际上就是一个原子,所以等于原子的字符上限 255
Open ports 默认可以同时打开的Erlang端口的最大数量16,384。可以通过启动参数+Q修改
Open files and sockets min(开放端口限制,操作系统限制)
Number of arguments to a function or fun 函数的参数个数限制:255
Unique References on a Runtime System Instance 唯一引用标记上限
每个调度程序线程都有自己的一组引用,其他进程共享一组引用,每组引用包括2^64-1个唯一引用,所以总上限 = (调度程序数+1)*(2^64 - 1)。
如果调度程序线程每纳秒创建一个新引用,则引用将在584年后被重用。即引用标记是唯一的

11 性能

11.1 不要妄测性能

即使是经验丰富的软件开发人员,也常常会猜错程序中性能瓶颈的位置。因此,要分析程序,查出性能瓶颈,并集中精力进行优化。

Erlang提供了几种可以查找瓶颈的工具

  • fprof 提供关于程序把时间消耗在哪的信息,但会降低被观察的进程的性能

  • eprof 提供程序中使用的每个函数的时间信息。没有生成调用图,所以eprof对它观察的程序的影响要小得多。
    如果程序太大,无法通过fprof或eprof进行分析,可用cprof替代

  • cprof是最轻量级的工具,但是它仅按功能提供执行计数(对于所有进程,而不是每个进程)。

  • dbg是erlang的断点调试器。通过使用timestamp或cpu_timestamp选项,可用于计算实时系统中函数调用所花费的时间。

  • lcnt用于查找锁竞争。当在进程,端口,ets表和其他可以并行运行的实体之间的交互中查找瓶颈时,此方法很有用

下面是一些非官方的性能查找工具

  • erlgrind 可视化查看fprof产生的数据

  • eflame是fprof的替代方法,可将配置文件输出显示为火焰图。

  • recon 是Erlang分析和调试工具的集合。该工具随附一本名为Anlang的Erlang电子书

11.2 内存调优

eheap_alloc: Cannot allocate 1234567890 bytes of memory (of type "heap").

以上是erlang虚拟机挂掉的常见错误之一。出于未知原因,导致无法再分配内存。挂掉时会产生报告文件,用crashdump_viewer可以看到到底使用了多少内存,有哪些内存很大、或消息很多的进程,或者是很大的ets表

在虚拟机运行时,可以用erlang:memory()查看内存使用情况,instrument(3)则可更详细地了解内存的使用。

可以用erlang:process_info/2 , erlang:port_info/2 and ets:info/1查看内存、端口、ets表所占用的内存

有时用erlang:memory(total)看到的内存总量,跟操作系统中显示的会不一样,这可能是Erlang虚拟机本身有内存碎片。可以用erlang:system_info(allocator). 检查内存分配器的数据。这个数据可能过于原始难懂,可以用recon_alloc代替。

11.3 大型系统

对于大型系统,通常都要在模拟环境下做性能分析。但瓶颈通常出现在多节点或多数程序同时运行时,因此最好在真实的生产环境中做性能分析。

对于大型系统,你可能不想在整个系统上运行性能分析工具。相反,您希望专注于执行中占很大比例的中央流程和模块。

也可以使用一些工具查看整个系统,尽管会有一些额外消耗

  • observer 可视化界面,可以连接到远程节点并显示有关正在运行的各种信息

  • etop 一个命令行工具,可以连接到远程节点,类似linux的top

  • msacc 允许用户查看Erlang运行时系统正在花费的时间。具有非常低的开销,这使它在重负载的系统中运行很有用,以使您了解从哪里开始进行更详细的性能分析。

11.4 分析

分析性能分析的文件,找出最耗时和执行次数最多的函数。关注经常调用的函数,因为经常重复,即使很小的事情也可能累加很多。要问自己,可以采取什么措施来减少这种时间。应该常常反问自己:

  • 能否减少函数的调用次数?

  • 调用次数是否与测试的逻辑顺序有关?

  • 能否删除多余的测试?

  • 每次计算的结果都相同或符合预期?

  • 有没有同样可以达到的方法且更高效?

  • 能否使用其他类型数据结构来更高效实现?

这些问题并非总是微不足道的。一般理论都要有基准支持,否则难以保持它是否是错的,是否会导致低效

11.5 工具

工具 结果 结果大小 对系统执行的影响 记录调用次数 记录执行时间 记录被谁调用 记录GC
fprof 每个进程的信息 运行缓慢 累计跟单独
eprof 每个进程函数的信息 稍微减缓 累计
cprof 每个模块 稍微减缓

cprof 模块函数调用次数 例子

11.6 基准测试

基准测试的主要目的是找出给定算法或函数的快慢。基准测试不是一门精确的科学。当今的操作系统通常运行难以关闭的后台任务,缓存和多个CPU内核不利于基准测试。在进行基准测试时,最好以单用户模式运行UNIX计算机,但这对临时测试来说不方便。

  • timer:tc/3 测量挂钟时间。挂钟时间的优点是包括了操作系统内核中的I/O交换和其他活动。缺点是测量值变化很大。通常多次运行并注意最短的时间,这是在最佳情况下可以实现的最短时间。

  • statistics/1 使用参数runtime时,意味花费的CPU时间。CPU时间的优点是每次运行的结果更加一致。缺点是不包括在操作系统内核中花费的时间(例如交换和I/O)。因此,如果涉及任何I/O(文件或套接字),则测量CPU时间会产生误导。

测花费时间时最好两者都测

最后的建议:

  • 两种测量类型的粒度都可能很高。因此,请确保每个单独的测量持续至少几秒钟。

  • 为了使测试公平,每个测试用例都应该在独立的进程,否则,如果所有测试都在同一进程中,则后面的测试将从较大的堆大小开始,因此可能执行较少的垃圾回收。更较真点,还可以考虑在每个测试之间重新启动Erlang节点。

  • 不要擅自认为在计算机A上执行最快,在B上也能最快

12 过期传说

我们相信真相迟早会纠正传说

12.1 函数变量很慢

Funs(函数变量,Funs=fun()-> code end)过去非常慢,比apply/3慢。最初,Funs的实现只不过是编译器的技巧。

但这是历史。在R6B中为Funs提供了自己的数据类型,并在R7B中对其进行了进一步优化。现在,一个Funs的调用成本大致介于调用本地函数和apply/3之间。

12.2 List遍历很慢

列表遍历过去通常是用Funs来实现的,在过去,Funs确实很慢。

如今,编译器将列表遍历重写为普通的递归函数。尾递归处理列表后再反转会更快。这就引出了另一个传闻,即尾递归函数比递归函数快。

12.3 List相减(--)很慢

列表减法曾经具有与操作数长度乘积成比例的运行时复杂度,因此,当两个列表都较长时,它的运行速度非常慢。

从OTP 22开始,运行时复杂度为“ n log n”,即使两个列表都很长,操作也将快速完成。实际上,把列表转换成order set,然后用ordsets:subtract/2,会更快更省内存

笔记

  1. 运行时系统会优化bianry的拼接,尽可能的复用已分配的内存空间,减少多次构造新bianry对象的行为

  2. 每次创建binary时,其分配的空间大小=max(两倍size, 256B);但是当其作为消息发送或进行垃圾回收时,其多余的空间会被回收

  3. 优先尾递归实现

  4. 多个分支匹配间少用变量

  5. 变量要尽量复用,而不是每次用到都去构建新变量

  6. 不要用tab2list,ets搜索函数优先select -> match -> match_object

  7. 函数调用效率顺序 local call > Fun() apply(Fun, []) > M:F(),apply(M,F,[]) > apply(M, F, Arg)

  8. 尾递归 = 最后一句代码为调用自己


以上是关于erlang 效率指南的主要内容,如果未能解决你的问题,请参考以下文章

获取 badarith,[erlang,'+',[error,0],[],同时使用 Erlang 片段在 TSUNG 中执行算术运算

markdown 打字稿...编码说明,提示,作弊,指南,代码片段和教程文章

Erlang 入坑指南

新书推荐—《Erlang趣学指南》

VsCode 代码片段-提升研发效率

2.Erlang语言精要