症状
泄漏能躲过直觉,因为每个 Rc 单独看都正确:Node 同时持有 parent(强引用)与 children(强引用),丢掉根节点后什么都没释放——子节点各持根一份强计数,根也持子节点强计数,环内计数永远到不了零。堆剖析显示根被丢弃后 Node 分配数量保持稳定——“有保留而无分配churn”,这就是循环的指纹。传统泄漏工具保持沉默,因为内存仍然“可达”:从它自身可达。长驻进程 RSS 持续增长、观察者注册表与双向链接的缓存无界膨胀,都是同一拓扑。
诊断思路
先用堆剖析(如 dhat)确认“丢弃根后类型数量稳定”的保留特征,区分于分配churn;再把强引用边映射成图——每个 Rc 字段是一条边,类型间闭合成环的边集就是泄漏拓扑;最后破一条反向边并验证:换成 Weak 后用 Drop 计数断言析构真的执行,环就是原因的证明。
根因
Rc 在强计数归零时释放内存。循环让每个成员的计数在外部引用全部丢弃后仍保持在一或以上,所有权模型无法回收:内存从自身可达,析构函数永不运行。
修复与验证
指定所有权方向并降级反向边为 Weak:容器到内容物是强引用(父拥有子),导航向上用弱引用(子持 Weak
代码示例
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); }
}