Rust 内存泄漏的隐蔽角落:循环引用、ManuallyDrop 与线程悬挂

封面信息图

在现代系统级编程的认知中,许多开发者常有一个误区:“只要使用了 Rust 的所有权(Ownership)与 RAII 机制,系统就绝对不会发生内存泄漏(Memory Leak)。”

然而,Rust 官方文档在最显眼的位置明确指出:“内存安全(Memory Safety)并不等于绝对不发生内存泄漏。”
在 Rust 的设计哲学中,内存泄漏是完全被类型系统所允许的(Safe Rust 允许 Box::leak)。

在真实的生产级中大型 Rust 系统中,内存泄漏往往不会以野指针崩溃的方式暴露,而是悄无声息地吞噬服务器的物理 RAM,直到触发 Linux OOM Killer 导致整个服务被操作系统瞬间杀死。

深入排查 Rust 中三大隐蔽的内存泄漏陷阱——Rc/Arc 循环引用、ManuallyDrop / std::mem::forget 析构截断 与 异步协程通道悬挂(Task Leak),是保障系统长期稳健运转的硬核必修课。

+--------------------------------------------------------------------------+
|                       Rust 生产级三大隐蔽内存泄漏根因全景                       |
+--------------------------------------------------------------------------+
| 1. [引用计数循环依赖 (Reference Cycle)]:                                  |
|    Node A (Arc) --------------> Node B (Arc)                             |
|         ^                          |                                     |
|         \==========================/ (强引用相互死锁,引用计数永远 >= 1!)  |
|    -> 🛑 作用域结束时,双方析构函数 Drop 永远无法被触发,内存永久沉没!       |
+--------------------------------------------------------------------------+
| 2. [ManuallyDrop / FFI 截断 (Destructor Suppression)]:                   |
|    let x = ManuallyDrop::new(large_vec);                                 |
|    -> 🛑 若未显式 unsafe { ManuallyDrop::drop(&mut x) },底层堆内存永久泄漏 |
+--------------------------------------------------------------------------+
| 3. [异步任务通道永久悬挂 (Hanging Tokio Tasks)]:                          |
|    tokio::spawn(async move { rx.recv().await; /* 发送端已遗失,永远阻塞! */ })|
|    -> 🛑 协程状态机及其捕获的全部闭包上下文常驻堆中,单日积压数万个僵尸 Task|
+--------------------------------------------------------------------------+

1. 陷阱一:Arc/Rc 强引用循环闭环与破局之道

当两个互相引用的结构体均使用强引用 Arc<T> 时:

use std::sync::{Arc, Mutex, Weak};

struct BadNode {
    next: Option<Arc<Mutex<BadNode>>>, // 💣 强引用导致引用计数死锁
}

// ✅ 正确修复:打破循环,下游使用弱引用 Weak<T>
struct SafeNode {
    next: Option<Arc<Mutex<SafeNode>>>,
    prev: Option<Weak<Mutex<SafeNode>>>, // 🚀 弱引用不递增强引用计数
}

破局铁律:

在构建图(Graph)、双向树或父子拓扑时,“父到子用 Arc(拥有所有权),子到父必须使用 Weak(无所有权的弱观察者)”。Weak 不会增加强引用计数,当父节点被释放时,子节点能够顺畅触发 RAII 析构。

2. 陷阱二:ManuallyDrop 与 std::mem::forget 的遗忘代价

在进行 FFI 跨语言调用或手写极致零拷贝内存池时,为了防止 Rust 在离开作用域时自动将内存 free 掉,开发者常使用 std::mem::forget 或 std::mem::ManuallyDrop。

致命漏洞场景:

use std::mem::ManuallyDrop;

pub fn process_tensor(data: Vec<f32>) {
    let mut manual = ManuallyDrop::new(data);
    // 假设在此处发生了一个提前的 return 或者 ? 操作符报错退出!
    if error_condition() {
        return; // 💣 致命:底层的 Vec 物理内存被永久遗忘,直接泄漏 100MB!
    }

    // 只有代码顺利走到这里才安全销毁
    unsafe { ManuallyDrop::drop(&mut manual); }
}

最佳实践:

尽量使用 RAII 强类型 Guard 结构体 替代裸露的 ManuallyDrop,在自定义 Guard 的 Drop 实现中统一处理释放逻辑,确保即便发生提前 return 或 panic,内存依然能被安全回收。

3. 陷阱三:Tokio 异步任务的“静默悬挂泄漏”

在异步编程中,最普遍但也最难排查的泄漏是 协程状态机泄漏:

  • 启动了一个 tokio::spawn(async move { ... });
  • 该任务在内部死死等待一个 channel.recv()、或者等待一个永远不会返回的跨机网络 Socket;
  • 如果发送端因为业务 Bug 被意外销毁且没有关闭通道,这个异步 Task 将永远驻留在 Tokio 调度的堆内存中,永远无法被 Drop!
  • 随着时间推移,单台服务器上积压了数十万个僵尸 Task,吃光所有物理内存。

终极防线:为一切等待注入超时(tokio::time::timeout)

// ✅ 为所有异步接收操作施加防御性硬超时
match tokio::time::timeout(Duration::from_secs(30), rx.recv()).await {
    Ok(Some(msg)) => process_msg(msg),
    Ok(None) => println!("通道已优雅关闭"),
    Err(_) => println!("警告:任务等待超时,主动退出防挂死!"),
}

时刻对内存生命周期保持敬畏,看清每一个作用域边缘的析构流转,这是编写工业级坚固 Rust 系统的最高自律。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐