【问题标题】:Recursive function calculating factorials leads to stack overflow递归函数计算阶乘导致堆栈溢出
【发布时间】:2016-10-03 21:25:59
【问题描述】:

我在 Rust 中尝试了递归阶乘算法。我使用这个版本的编译器:

rustc 1.12.0 (3191fbae9 2016-09-23)
cargo 0.13.0-nightly (109cb7c 2016-08-19)

代码:

extern crate num_bigint;
extern crate num_traits;

use num_bigint::{BigUint, ToBigUint};
use num_traits::One;

fn factorial(num: u64) -> BigUint {
    let current: BigUint = num.to_biguint().unwrap();
    if num <= 1 {
        return One::one();
    }
    return current * factorial(num - 1);
}

fn main() {
    let num: u64 = 100000;
    println!("Factorial {}! = {}", num, factorial(num))
}

我收到了这个错误:

$ cargo run

thread 'main' has overflowed its stack
fatal runtime error: stack overflow
error: Process didn't exit successfully

如何解决这个问题?为什么我在使用 Rust 时会看到这个错误?

【问题讨论】:

    标签: rust biginteger factorial bignum


    【解决方案1】:

    Rust 没有尾调用消除功能,因此您的递归受到堆栈大小的限制。它可能是未来 Rust 的一个特性(你可以在Rust FAQ 阅读更多关于它的信息),但与此同时你必须要么不递归那么深,要么使用循环。

    【讨论】:

    • 很好的答案!但是阶乘 bigint 的循环还有另一个问题 - 非常慢。
    • @mrLSD 我不怀疑!但是递归,至少在 Rust 中,并没有真正提供比循环更快的速度。至少我没听说过。
    • @mrLSD 虽然 LLVM 可以优化某些尾调用,但问题中的函数没有尾调用并且无论如何都不会被优化,即使是在保证 TCO 的语言中
    【解决方案2】:

    为什么?

    这是一个堆栈溢出,只要没有剩余的堆栈内存就会发生。比如栈内存被

    • 局部变量
    • 函数参数
    • 返回值

    递归使用大量堆栈内存,因为对于每个递归调用,所有局部变量、函数参数等的内存都必须在堆栈上分配。


    如何解决?

    显而易见的解决方案是以非递归方式编写算法(当您想在生产中使用算法时应该这样做!)。但您也可以只增加堆栈大小。虽然主线程的栈大小不能修改,但是你可以创建一个新线程并设置一个特定的栈大小:

    fn main() {
        let num: u64 = 100_000;
        // Size of one stack frame for `factorial()` was measured experimentally
        thread::Builder::new().stack_size(num as usize * 0xFF).spawn(move || {
            println!("Factorial {}! = {}", num, factorial(num));
        }).unwrap().join();
    }
    

    此代码有效,当通过cargo run --release(经过优化!)执行时,只需几秒钟的计算即可输出解决方案。


    测量堆栈帧大小

    如果您想知道 factorial() 的堆栈帧大小(one 调用的内存需求)是如何测量的:我在每个 @987654325 上打印了函数参数 num 的地址@调用:

    fn factorial(num: u64) -> BigUint {
        println!("{:p}", &num);
        // ...
    }
    

    两个连续调用的地址之间的差异(或多或少)是堆栈帧大小。在我的机器上,差异略小于0xFF (255),所以我只是将其用作大小。

    如果您想知道为什么堆栈帧大小不小:Rust 编译器并没有真正针对这个指标进行优化。通常它真的不重要,所以优化器倾向于牺牲这个内存需求以获得更好的执行速度。我查看了程序集,在这种情况下,许多 BigUint 方法被内联。这意味着其他函数的局部变量也在使用堆栈空间!

    【讨论】:

    • 注意:很像那只著名的猫,测量堆栈帧大小可能会增加它(在num的地址被占用之前,它可以留在寄存器中,println!也需要一些堆栈空间)。
    【解决方案3】:

    只是作为一种选择..(我不推荐)

    Matts 的回答在一定程度上是正确的。有一个名为 stacker (here) 的 crate 可以人为地增加堆栈大小以供递归算法使用。它通过分配一些堆内存来溢出来做到这一点。

    作为警告......这需要运行很长时间......但是,它会运行,并且不会破坏堆栈。使用优化编译会使它下降,但它仍然很慢。正如马特建议的那样,您可能会从循环中获得更好的性能。我想我还是会把它扔出去。

    extern crate num_bigint;
    extern crate num_traits;
    extern crate stacker;
    
    use num_bigint::{BigUint, ToBigUint};
    use num_traits::One;
    
    fn factorial(num: u64) -> BigUint {
        // println!("Called with: {}", num);
        let current: BigUint = num.to_biguint().unwrap();
        if num <= 1 {
            // println!("Returning...");
            return One::one();
        }
    
        stacker::maybe_grow(1024 * 1024, 1024 * 1024, || {
            current * factorial(num - 1)
        })
    }
    
    fn main() {
        let num: u64 = 100000;
        println!("Factorial {}! = {}", num, factorial(num));
    }
    

    我已经将调试printlns 注释掉了。如果你愿意,可以取消注释。

    【讨论】:

    • maybe_grow(1024 * 1024, 1024 * 1024 将在每次调用时分配一个新的堆栈帧。我measure 1GB 内存使用。如果我把它改成maybe_grow(32 * 1024, 1024 * 1024,它不会分配一个新的堆栈,直到剩下32K。现在它只使用 20MB 内存。不过,这种变化并没有真正改变速度。
    • 啊,是的,抱歉 - 我最初确实有它在 64k 边界,但在我的最终测试中将它撞了。
    • 你说I do not recommend,但如果你不能将递归算法重写为迭代算法,我认为这确实是一个不错的选择。运行时堆栈溢出非常糟糕。当然这个问题的算法很容易迭代写。
    猜你喜欢
    • 2011-02-26
    • 2013-04-05
    • 1970-01-01
    • 2020-03-06
    • 2016-11-14
    • 2023-03-14
    相关资源
    最近更新 更多