当使用HTTPS协议推送代码到Git仓库时,发现每次都需要输入密码,操作起来非常麻烦。下面介绍几种免去输入密码的方法。
关于erlang-mysql-driver timeout的bug分析
我们游戏之前使用的是erlang-mysql-driver来连接数据库,经常会碰到一些timeout和一条纪录被重复插入多次的bug,后面把erlang-mysql-driver替换成emysql就没有问题了。研究了下erlang-mysql-driver的源代码才知道具体的问题出在哪里,下面就简单的介绍下这个问题,同时介绍下emysql是如何避免这个问题的
erlang-mysql-driver 用法
1 | mysql:start_link(DB, Pool, Server, Port, User, Passwd, DBName, fun mysql_log/4, utf8), |
使用erlang-mysql-driver的时候首先要用mysql:start_link来建立一个连接池,然后再自己调用mysql:connect来建立多个连接。对应的erlang-mysql-driver库里面会创建一个名为DB的连接池gen_server,然后再建立多个mysql数据库的连接,每个连接会有一个进程来接管,并把这些进程和连接信息放入连接池gen_server中。按道理我们建立了多个数据库的连接,在进行数据库操作的时候应该能够并发访问数据库的,确实erlang-mysql-driver实现也是多进程访问数据库的,但是由于连接池gen_server的单点瓶颈,会导致一些事实上成功的操作被认为是失败的。
erlang-mysql-driver 的sql执行过程
在我们执行一个mysql:execute的函数的时候,具体的执行过程如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27%% mysql.erl 文件中的代码
execute(SvrName, PoolId, Name, Params, Timeout) ->
case get(?STATE_VAR) of
undefined ->
call_server(SvrName, {execute, SvrName, PoolId, Name, Params}, Timeout);
State ->
case mysql_conn:execute_local(SvrName, State, Name, Params) of
{ok, Res, NewState} ->
put(?STATE_VAR, NewState),
Res;
Err ->
Err
end
end.
handle_call({execute, SvrName, PoolId, Name, Params}, From, State) ->
with_next_conn(PoolId, State,
fun(Conn, State1) ->
case gb_trees:lookup(Name, State1#state.prepares) of
none ->
{reply, {error, {no_such_statement, Name}}, State1};
{value, {_Stmt, Version}} ->
mysql_conn:execute(SvrName, Conn#conn.pid, Name,
Version, Params, From),
{noreply, State1}
end
end);
1 | %% mysql_conn的代码 |
mysql:execute在执行的过程中首先调用名为DB的连接池gen_server,该gen_server会执行with_next_conn选择一个持有数据库连接的进程(进程x),然后通过mysql_conn中的send_msg函数向进程x发送需要执行的sql语句,sql语句发送成功后gen_server会返回noreply,这时调用mysql:execute的进程会一直阻塞,直到进程x执行gen_server:reply来返回结果。
通过上面的执行过程我们可以知道以下两点:
- mysql:execute的timeout为gen_server:call调用的timeout时间
- 连接池gen_server只要把sql语句发送给进程x,进程x就会去执行(可能执行的比较慢,但是已经加入进程x的信箱)
现在我们可以知道mysql:execute返回timeout的情况有两种:
- 连接池gen_server太过繁忙,mysql:execute的请求还没执行,mysql:execute就已经timeout
- 连接池gen_server已经成功执行请求,返回noreply,mysql:execute的执行进程一直在等待进程x的返回,而进程x一直不返回,这时mysql:execute触发timeout
不管是上述那种timeout情况,只要是mysql数据库没有问题,sql语句都能够执行成功(可能会执行的慢点)。mysql:execute的调用者在发现mysql:execute返回timeout的情况下,肯定会认为sql语句没有执行成功,这时候会重新调用mysql:execute,导致一条相同的记录被多次插入。
emysql 的sql执行过程
emysql的连接池也会有一个gen_server进行管理,emysql:execute在执行的过程中是去向该gen_server申请一个可用的连接,然后再spawn一个进程来执行sql语句,而不是委托该gen_server来执行sql语句,从而避免了这个timeout的bug。
总结
通过上面的分析,我觉得erlang-mysql-driver会写出这个bug的原因主要是对gen_server noreply的误用。所以以后如果有需要用noreply的话,要注意避免该问题。
efficiency-guide:进程
这篇文章主要介绍进程的堆大小的优化,以及模块内的常量池,如果之前没有看过的,值得一看。
创建一个进程
与操作系统中的线程和进程相比,Erlang进程是轻量级的。 新创建的Erlang进程在非SMP和非HiPE模式下中使用309个字的内存。(SMP和HiPE支持会增加一些内存)进程占用的内存大小可以通过以下方式得到:
Erlang (BEAM) emulator version 5.6 [async-threads:0] [kernel-poll:false] Eshell V5.6 (abort with ^G) 1> Fun = fun() -> receive after infinity -> ok end end.
#Fun<…> 2> {_,Bytes} = process_info(spawn(Fun), memory). {memory,1232} 3> Bytes div erlang:system_info(wordsize). 309
此大小包括堆区域(包括堆栈)的233个字。垃圾收集器根据需要增加堆。
进程的主(外)循环必须是尾递归的。否则,堆栈会一直增长直到进程终止。
1
2
3
4
5
6
7
8
9
10
11
12%% 不要这样做
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", []).
对io:format/2的调用永远不会被执行,但是每次loop/0被递归调用时,返回地址仍然被推送到堆栈。该函数的正确尾递归版本如下所示:
1
2
3
4
5
6
7
8
9
10loop() ->
receive
{sys, Msg} ->
handle_sys_msg(Msg),
loop();
{From, Msg} ->
Reply = handle_msg(Msg),
From ! Reply,
loop()
end.
初始堆大小
对于具有数十万甚至数百万个进程的Erlang系统,默认的初始堆大小为233个字节是相当保守的。垃圾收集器根据需要增加和收缩堆。 在使用相对较少进程的系统中,可以通过使用erl的+h选项或使用spawn_opt/4的min_heap_size选项在每个进程的基础上增加最小堆大小来提高性能。主要有以下两个好处:
- 虽然垃圾收集器可以逐步增长堆的大小,但这比生成进程时直接建立更大的堆来的低效。
- 垃圾收集器还可以收缩堆,如果它比存储在其上的数据量大得多;设置最小堆大小可以防止这种情况发生。
注意:由于虚拟机可以使用更多的内存,则内存回收的次数将减少,因此Binary可以被保存的更久才进行内存回收
在具有许多进程的系统中,运行时间很短的计算任务可以生成具有更大最小堆大小的新进程。当进程完成后,它将计算结果发送到另一个进程并终止。如果正确计算最小堆大小,则该过程可能根本不需要执行任何垃圾回收。如果没有进行适当的测量,则不进行此优化。
进程消息
除了在同一个Erlang节点上的refc binary,Erlang进程之间的所有消息都会被复制。 当消息发送到另一个Erlang节点上的进程时,它首先被编码为Erlang外部格式,然后通过TCP/IP套接字发送。接收的Erlang节点解码消息并将其分发到相应的进程。
常量池(Constant Pool)
Erlang常量(也称为文字(literals))保存在常量池中;每个加载的模块都有自己的池。以下函数不会在每次调用时构建元组(仅在下次运行垃圾回收时丢弃它),因为元组位于模块的常量池中:
1
2days_in_month(M) ->
element(M, {31,28,31,30,31,30,31,31,30,31,30,31}).
但是如果一个常量被发送到另一个进程(或存储在一个Ets表中),那么它将被复制。原因是运行时系统必须能够跟踪所有对常量的引用,以正确卸载包含常量的代码。(当代码被卸载时,这些常量被复制到引用它们的进程的堆中。)复制常量可能在将来的Erlang/OTP版本中被删除。
共享丢失
在以下情况下,共享不会保留:
- 当一个共享变量被发送到另一个进程
- 当一个共享变量作为spawn调用中的初始进程参数传递时
- 当共享变量被存储在Ets表中时
这是一个优化。大多数应用不发送带有共享变量的消息。
以下示例显示如何创建共享变量:
1
2
3
4
5
6
7kilo_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,则可以将嵌套列表转换为1024字节的Binary:
1> 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
当数据被插入到Ets表时,erts_debug:size/1和erts_debug:flat_size/1返回相同的值。共享已经丢失。 在未来的Erlang/OTP版本中,可能会实现一种方式(可选)保留共享。
SMP
SMP模式(在R11B中引入)通过运行多个Erlang调度器线程(通常与内核数相同)来利用多核计算机的性能。每个调度器线程以与非SMP模式中的Erlang调度器相同的方式调度Erlang进程。
为了通过使用SMP模式获得更多的性能提升,应用程序大多数时候都必须有多个可运行的Erlang进程。否则,Erlang虚拟机仍然只能运行一个Erlang进程,但是仍然必须增加锁的开销。尽管Erlang/OTP尝试尽可能减少锁开销,但它永远不会变为零。
efficiency-guide:表和数据库
这篇文件主要讲一些简单的Erlang数据库注意要点,干货好像没那么多,可以快速浏览下。
Ets、Dets、Mnesia
每个Ets的例子都有一个与之对应的Mnesia的例子。一般来说,所有Ets示例也适用于Dets表。
Select/Match操作
在Ets和Mnesia表上的Select/Match操作是非常低效的。他们通常需要扫描整个表。需要优化数据的结构来减少对Select/Match操作的使用。但是,如果确实需要Select/Match操作,它仍然比使用tab2list更有效率。函数ets:select/2和mnesia:select/3是优于ets:match/2、ets:match_object/2和mnesia:match_object/3的。
在某些情况下,Select/Match操作是不需要扫描整张表的。例如,在ordered_set的表中搜索,或者搜索的Mnesia表的字段是有建立索引的。
当创建要在Select/Match操作中使用的记录时,如果希望大多数字段具有值“_”。最简单和最快捷的方法如下:
1
#person{age = 42, _ = '_'}.
删除一个元素
如果元素不存在于表中,则删除操作被认为是成功的。因此,在删除之前,所有尝试检查元素是否存在于Ets / Mnesia表中都是不必要的。以下是Ets表的示例:
1
2
3
4
5
6
7
8
9
10
11
12
13%% 直接删就可以了
...
ets:delete(Tab, Key),
...
%% 这样做,效率低而且没有意义
...
case ets:lookup(Tab, Key) of
[] ->
ok;
[_|_] ->
ets:delete(Tab, Key)
end,
...
非持久数据库存储
对于非持久数据库存储,优先考虑Ets表,而不是Mnesia local_content表。与Ets写入相比,即使是Mnesia dirty_write操作具有常量的开销。 Mnesia也必须检查表是否被复制或具有索引,这涉及每个dirty_write至少一次Ets查找。因此,Ets总是比Mnesia写的快。
tab2list
简单的说tab2list肯定不要用,除非是需要返回所有的Ets数据。如果需要选择一部分的数据可以用Select/Match操作,因为获取Ets的数据是需要拷贝的,tab2list返回所有的数据,需要大量的拷贝,效率非常低。
ordered_set表
ordered_set仅保证按Key的顺序处理对象。即使Key不包含在结果中,也可以按Key顺序显示ets:select/2等函数的结果(还有select,match_object,foldl,first,next等函数)。
优化掉Select/Match操作
Ets
由于Ets用Key来Lookup获取数据是可以在常量的时间内完成的(使用哈希和树结构),所以如果要优化掉Select操作,可以再建一个要Select的字段到先前的Ets的Key的新的Ets表就可以了,这样通过两次lookup就可以取得想要的数据了。
Mnesia
在Mnesia中只要多建立一些想要Select字段的索引就可以,这样就不用扫描整张表了。
efficiency-guide:函数
本篇文章主要介绍函数的模式匹配和调用,函数的模式匹配优化还是有必要了解一下的。
模式匹配
在函数的头部以及case和receive子句中的模式匹配都会被编译器优化。但是有一些例外,重新排列匹配子句没有什么好处。
Binary的模式匹配就是一个例外。编译器不重新排列与Binary相匹配的子句。最后放置与空Binary匹配的子句通常比放置在第一个的子句执行的更快。
下面是另一种例外的例子:
1
2
3
4
5
6
7atom_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的子句。由于变量可以匹配任何内容,包括原子four、five、six,后续的子句也匹配,所以编译器必须生成次优代码,执行如下:
- 首先,将输入值与one、two、three(使用二分查找,即使有很多值也非常有效)来选择要执行的前三个子句中的哪一个(如果有的话)。
- 如果前三个子句中没有一个匹配,则第四个子句匹配变量的话会始终匹配。
- 如果测试is_integer(Int)成功,则执行第四个子句。
- 如果测试失败,则将输入值与four、five、six进行比较,并选择适当的子句。 (如果没有匹配成功,则会产生一个function_clause异常。)
如果想让匹配代码更加高效,则上面的代码可以重写成:
1
2
3
4
5
6
7atom_map2(one) -> 1;
atom_map2(two) -> 2;
atom_map2(three) -> 3;
atom_map2(four) -> 4;
atom_map2(five) -> 5;
atom_map2(six) -> 6;
atom_map2(Int) when is_integer(Int) -> Int.
或者:
1
2
3
4
5
6
7atom_map3(Int) when is_integer(Int) -> Int;
atom_map3(one) -> 1;
atom_map3(two) -> 2;
atom_map3(three) -> 3;
atom_map3(four) -> 4;
atom_map3(five) -> 5;
atom_map3(six) -> 6.
还有下面的例子:
1
2
3
4
5
6map_pairs1(_Map, [], Ys) ->
Ys;
map_pairs1(_Map, Xs, [] ) ->
Xs;
map_pairs1(Map, [X|Xs], [Y|Ys]) ->
[Map(X, Y)|map_pairs1(Map, Xs, Ys)].
第一个参数没有问题。它是在所有匹配子句都有的一个变量。有问题的是第二个参数在第二个匹配子句的变量Xs。因为Xs变量可以匹配任何东西,所以编译器不能重新排列匹配子句,而是必须按照上述代码的顺序生成与它们匹配的代码。
如果函数按如下方式重写,编译器就可以自由地重新排列子句:
1
2
3
4
5
6map_pairs2(_Map, [], Ys) ->
Ys;
map_pairs2(_Map, [_|_]=Xs, [] ) ->
Xs;
map_pairs2(Map, [X|Xs], [Y|Ys]) ->
[Map(X, Y)|map_pairs2(Map, Xs, Ys)].
编译器将生成与此类似的代码:
1
2
3
4
5
6
7
8
9
10
11
12explicit_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变量推导出更准确的类型。)
函数调用
以下是对不同函数调用类型的代价的粗略的估计。它是在Solaris/Sparc上运行测试出来的基准数据:
- 调用本地或外部函数(foo(),m:foo())是最快的。
- 调用或者apply调用一个匿名函数(Fun(),apply(Fun, []))的大概时间花费差不多是调用一个本地函数的三倍。
- Apply调用一个被导出的函数(Mod:Name(),apply(Mod, Name, [])),大概的时间花费差不多是调用匿名函数的两倍,也就是本地函数调用的六倍
注释和实现细节
调用和apply一个匿名函数不涉及任何的哈希表查找。一个匿名函数变量包含一个(间接的)指向匿名函数实现的函数的指针。
apply/3必须在哈希表中查找执行的函数的代码。因此,总是比直接调用或匿名函数调用来的慢。
它不再重要(从性能的角度来看)是否写成:
1
Module:Function(Arg1, Arg2)
或者:
1
apply(Module, Function, [Arg1,Arg2])
编译器会将后一种代码重写为前一种的方式。
以下代码会稍微慢一点,因为参数的个数在编译时是未知的。
1
apply(Module, Function, Arguments)
递归的内存使用
当编写递归函数时,最好使用尾递归的方式,以便它们可以在恒定的内存空间中执行:
最好这样写:
1
2
3
4
5
6
7
8list_length(List) ->
list_length(List, 0).
list_length([], AccLen) ->
AccLen; % Base case
list_length([_|Tail], AccLen) ->
list_length(Tail, AccLen + 1). % Tail-recursive
而不要这样写:
1
2
3
4list_length([]) ->
0. % Base case
list_length([_ | Tail]) ->
list_length(Tail) + 1. % Not tail-recursive
efficiency-guide:List处理
关于列表的处理,大多懂得Erlang的同学都差不多知道列表要怎么用,但是关于列表嵌套和拉伸的优化点还是值得一看的。
列表创建
不能使用如下代码创建列表,因为每次迭代都会创建一个新的列表:
1
2
3
4
5
6
7bad_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]).
应该使用如下的代码创建列表:
1
2
3
4
5
6
7tail_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]).
列表推导
列表推导现在仍然被认为是缓慢的。他们过去常常使用funs来实现,而funs过去很慢。
以下的列表推导:
1
[Expr(E) || E <- List]
会被转换成本地的函数实现:
1
2
3'lc^0'([E|Tail], Expr) ->
[Expr(E)|'lc^0'(Tail, Expr)];
'lc^0'([], _Expr) -> [].
如果列表推导的结果不会被使用,则不会构造列表。如下的代码:
1
2
3
4
5
6
7
8
9
10
11[io:put_chars(E) || E <- List],
ok.
%% 或者
...
case Var of
... ->
[io:put_chars(E) || E <- List];
... ->
end,
some_function(...),
...
上述的代码不会构造列表,所以转换成以下的本地函数实现:
1
2
3
4'lc^0'([E|Tail], Expr) ->
Expr(E),
'lc^0'(Tail, Expr);
'lc^0'([], _Expr) -> [].
编译器知道分配给’_’意味着该值不会被使用。因此,以下示例中的代码也将进行优化:
1
2_ = [io:put_chars(E) || E <- List],
ok.
嵌套和拉伸列表(Deep and Flat Lists)
lists:flatten/1比++操作更加的低效,在下述的情况中,可以很简单的避免使用lists:flatten/1:
- 向端口发送数据时。端口了解嵌套列表,所以没有理由在将列表发送到端口之前拉伸列表。
- 当调用接受嵌套列表的BIF时,例如list_to_binary/1或iolist_to_binary/1。
- 当知道列表只有一级嵌套时,可以使用list:append/1。
端口例子
1 | ... |
通常会这样向端口发送一个以0为结尾的字符串:
1
2
3
4...
TerminatedStr = String ++ [0], % String="foo" => [$f, $o, $o, 0]
port_command(Port, TerminatedStr)
...
上述效率比较低,应该用下述方式代替:
1
2
3
4...
TerminatedStr = [String, 0], % String="foo" => [[$f, $o, $o], 0]
port_command(Port, TerminatedStr)
...
Append例子
1 | lists:append([[1], [2], [3]]). %% DO |
递归列表函数
普通递归列表函数和尾部递归函数在结束的时候反转列表之间通常没有太大差异。因此,专注于编写好看的代码,并忘记了列表功能的性能。在代码的性能关键部分(仅在那里),用比较高效的写法就行了。
这部分是关于构造列表的列表函数。不构造列表的尾递归函数运行在常量空间中,而相应的普通递归函数使用与列表长度成比例的堆栈空间。
例如,一个将整数列表相加的函数不能写成如下:
1
2recursive_sum([H|T]) -> H+recursive_sum(T);
recursive_sum([]) -> 0.
应该写成:
1
2
3
4sum(L) -> sum(L, 0).
sum([H|T], Sum) -> sum(T, Sum + H);
sum([], Sum) -> Sum.
efficiency-guide:Binary的构建和匹配
这篇文章非常详细的介绍了Binary是怎么样构造和匹配的,同时介绍了一些优化的技巧,没看过的一定要仔细看下。
以下代码可以高效的构建Binary(有点奇怪,和List不一样,下面构造Binary的段落会有解释):
1
2
3
4
5
6
7my_list_to_binary(List) ->
my_list_to_binary(List, <<>>).
my_list_to_binary([H|T], Acc) ->
my_list_to_binary(T, <<Acc/binary,H>>);
my_list_to_binary([], Acc) ->
Acc.
以下代码可以高效的匹配Binary:
1
2
3my_binary_to_list(<<H,T/binary>>) ->
[H|my_binary_to_list(T)];
my_binary_to_list(<<>>) -> [].
Binary类型是怎么实现的?
Binary和Bitstring在虚拟机的内部实现是一样的。在虚拟机的源代码中都叫做Binary。Binary类型在虚拟机内部由四种Binary对象实现:
Refc Binaries Refc Binaries由两部分组成:
- 存储在进程堆上的对象,称为ProcBin
- Binary对象本身,它存储在所有进程堆之外
Binary对象可以由任意数量的进程引用任意数量的ProcBin。该对象包含一个引用计数器,用于跟踪引用数量,以便在最后一个引用消失时可以将其删除。进程中的所有ProcBin对象都是链表的一部分,因此当ProcBin消失时,垃圾回收器可以跟踪它们并减少二进制中的引用计数器。当构建的Binary大于64Byte的时候就会使用这种类型,此时进程之间发送Binary只是发送一个ProcBin。
- Heap Binaries Heap binaries是小型Binary,最多64Byte,并直接存储在进程堆中。当Heap Binary被进程垃圾回收或者是作为消息发送时,都需要被复制。垃圾收集器不需要特殊处理。
- Sub Binaries Sub Binaries和match contexts对象能引用refc binary和heap binary对象的部分内容。 Sub Binary是由split_binary/2创建的。一个Sub Binary引用另一个Binary的部分内容(只能引用Refc和Heap Binary,不能引用另一个Sub Binary)。因此,匹配一个Binary类型是比较高效的,因为实际的Binary数据是不会被复制的。
- Match Context Match context和Sub binary比较类似,但是Match context专门为Binary匹配优化。(原文比较拗口,这里不做解释了,我也没太看懂)
构建Binary
Binary和Bitstring的append操作是被运行时系统特别优化的,只有在极少数的情况下优化是不起作用的。
如下代码可以解释优化是如何起作用的:
1
2
3
4
5
6Bin0 = <<0>>,
Bin1 = <<Bin0/binary,1,2,3>>,
Bin2 = <<Bin1/binary,4,5,6>>,
Bin3 = <<Bin2/binary,7,8,9>>,
Bin4 = <<Bin1/binary,17>>,
{Bin4,Bin3}
- 第1行分配一个Heap Binary给Bin0变量
- 第2行是append操作。由于Bin0没有涉及append操作,所以创建一个新的Refc Binary,并将Bin0的内容复制到其中。Refc Binary的ProcBin部分存有Binary对象的数据大小,而Binary对象还分配了额外的空间。Binary对象的大小是Bin1或256的大小的两倍,以较大者为准。在这个例子中是256。
- 第3行更有意思。Bin1已被用于append操作,最后有252字节的未使用内存,因此3个新的字节会被存储在这些空闲的内存中。
- 第4行。和第3行一样。剩下249个字节,所以存储另外3个新字节没有问题。
- 第5行。有趣的事情发生。请注意,Bin4是用Bin1来append值17。Bin4将被赋值为<<0,1,2,3,17>>。Bin3将保留其价值<<0,1,2,3,4,5,6,7,8,9>>。显然,运行时系统不能将字节17写入上述的Refc Binary中,因为这会将Bin3的值更改为<<0,1,2,3,4,17,6,7,8,9>>。 运行时系统知道Bin1是先前append操作的结果,所以它将Bin1的内容复制到一个新的Binary,预留额外的存储空间等等类似上面的操作。(这里没有解释为什么运行时系统知道不能写入到Bin1中,如果有兴趣的话可以阅读erl_bits.c源代码)
强制拷贝的情况
Binary的append操作优化要求对于Binary:只有一个ProcBin指向一个Refc Binary的Binary对象。原因是优化需要在append操作期间移动(重新分配)Binary对象,并且同时更新ProcBin中的指针。如果有多个ProcBin指向Binary对象,则无法找到并更新它们。
因此,对Binary的某些操作会被做标记,以便在将来做append操作的时候知道是否要强制拷贝Binary。在大多数情况下,Binary额外分配的空间也会在这个时候也被回收掉。
如果将Binary作为消息发送到其他进程或端口,则binary对象会缩小,任何进一步的append操作都会将Binary数据复制到新的Binary中。例如,在下面的代码,第3行的Bin1将会被复制:
1
2
3Bin1 = <<Bin0,...>>,
PortOrPid ! Bin1,
Bin = <<Bin1,...>> %% Bin1 will be COPIED
同样的情况一样会发生,如果将Binary插入到Ets表中、使用erlangport_command/2将其发送到端口、或者将其传递给NIF中的enif_inspect_binary。
匹配Binary也会导致其缩小,下一个append操作将会复制Binary数据:
1
2
3Bin1 = <<Bin0,...>>,
<<X,Y,Z,T/binary>> = Bin1,
Bin = <<Bin1,...>> %% Bin1 will be COPIED
原因是match context包含直接指向二进制数据的指针。 如果一个进程简单地保留Binary(在“循环数据”或进程字典中),垃圾回收器最终可以收缩这个Binary。如果只保留一个这样的Binary,则不会收缩。如果该进程后续append到已收缩的Binary中,则Binary对象将被重新分配,以使数据被加上。
匹配Binary
重新看下文章开头的匹配例子:
1
2
3my_binary_to_list(<<H,T/binary>>) ->
[H|my_binary_to_list(T)];
my_binary_to_list(<<>>) -> [].
第一次调用my_binary_to_list/1时,会创建match context。match context指向Binary的第一个字节。匹配1个字节,并更新match context以指向Binary的第二个字节。
在此时,创建一个sub binary似乎是有意义的,但是在这个特定的例子中编译器知道每次匹配后会马上调用一个函数(在这个例子中,是my_binary_to_list/1本身),这会导致要创建一个新的match context然后丢弃sub binary。
因此,my_binary_to_list/1使用match context而不是使用sub binary调用自身。初始化匹配操作的指令当它看到它被传递给match context而不是sub binary时基本上什么都不做。
当到达Binary的末尾并且第二个子句匹配时,match context将被简单地丢弃(在下一个垃圾回收中被移除,因为不再有任何引用)。
总而言之,my_binary_to_list/1只需要创建一个match context,而不需要sub binary。
注意,当遍历完整个Binary后,my_binary_to_list/1中的match context被丢弃。如果迭代在Binary结束之前停止,会发生什么?优化还会生效吗?
1
2
3
4
5
6after_zero(<<0,T/binary>>) ->
T;
after_zero(<<_,T/binary>>) ->
after_zero(T);
after_zero(<<>>) ->
<<>>.
答案是依然生效,编译器将在第二个子句中删除sub binary的构建:
1
2
3
4...
after_zero(<<_,T/binary>>) ->
after_zero(T);
...
但是它会生成在第一个子句中构建sub binary的代码:
1
2
3after_zero(<<0,T/binary>>) ->
T;
...
因此,after_zero/1将构建一个match context和一个sub binary(如果它传进一个包含0的binary)。
以下代码也将进行优化:
1
2
3
4
5
6all_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).
编译器将删除第二和第三个子句中的sub binary的构建,并向第一个子句添加一个指令,该指令将Buffer从match context转换成sub binary(如果Buffer已经是binary,则不执行任何操作)。
在开始认为编译器可以优化任何Binary模式匹配之前,以下函数不能由编译器进行优化(至少当前是这样的):
1
2
3
4
5
6non_opt_eq([H|T1], <<H,T2/binary>>) ->
non_opt_eq(T1, T2);
non_opt_eq([_|_], <<_,_/binary>>) ->
false;
non_opt_eq([], <<>>) ->
true.
之前提到,如果编译器知道Binary不会被共享,则会延迟创建sub binary。在当前的这种情况下,编译器无法知道。 很快,下面的章节将解释如何重写non_opt_eq/2,以便可以应用延迟sub binary的优化,更重要的是,还能发现你的代码是否可以优化。
bin_opt_info选项
使用bin_opt_info选项可以让编译器打印大量有关二进制优化的信息。
erlc +bin_opt_info Mod.erl
请注意,bin_opt_info不能是能永久添加到Makefile中的选项,因为它生成的所有信息都不能被删除。因此,通过环境选择在大多数情况下是最实际的方法。
为了更准确地说明警告所引用的代码,以下示例中的警告将作为注释引用到它们所引用的子句之后插入,例如:
1
2
3
4
5
6
7
8after_zero(<<0,T/binary>>) ->
%% NOT OPTIMIZED: sub binary is used or returned
T;
after_zero(<<_,T/binary>>) ->
%% OPTIMIZED: creation of sub binary delayed
after_zero(T);
after_zero(<<>>) ->
<<>>.
上述代码说明第一个匹配没有优化,第二个匹配会被优化。
让我们重新回顾一下无法优化的代码的例子,并找出原因:
1
2
3
4
5
6
7
8
9
10
11
12non_opt_eq([H|T1], <<H,T2/binary>>) ->
%% INFO: matching anything else but a plain variable to
%% the left of binary pattern will prevent delayed
%% sub binary optimization;
%% SUGGEST changing argument order
%% NOT OPTIMIZED: called function non_opt_eq/2 does not
%% begin with a suitable binary matching instruction
non_opt_eq(T1, T2);
non_opt_eq([_|_], <<_,_/binary>>) ->
false;
non_opt_eq([], <<>>) ->
true.
编译器发出两个警告。INFO警告指的是函数non_opt_eq/2作为被调用者,表示任何调用non_opt_eq/2的函数都不能进行延迟sub binary优化。还有一个建议来改变参数顺序。第二个警告(恰好是指同一行)是指sub binary本身的构造。
下面的另一个例子将显示INFO和NOT OPTIMIZED警告之间的区别,这些警告有些清晰,但是让我们先来试一下改变参数顺序的建议:
1
2
3
4
5
6
7opt_eq(<<H,T1/binary>>, [H|T2]) ->
%% OPTIMIZED: creation of sub binary delayed
opt_eq(T1, T2);
opt_eq(<<_,_/binary>>, [_|_]) ->
false;
opt_eq(<<>>, []) ->
true.
编译器给出以下代码片段的警告:
1
2
3
4
5
6
7match_body([0|_], <<H,_/binary>>) ->
%% INFO: matching anything else but a plain variable to
%% the left of binary pattern will prevent delayed
%% sub binary optimization;
%% SUGGEST changing argument order
done;
...
这个警告意味着如果有一个对match_body/2的调用(来自match_body/2中的另一个子句或另一个函数),那么延迟的子二进制优化是不可能的。在二进制匹配结束处的任何地方将发生更多警告,并作为match_body/2的第二个参数传递,例如:
1
2
3
4match_head(List, <<_:10,Data/binary>>) ->
%% NOT OPTIMIZED: called function match_body/2 does not
%% begin with a suitable binary matching instruction
match_body(List, Data).
未使用的变量
编译器能够算出一个变量是否未被使用。然后为以下每个功能生成相同的代码:
1
2
3
4
5
6
7
8count1(<<_,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.
在每次迭代中,二进制中的前8位将被跳过,不匹配。
历史笔记
R12的Binary处理显着改善。因此R11B中执行高效的代码在R12B中可能不是那么高效,反之亦然,此efficiency-guide的较早版本包含了有关R11B中二进制处理的一些信息。
efficiency_guide:需要注意的模块和BIF
以下就是要注意的模块和BIF,这些内容之前大多数知道,就setelement/3和size/1的使用优化是不知道的,值得一读
Timer模块
使用erlang:send_after/3和erlang:start_timer/3创建定时器比使用Timer模块更有效率。Timer模块使用单独的进程来管理定时器。如果许多进程在同时创建和取消定时器,这个进程很容易成为瓶颈。此外Timer模块的有些函数是不通过单一的进程实现的(如定时器:tc/3或定时器:sleep/1),因此这些函数是可以放心使用的。
list_to_atom/1
Atom是不进行内存回收的,一旦一个Atom被创建,它永远不会被移除。如果Atom的数量到达虚拟机的上限(默认1,048,576)的话,虚拟机将会奔溃。
因此,在一个连续运行的系统中,将任意输入字符串转换成Atom是危险的。如果只允许某些定义好的原子作为输入,使用list_to_existing_atom/1可以用来防范拒绝服务攻击。
使用list_to_atom/1构建一个可以被传入apply/3函数的atom,效率是比较低的,不推荐在需要运行很快的代码中使用:
1
apply(list_to_atom(“some_prefix”+ + Var), foo, Args)
length/1
与tuple_size/1、byte_size/1和bit_size/1的O(1)时间复杂度不同,length/1执行的时间与List的长度成正比为O(n)
。通常,没有必要担心length/1的速度,因为它是用C语言很高效的实现的,但是如果List很长,为了避免O(n)的时间复杂度。在一些使用场景中length/1可以被模式匹配来代替,如下:
1
2foo(L) when length(L) >= 3 ->
...
可以被写成模式匹配的方式
1
2foo([_,_,_|_]=L) ->
...
上面两段代码的区别是:如果L不是的列表,length(L)将会出错,而第二段代码中将不能正确匹配。
setelement/3
setelement/3会复制其修改的tuple。因此,使用setelement/3循环更新一个tuple的不同字段,会每次创建一个tuple的新副本。
有一种情况是可以例外的,如果编译器清楚地知道,破坏性地更新tuple会产生tuple复制相同的结果,那么对setelement/3的调用将被替换为一个特殊的破坏性设置指令。在以下代码中,第一个setelement/3调用复制该tuple并修改第9个元素:
1
2
3
4multiple_setelement(T0) ->
T1 = setelement(9, T0, bar),
T2 = setelement(7, T1, foobar),
setelement(5, T2, new_value).
后面两个setelement/3调用将该复制的tuple的d第7和第5个元素也修改了,不再复制新的tuple。 要实现上述的优化,必须满足以下条件:
- 索引必须是整数文字,而不是变量或表达式。
- 索引必须按降序给出。
- 在连续的setelement/3调用之间不能有任何其他函数调用。
从一个setelement/3调用返回的元组只能在随后的setelement/3调用中使用。
如果代码不能像multi_setelement/1示例中那样被构造,那么修改大元组中的多个元素的最好方法是将元组转换为列表,修改列表,并将其转换回元组。
size/1
size/1可以用来返回tuple和binary的大小。但是如果使用tuple_size/1和byte_size/1的话,能为编译器和运行时系统提供了更多优化机会。另一个优点是能给了Dialyzer提供更多的类型信息。
split_binary/2
使用模式匹配而不是调用split_binary/2函数来分割二进制通常更有效率。此外,混合使用比特语法匹配和split_binary/2会使比特语法匹配的一些优化失效。
1
2<<Bin1:Num/binary,Bin2/binary>> = Bin %% 推荐
{Bin1,Bin2} = split_binary(Bin, Num) %% 不推荐
运算符 “- -“
“- -“ 运算符具有与其操作数长度乘积成比例的时间复杂度(O(m*n))。这意味着如果该操作符的两个操作数都是长列表,那么操作者非常慢:
1
2
3
4
5
6
7
8HugeList1 -- HugeList2
%% 上述操作应该被替换成下面的操作
HugeSet1 = ordsets:from_list(HugeList1),
HugeSet2 = ordsets:from_list(HugeList2),
ordsets:subtract(HugeSet1, HugeSet2)
%% 如果在意列表的原始顺序的话,可以退换成如下的操作
Set = gb_sets:from_list(HugeList2),
[E || E <- HugeList1, not gb_sets:is_element(E, Set)]
注意:如果列表包含重复的元素(HugeList2中出现一个元素在HugeList1中删除了所有出现的元素),则该代码的行为与“- -”不同。另外,这个代码比较了使用“==”运算符的列表元素,而“- -”使用“=:=”运算符。如果这个区别很重要,那么可以使用set代替gb_set,但是在长列表中set:from_list/1比gb_sets:from_list/1慢得多。 使用“- -”运算符从列表中删除一个元素不会有性能问题:HugeList1 – [Element] 。