【问题标题】:Erlang: stackoverflow with recursive function that is not tail call optimized?Erlang:带有未优化尾调用的递归函数的stackoverflow?
【发布时间】:2014-11-19 23:12:26
【问题描述】:

是否可以使用未在 Erlang 中优化尾调用的函数获得 stackoverflow?例如,假设我有这样的功能

sum_list([],Acc) ->
   Acc;
sum_list([Head|Tail],Acc) ->
   Head + sum_list(Tail, Acc).

看起来如果传入一个足够大的列表,它最终会耗尽堆栈空间并崩溃。我试过这样测试:

> L = lists:seq(1, 10000000).
[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,28,29|...]
> sum_test:sum_list(L, 0).
50000005000000

但它永远不会崩溃!我尝试了一个包含 100,000,000 个整数的列表,它花了一段时间才完成,但它仍然没有崩溃!问题:

  1. 我的测试是否正确?
  2. 如果是这样,为什么我无法生成 stackoverflow?
  3. Erlang 是否正在做一些事情来防止堆栈溢出的发生?

【问题讨论】:

  • 我刚刚检查了这一点,我认为我对常量空间的看法是错误的:堆栈在所有体递归中都会增长,但 Erlang 的堆栈比你习惯的要高效得多.我正在考虑的优化是大幅增加速度,而不是空间,使主体递归与尾递归相当。用一万亿个整数尝试你的测试。

标签: recursion erlang stack-overflow tail-call-optimization


【解决方案1】:

您正确地测试了这一点:您的函数确实不是尾递归的。要找出答案,您可以使用erlc -S <erlang source file> 编译您的代码。

{function, sum_list, 2, 2}.
  {label,1}.
    {func_info,{atom,so},{atom,sum_list},2}.
  {label,2}.
    {test,is_nonempty_list,{f,3},[{x,0}]}.
    {allocate,1,2}.
    {get_list,{x,0},{y,0},{x,0}}.
    {call,2,{f,2}}.
    {gc_bif,'+',{f,0},1,[{y,0},{x,0}],{x,0}}.
    {deallocate,1}.
    return.
  {label,3}.
    {test,is_nil,{f,1},[{x,0}]}.
    {move,{x,1},{x,0}}.
    return.

作为比较函数的以下尾递归版本:

tail_sum_list([],Acc) ->
   Acc;
tail_sum_list([Head|Tail],Acc) ->
   tail_sum_list(Tail, Head + Acc).

编译为:

{function, tail_sum_list, 2, 5}.
  {label,4}.
    {func_info,{atom,so},{atom,tail_sum_list},2}.
  {label,5}.
    {test,is_nonempty_list,{f,6},[{x,0}]}.
    {get_list,{x,0},{x,2},{x,3}}.
    {gc_bif,'+',{f,0},4,[{x,2},{x,1}],{x,1}}.
    {move,{x,3},{x,0}}.
    {call_only,2,{f,5}}.
  {label,6}.
    {test,is_nil,{f,4},[{x,0}]}.
    {move,{x,1},{x,0}}.
    return.

注意尾递归版本中缺少allocatecall_only 操作码,而不是非递归函数中的allocate/call/deallocate/return 序列。

您没有遇到堆栈溢出,因为 Erlang “堆栈”非常大。实际上,堆栈溢出通常意味着处理器堆栈溢出,因为处理器的堆栈指针离得太远了。进程传统上具有有限的堆栈大小,可以通过与操作系统交互来调整。参见例如 POSIX 的setrlimit

但是,Erlang 执行堆栈不是处理器堆栈,因为代码是被解释的。每个进程都有自己的堆栈,可以通过调用操作系统内存分配函数(通常为 Unix 上的 malloc)按需增长。

因此,只要malloc 调用成功,您的函数就不会崩溃。

为了记录,实际列表L 使用与堆栈相同数量的内存来处理它。实际上,列表中的每个元素都包含两个单词(整数值本身,因为它们很小,所以被装箱为单词)和指向列表下一个元素的指针。相反,堆栈在每次迭代中由allocate 操作码增长两个字:一个字用于CP,由allocate 自己保存,一个字根据请求(allocate 的第一个参数)用于当前值.

对于 64 位 VM 上的 100,000,000 个字,该列表至少需要 1.5 GB(幸运的是,实际堆栈不是每两个字增长一次)。在 shell 中监控和垃圾处理是很困难的,因为许多值仍然存在。如果你生成一个函数,你可以看到内存使用情况:

spawn(fun() ->
    io:format("~p\n", [erlang:memory()]),
    L = lists:seq(1, 100000000),
    io:format("~p\n", [erlang:memory()]),
    sum_test:sum_list(L, 0),
    io:format("~p\n", [erlang:memory()])
end).

如你所见,递归调用的内存并没有立即释放。

【讨论】:

  • io:format("~p\n", [erlang:process_info(self())])测试似乎更容易
  • 还有一个问题:似乎 Erlang 进程会先崩溃,而不是整个 VM 崩溃。
猜你喜欢
  • 2018-03-08
  • 2021-05-30
  • 1970-01-01
  • 2011-09-13
  • 2017-04-18
  • 2010-10-20
  • 2015-06-16
  • 1970-01-01
  • 2015-06-08
相关资源
最近更新 更多