如何调试 Rust 中的内存问题?

How do I debug a memory issue in Rust?

我希望这个问题不要过于开放。我 运行 遇到了 Rust 的内存问题,我得到了 . I'm unsure how to debug it. Prints have only brought me to the point where the failure occurs. I'm not very familiar with other tools such as ltrace,所以虽然我可以创建一个跟踪(231MiB,pff),但我真的不知道如何处理它。这样的痕迹有用吗?我会更好地抓住 gdb/lldb 吗?还是 Valgrind?

一般来说,要进行调试,您可以使用基于日志的方法(通过自己插入日志,或者使用 ltraceptrace、...为您生成日志)或者您可以使用调试器。

请注意,ltraceptrace 或基于调试器的方法要求您能够重现问题;我倾向于赞成手动日志,因为我在一个错误报告通常过于不精确而无法立即复制的行业工作(因此我们使用日志来创建复制器场景)。

Rust 支持这两种方法,并且用于 C 或 C++ 程序的标准工具集适用于它。

我个人的方法是让 一些 日志记录到位以快速缩小问题发生的范围,如果日志记录不足以启动调试器以进行更精细的梳理检查。在这种情况下,我建议直接使用调试器。

生成一个 panic,这意味着通过中断对 panic hook 的调用,您可以在出现问题的那一刻看到调用堆栈和内存状态。

用调试器启动你的程序,在 panic 挂钩上设置一个断点,运行 程序,盈利。

一般来说,我会尝试采用以下方法:

  1. 减少样板文件: 尝试缩小 OOM 问题的范围,这样您就不会有太多额外的代码。换句话说:你的程序崩溃得越快越好。有时也可以撕掉一段特定的代码并将其放入额外的二进制文件中,仅用于调查。

  2. 减少问题大小: 将问题从 OOM 降低到简单的 "too much memory" 这样你就可以知道某些部分浪费了一些东西但是它不会导致 OOM。如果很难判断您是否看到问题,您可以降低内存限制。在 Linux 上,可以使用 ulimit:

    来完成
    ulimit -Sv 500000  # that's 500MB
    ./path/to/exe --foo
    
  3. 信息收集:如果你的问题足够小,你准备好收集噪音水平较低的信息。您可以尝试多种方法。请记住使用调试符号编译您的程序。关闭优化也可能是一个优势,因为这通常会导致信息丢失。两者都可以通过在编译期间不使用 --release 标志来存档。

    • 堆分析: 一种方法也是使用 gperftools

      LD_PRELOAD="/usr/lib/libtcmalloc.so" HEAPPROFILE=/tmp/profile ./path/to/exe --foo
      pprof --gv ./path/to/exe /tmp/profile/profile.0100.heap
      

      这向您展示了一个图表,它象征着程序的哪些部分占用了多少内存。有关详细信息,请参阅 official docs

    • rr: 有时很难弄清楚到底发生了什么,尤其是在创建配置文件之后。假设您在第 2 步中做得很好,您可以使用 rr:

      rr record ./path/to/exe --foo
      rr replay
      

      这将产生一个具有超能力的 GDB。与普通调试会话的不同之处在于,您不仅可以 continue,还可以 reverse-continue。基本上,您的程序是从您可以根据需要来回跳转的录音中执行的。 This wiki page 为您提供了一些额外的例子。需要指出的一件事是 rr 似乎只适用于 GDB。

    • 良好的旧调试: 有时您得到的痕迹和记录仍然太大。在这种情况下,您可以(结合 ulimit 技巧)只使用 GDB 并等待程序崩溃:

      gdb --args ./path/to/exe --foo
      

      您现在应该得到一个正常的调试会话,您可以在其中检查程序的当前状态。 GDB 也可以使用 coredumps 启动。该方法的一般问题是您无法及时返回并且无法继续执行。所以您只能看到当前状态,包括所有堆栈帧和变量。如果你愿意,这里也可以使用 LLDB。

  4. (潜在)修复+重复: 在你弄清楚可能会出错的地方后,你可以尝试更改你的代码。然后重试。如果仍然无效,请返回步骤 3 重试。

Valgrind 和其他工具工作正常,从 Rust 1.32 开始应该开箱即用。 Rust 的早期版本需要将全局分配器从 jemalloc 更改为系统的分配器,以便 Valgrind 和朋友知道如何监控内存分配。

在这个答案中,我使用 macOS 开发人员工具 Instruments,就像我在 macOS 上一样,但 Valgrind / Massif / Cachegrind 的工作方式类似。

示例:无限循环

这是一个通过将 1MiB Strings 推入 Vec 并且从不释放它来 "leaks" 内存的程序:

use std::{thread, time::Duration};

fn main() {
    let mut held_forever = Vec::new();
    loop {
        held_forever.push("x".repeat(1024 * 1024));
        println!("Allocated another");

        thread::sleep(Duration::from_secs(3));
    }
}

您可以看到内存随时间的增长,以及分配内存的确切堆栈跟踪:

示例:引用计数中的循环

下面是一个通过创建无限引用循环来泄漏内存的示例:

use std::{cell::RefCell, rc::Rc};

struct Leaked {
    data: String,
    me: RefCell<Option<Rc<Leaked>>>,
}

fn main() {
    let data = "x".repeat(5 * 1024 * 1024);

    let leaked = Rc::new(Leaked {
        data,
        me: RefCell::new(None),
    });

    let me = leaked.clone();
    *leaked.me.borrow_mut() = Some(me);
}

另请参阅:

  • Handling memory leak in cyclic graphs using RefCell and Rc
  • Minimal `Rc` Dependency Cycle