我们先看两段递归实现:
尾递归和其对应的汇编码:
tail_rec(_, [], Acc) -> lists:reverse(Acc);
tail_rec(F, [H|T], Acc) -> tail_rec(F, T, [F(H)|Acc]).
//注:虽然 beam_emu.c 中有 #define y(N) E[N]。 E[0]为进程空间的栈顶
//但是加载器在加载到 {y,Y}时,会将Y加1。也就是说我们看汇编码中的Y(N),实际对应的地址为E[N+1]
{label,4}.
{test,is_nonempty_list,{f,5},[{x,1}]}. //判断函数第二个传参是否为空,空则执行label5的逻辑
{allocate,3,3}.//栈区申请3+1个Eterm 即 E -= 4 注:剩余空间不够时会触发gc
{move,{x,2},{y,1}}.//E[2] = Acc
{move,{x,0},{y,2}}.//E[3] = F
{get_list,{x,1},{x,0},{y,0}}.//x(1)列表的第一个值赋值给x(0),尾部赋值给E[1]
{move,{y,2},{x,1}}.//x(1) = F
{call_fun,1}.//执行x(1), 即 F(H),传参和返回值都存在x(0)
{put_list,{x,0},{y,1},{x,2}}.// x(2) = [x(0) | E[2]]
{move,{y,0},{x,1}}.// E[1]赋值给x(1),即 x(1) = Acc
{move,{y,2},{x,0}}.// x(0) = F
{call_last,3,{f,4},3}.// E+=4;执行 label4 的逻辑。
//{call_last,3,{f,4},3} 意为 {指令名,目标函数参数,目标函数,释放空间M}
//加载器在加载到 call_last 指令时,会将M重写 NewM = (M+1)*sizeof(Eterm)
//在进行释放空间逻辑时,会释放掉 NewM/sizeof(Eterm) 个Eterm的空间
//进行下一次递归调用时,当前调用已不占用栈空间,从而形成尾递归
{label,5}.
{move,{x,2},{x,0}}.
{call_ext_only,1,{extfunc,lists,reverse,1}}. //执行 lists:reverse(Acc)
非尾递归和其对应的汇编码:
body_rec(_, []) -> [];
body_rec(F, [H|T]) -> [F(H) | body_rec(F, T)].
{label,7}.
{test,is_nonempty_list,{f,8},[{x,1}]}.//判断函数第二个传参是否为空,空则执行label8的逻辑
{allocate,2,2}.//栈区申请2+1个Eterm,即 E -= 3
{move,{x,0},{y,1}}.//E[2] = F
{get_list,{x,1},{x,0},{y,0}}.//x(1)列表的第一个值赋值给x(0),尾部赋值给E[1]
{move,{y,1},{x,1}}.//x(1) = F
{call_fun,1}.
//E[0]存放下条指令地址,执行x(1), 即 F(H),传参和返回值都存在x(0)
//F(H)执行完毕返回前,会执行 I = E[0]; E[0] = NIL; Goto(*I)
{move,{y,0},{x,2}}.//x(2) = T
{swap,{y,1},{x,0}}.//x(0) = F, E[2] = F(H)的返回值
{move,{x,2},{x,1}}.//x(1) = T
{trim,1,1}.//E += 1, E[0] = NIL
{call,2,{f,7}}.//E[0] = 下条指令地址, 执行label7,
{put_list,{y,0},{x,0},{x,0}}.// x(0) = [E[1] 即 F(H)的返回值 | label7执行结果]
{deallocate,1}.//栈区释放2个Eterm空间 注:释放传参+1个,而不是传参个
return.//跳转到E[0]
{label,8}.//函数第二个传参为空,则返回nil 即空列表
{test,is_nil,{f,6},[{x,1}]}.
{move,nil,{x,0}}.//x(0) 为NIL,即返回值为[]
return.//跳转到E[0]
两种写法的汇编码分析完毕后,我们就可以梳理流程并估算两种写法所需要空间大小:
尾递归写法:
判断Acc是否为空
是 →(运行1次)
翻转列表算法:
1.堆区剩余空间大于8则:利用堆剩余空间反转,直到空间不足时,走2的逻辑
2.堆区剩余空间不足则: 会在堆外(p→mbuf)申请足够的空间
不是 → (运行N次)
栈区申请4个Eterm
执行F
堆区申请2个Eterm
栈区释放4个Eterm
函数跳转
可得:尾递归写法,每次递归调用时堆区占用2个Eterm,栈区不占用空间。递归结束时再一次性占用2N个Eterm的堆内剩余+堆外空间。
空间总占用为: 堆区4N Eterm
非尾递归写法:
判断Acc是否为空
是 → (运行1次)
函数跳转
不是 → (运行N次)
栈区申请3个Eterm
执行F
栈区释放1个Eterm
递归自身 (此时,当前调用会在栈区残留2个Eterm)
堆区申请2个Eterm
释放2个Eterm
函数跳转
可得:非尾递归写法,每次递归调用时栈区占用2个Eterm,当递归完毕返回时,每次返回会在堆区占用2个Eterm
空间总占用为:堆区 2N Eterm, 栈区 2N Eterm
接着对双方耗时进行测试:
//测试源码
-module(recursion).
-compile(export_all).
tail_rec(F, L) -> tail_rec(F, L, []).
tail_rec(_, [], Acc) -> lists:reverse(Acc);
tail_rec(F, [H|T], Acc) -> tail_rec(F, T, [F(H)|Acc]).
body_rec(_, []) -> [];
body_rec(F, [H|T]) -> [F(H) | body_rec(F, T)].
f(X) -> X.
main() ->
PidB = spawn(fun F() ->
receive
build ->
put(list, lists:seq(1, 10000, 1)),
io:format("build over~n"),
F();
start_body_gc ->
?MODULE:body_rec(fun f/1, get(list)),
io:format("start_body_gc over:~n");
start_tail_gc ->
?MODULE:tail_rec(fun f/1, get(list)),
io:format("start_tail_gc over:~n");
start_body_time ->
List = [begin erlang:garbage_collect(), element(1, timer:tc(?MODULE, body_rec, [fun f/1, get(list)])) end || _ <- lists:seq(1,100,1)],
io:format("start_body max_time:~p, min_time:~p, ave_time:~p~n", [lists:max(List), lists:min(List), lists:sum(List) div 100]);
start_tail_time ->
List = [begin erlang:garbage_collect(), element(1, timer:tc(?MODULE, tail_rec, [fun f/1, get(list)])) end || _ <- lists:seq(1,100,1)],
io:format("start_tail max_time:~p, min_time:~p, ave_time:~p~n", [lists:max(List), lists:min(List), lists:sum(List) div 100]);
_ ->
io:format("error~n")
end
end),
TraceB = spawn(fun trace/0),
PidB!build,
receive after 1000 -> skip end,
io:format("begin trace~n"),
erlang:trace(PidB, true, [garbage_collection, {tracer, TraceB}]),
{PidB, TraceB}.
trace() ->
receive
{_,_,gc_minor_start,Info} ->
io:format("gc_minor_start:~p~n", [Info]),
M = get(min), case M of undefined -> Nm = 0; _ -> Nm = M end, put(min, Nm+1), trace();
{_,_,gc_major_start,Info} ->
io:format("gc_major_start:~p~n", [Info]),
M = get(max), case M of undefined -> Nm = 0; _ -> Nm = M end, put(max, Nm+1), trace();
print ->
io:format("~p~n",[{get(min), get(max)}]);
_ ->trace()
end.
测试流程:
//测试双方空间足够的情况下,耗时和gc次数
//注:测试操作系统:linux
//使用 erl +hms 80000 +S 1:1提供足够的内存空间测试
{Pid, Trace} = recursion:main().
//测试耗时
Pid!start_body_time.测试非尾递归耗时:start_body max_time:810, min_time:484, ave_time:502
Pid!start_tail_time.测试尾递归耗时:start_tail max_time:765, min_time:486, ave_time:506
//测试gc
Pid!start_body_gc.测试非尾递归
Pid!start_tail_gc.测试尾递归
Trace!print.打印测试结果
测试结果均为:{undefined,undefined}
//可见空间足够的情况下,双方耗时相差无几
//测试erl +S 1:1正常启动的情况下,gc次数
{Pid, Trace} = recursion:main().
Pid!start_body_gc. 测试非尾递归
Pid!start_tail_gc. 测试尾递归
Trace!print.
非尾递归结果均为:{3,undefined};
尾递归结果:{3,1}
//测试耗时需要调整代码
//测试代码如下:
time_test(Type) ->
RecPid = spawn(fun time_receive/0),
[begin receive after 100 -> skip end, spawn(?MODULE, time_run, [Type, RecPid]) end || _ <- lists:seq(1,100,1)].
time_run(body, RecPid) ->
L = lists:seq(1, 10000, 1),
RecPid ! element(1, timer:tc(?MODULE, body_rec, [fun f/1, L]));
time_run(tail, RecPid) ->
L = lists:seq(1, 10000, 1),
RecPid ! element(1, timer:tc(?MODULE, tail_rec, [fun f/1, L])).
time_receive() ->
receive
Time when is_integer(Time) ->
case get(list) of
List when is_list(List) ->
NewList = [Time]++List,
case length(NewList) of
100 ->
io:format("max_time:~p, min_time:~p, ave_time:~p~n", [lists:max(NewList), lists:min(NewList), lists:sum(NewList) div 100]);
_ ->
put(list, NewList),
time_receive()
end;
undefined ->
put(list, [Time]),
time_receive()
end;
_ ->
error
end.
//测试结果如下:
recursion:time_test(body).
打印结果:max_time:896, min_time:583, ave_time:604
recursion:time_test(tail).
打印结果:max_time:1463, min_time:861, ave_time:931
就结果看来,尾递归会多触发一次大gc,平均耗时也多过非尾递归。
那尾递归是怎么多出一次大gc的?可以根据gc规则进行推导:
测试进程在执行 put(list, lists:seq(1, 10000, 1)) 后,内存空间状态为 :
堆栈总大小:17731; 堆区消耗:6464; 栈区消耗:0; 水位线:4192
老堆消耗:13536; 老堆总大小:28690
非尾递归gc次数来源:
每次进入函数时,栈区申请3个Eterm,执行完F后栈区释放1个Eterm
此时栈区可用空间为:17731-6464 = 11267
可支持 11267/2 = 5633次递归调用,第5634次调用时触发小gc
第一次小gc触发后内存状态为:
堆栈总大小:17731; 堆区消耗:2272; 栈区消耗:11267; 水位线: 2272
老堆消耗:17728; 老堆总大小:28690
此时栈区可用空间为:17731-2272-11267=4192
也就是在:4192/2=2096次递归调用后,会触发第二次小gc
第二次小gc触发后内存状态为:
堆栈总大小:17731; 堆区消耗:0; 栈区消耗:15459; 水位线: 0
老堆消耗:20000; 老堆总大小:28690
此时栈区可用空间为:17731-15459=2272
也就是在:2272/2=1136次递归调用后,会触发第三次小gc
第三次小gc触发后内存状态为:
堆栈总大小:28690; 堆区消耗:0; 栈区消耗:17731; 水位线: 0
老堆消耗:20000; 老堆总大小:28690
结论:非尾递归执行了3次小gc逻辑,一次空间增长逻辑
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
尾递归gc次数来源:
每次递归占用2个堆区Eterm
此时堆区可用空间为:17731-6464=11267
可支持 11267/2 = 5633次递归调用,第5634次调用时触发小gc
第一次小gc触发后内存状态为:
堆栈总大小:17731; 堆区消耗:13539; 栈区消耗:0; 水位线: 13539
老堆消耗:17728; 老堆总大小:28690
此时堆区可用空间为:17731-13539=4192
在 4192/2=2096次递归调用后,会触发第二次小gc,
小gc逻辑中(在实际gc逻辑前)发现老堆不够用,于是会触发大gc
第一次大gc触发后内存状态为:
堆栈总大小:46422; 堆区消耗:35459; 栈区消耗:0; 水位线: 35459
老堆消耗:0; 老堆总大小:0
执行lists:reverse操作时,由于是bif操作,会在操作结束后执行一次小gc
结论:尾递归实际执行了2次小gc,一次大gc,一次空间增长逻辑
测试案例中,尾递归表现稍差的原因呼之欲出——大gc,可真是这样么?
如果没有这次大gc,双方的性能表现又会如何呢?
选5000,调整下测试逻辑
body_rec(_, []) -> [];
改为 body_rec(_, [5000|Acc]) -> [5000|Acc];
tail_rec(_, [], Acc) -> lists:reverse(Acc);
改为 tail_rec(_, [5000|Acc1], Acc2) -> lists:reverse(Acc2)++[5000|Acc1];
recursion:time_test(body).
结果:max_time:458, min_time:284, ave_time:301
recursion:time_test(tail).
结果:max_time:619, min_time:388, ave_time:421
可见在均不触发大gc的情况下,非尾递归的表现还是更优,一个可能的原因是调用bif会触发一次小gc,
尾递归需要reverse的写法天然多了一次小gc消耗,那我们给非尾递归人为的加个小gc,再看看双方对比:
body_rec(_, []) -> [];
改为 body_rec(_, [5000|Acc]) -> erlang:garbage_collect(self(), [{type, 'minor'}]), [5000|Acc];
recursion:time_test(body).
结果:max_time:532, min_time:315, ave_time:337
还是比尾递归快
那尾递归就赢不了么,天无绝尾递归之路,如下例子尾递归就更快些:
spawn(fun() ->
L = lists:seq(1,250000),
F = fun(X) -> X end,
{{A,_}, {B,_}} = {timer:tc(recursion, body_rec, [F, L]), timer:tc(recursion, tail_rec, [F, L])},
io:format("Body:~p~nTail:~p~n", [A,B]) end).
结果打印: Body:9391 Tail:4303
问题来了…这是为什么呢(奸笑)?
有如下函数:
//注 使用erl 23版本,别的版本产生如下效果需要的数值需另行探讨
single_tail(N) ->
spawn(fun() -> io:format("~p~n", [element(1, timer:tc(recursion, tail_rec, [fun(X)-> X end, lists:seq(1,N,1)]))]) end).
测试打印:
recursion:single_tail(3500000). 打印: 554862
recursion:single_tail(3800000). 打印: 498627
列表更长,耗时反而更短…这又是为什么呢?