← 中文资源

简体中文版本

Rust — Rc 引用循环泄漏内存

诊断 Rust 中 Rc/RefCell 循环导致的内存增长——所有权模型无法释放它们,用 Weak 打破循环,并用泄漏测试验证。

阅读英文原文

症状

  泄漏能躲过直觉,因为每个 Rc 单独看都正确:Node 同时持有 parent(强引用)与 children(强引用),丢掉根节点后什么都没释放——子节点各持根一份强计数,根也持子节点强计数,环内计数永远到不了零。堆剖析显示根被丢弃后 Node 分配数量保持稳定——“有保留而无分配churn”,这就是循环的指纹。传统泄漏工具保持沉默,因为内存仍然“可达”:从它自身可达。长驻进程 RSS 持续增长、观察者注册表与双向链接的缓存无界膨胀,都是同一拓扑。

诊断思路

  先用堆剖析(如 dhat)确认“丢弃根后类型数量稳定”的保留特征,区分于分配churn;再把强引用边映射成图——每个 Rc 字段是一条边,类型间闭合成环的边集就是泄漏拓扑;最后破一条反向边并验证:换成 Weak 后用 Drop 计数断言析构真的执行,环就是原因的证明。

根因

  Rc 在强计数归零时释放内存。循环让每个成员的计数在外部引用全部丢弃后仍保持在一或以上,所有权模型无法回收:内存从自身可达,析构函数永不运行。

修复与验证

  指定所有权方向并降级反向边为 Weak:容器到内容物是强引用(父拥有子),导航向上用弱引用(子持 Weak,使用时 upgrade() 并优雅处理 None);缓存类图反着来——外部持强键、内部存 Weak 值,被引用者死亡时条目自动蒸发。验证时用带 Drop 计数的测试断言负载结束后节点计数归零;长稳测试观察 RSS 进入平台期而非增长。每个 upgrade() 的 None 都是新失败模式,必须有意处理而不是 unwrap。

代码示例

struct Node {
    parent: RefCell<Option<Rc<Node>>>,   // strong back-edge
    children: RefCell<Vec<Rc<Node>>>,
}
use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
    parent: RefCell<Option<Weak<Node>>>,   // weak: no cycle
    children: RefCell<Vec<Rc<Node>>>,      // strong: parent owns children
}

impl Node {
    fn parent(&self) -> Option<Rc<Node>> {
        self.parent.borrow().as_ref().and_then(|w| w.upgrade())
    }
}
let cache: HashMap<Id, Weak<Asset>> = HashMap::new();
impl Drop for Node {
    fn drop(&mut self) { DROPPED.fetch_add(1, Ordering::Relaxed); }
}