Weak 弱引用:如何用 Weak 打破 Rc 与 Arc 的循环引用

Weak 弱引用:如何用 Weak 打破 Rc 与 Arc 的循环引用

在很多人的认知里,Rust ≈ 没有内存问题。但如果你写过稍微复杂一点的数据结构,你很快就会遇到一个现实:Rust 不会产生悬垂指针,但*仍然可能发生内存泄漏。而这类泄漏,往往就来自一个经典陷阱:Rc/Arc 的循环引用。

Rust 为什么也会内存泄漏?

Rust 的内存安全,主要解决的是:悬垂指针、double free 等,但它并不保证一定释放内存

来看一个真实例子:

rust 复制代码
use std::rc::Rc;

fn main() {
    let a = Rc::new(1);
    let b = Rc::clone(&a);

    println!("{}", Rc::strong_count(&a)); // 2
}

只要 strong_count 不为零,数据就不会被释放。也就是说这就会造成内存泄漏。

循环引用是如何产生的?

要理解循环引用的产生,我们先回顾一个核心前提:Rc 与 Arc 的强引用计数规则是只要 strong_count 不为零,数据就不会被释放,且每一次强引用克隆都会让计数加一。

循环引用的本质,就是多个 Rc 与 Arc 实例互相持有对方的强引用,形成一个闭环,导致彼此的 strong_count 永远无法归零。

来看一个经典错误设计,双向链表,如下所示:

rust 复制代码
use std::cell::RefCell;
use std::rc::Rc;

// 错误设计:next和prev均为强引用
struct Node {
    next: Option<Rc<RefCell<Node>>>, // 强引用持有下一个节点
    prev: Option<Rc<RefCell<Node>>>, // 强引用持有上一个节点
}

fn main() {
    // 创建节点A和B,初始时next和prev均为None
    let a = Rc::new(RefCell::new(Node {
        next: None,
        prev: None,
    }));
    let b = Rc::new(RefCell::new(Node {
        next: None,
        prev: None,
    }));

    // 让A的next指向B,B的prev指向A
    a.borrow_mut().next = Some(Rc::clone(&b)); // A强引用B,b的strong_count +=1(变为2)
    b.borrow_mut().prev = Some(Rc::clone(&a)); // B强引用A,a的strong_count +=1(变为2)
}

在上面的示例中,我们让 A、B 两个节点互相强引用,形成了闭环。此时,两个节点的强引用计数状态 strong_count 均为2。

接下来,我们尝试释放外部持有的引用:

rust 复制代码
drop(a); // 释放外部对A的引用,A的strong_count变为1(仅剩B的prev持有)
drop(b); // 释放外部对B的引用,B的strong_count变为1(仅剩A的next持有)

此时最关键的问题出现了:虽然外部引用已经全部释放,但节点A和B仍然互相持有对方的强引用,即 A 的 next 持有 B,B 的 prev 持有 A。

因此,这两个节点占用的内存永远不会被 Rust 释放,这就是循环引用导致的逻辑内存泄漏

为什么 Rust 不自动解决循环引用?

一个很自然的问题是:Rust 为什么不帮我检测并回收这些环?答案很简单,那就是代价太高,而且违背设计哲学

Rc 和 Arc 只知道有多少人引用我,但不知道整个对象图长什么样。如果想检测有没有环就需要全局扫描,这其实就是垃圾回收(GC)在做的事情。

Rust 的哲学是用类型系统表达所有权,而不是运行时猜测。所以绝不会引入 GC,更不会去自动检测循环,这一切的工作交给开发者。

用 Weak 打破循环引用

为了解决这个问题,Rust 提供了 Weak,它的特性如下:

  • 不影响强引用计数:创建 Weak 实例时,不会增加目标数据的 strong_count,只影响 weak_count(弱引用计数),weak_count 不决定数据是否释放。
  • 不拥有数据所有权:Weak 本身不"拥有"目标数据,哪怕存在多个 Weak 引用,只要目标数据的 strong_count 归零,数据就会被释放,不受 Weak 引用数量影响。
  • 无法直接访问数据:正因为不拥有所有权,Weak 不能直接 deref 访问数据(避免悬垂指针风险),必须通过 upgrade() 方法尝试将其转为强引用并访问数据。

我们用一段简单的代码,直观感受 Weak 的核用法,理解它如何体现"观察而非拥有":

rust 复制代码
use std::rc::Rc;

fn main() {
    // 创建强引用,此时 strong_count = 1,weak_count = 0
    let rc = Rc::new("hello world".to_string());

    // 将强引用降级为弱引用,此时 strong_count 仍为 1,weak_count = 1
    let weak = Rc::downgrade(&rc);

    // 尝试将弱引用升级为强引用:此时数据存在,升级成功
    if let Some(strong) = weak.upgrade() {
        println!("数据存在:{}", strong); // 输出:数据存在:hello world
    } else {
        println!("数据已释放");
    }

    // 释放强引用,此时 strong_count = 0,数据被释放
    drop(rc);

    // 再次尝试升级弱引用:数据已释放,升级失败
    if let Some(strong) = weak.upgrade() {
        println!("数据存在:{}", strong);
    } else {
        println!("数据已释放"); // 输出:数据已释放
    }
}

从这段代码能清晰看到 Weak 的设计逻辑:它就像一个"观察者",只能观察数据是否存在,却不能阻止数据被释放。这种"不拥有"的特性,正是打破 Rc 与 Arc 循环引用的关键。只要将循环中的一个强引用替换为 Weak 弱引用,"互相拥有"的闭环就会被打破,强引用计数就能正常归零。

我们来改造刚才的双向链表,这里的改造逻辑是:父对子用 Rc(强引用),而子对父用 Weak(弱引用),既保证父节点释放时子节点同步释放,也避免子节点持有父节点导致的循环引用。

rust 复制代码
use std::cell::RefCell;
use std::rc::{Rc, Weak};

// 正确设计:next 强引用,prev 弱引用(避免循环引用)
struct Node {
    next: Option<Rc<RefCell<Node>>>,   // 强引用持有下一个节点
    prev: Option<Weak<RefCell<Node>>>, // 弱引用持有上一个节点
}

fn main() {
    // 创建节点A和B,初始时next和prev均为None
    let a = Rc::new(RefCell::new(Node {
        next: None,
        prev: None,
    }));
    let b = Rc::new(RefCell::new(Node {
        next: None,
        prev: None,
    }));

    // 让A的next指向B, A强引用B
    a.borrow_mut().next = Some(Rc::clone(&b));

    // B的prev指向A,B弱引用A
    // Rc::downgrade将Rc强引用&a转换为弱引用&a
    b.borrow_mut().prev = Some(Rc::downgrade(&a));
}

Weak 的常见坑

Weak 虽能解决循环引用问题,但并非万能工具,若使用不当,反而会引入新的内存安全隐患或逻辑错误。

强行 unwrap,引发 panic

这是最常见、最容易忽略的坑。upgrade() 方法返回 Option,有些开发者图省事,直接用 unwrap() 强制获取升级后的强引用,一旦数据已释放,就会触发程序 panic。

在实际开发中,必须用 if letmatch 语句处理 upgrade() 的返回值,严禁 unwrap。

滥用 Weak,导致数据提前释放

有些开发者会过度使用 Weak,甚至将所有引用都改为 Weak,导致数据因 strong_count 为零被提前释放,引发逻辑错误。错误示例如下:

rust 复制代码
// 错误示例:主数据也用 Weak,导致数据提前释放

// 错误:没有强引用持有数据,strong_count = 0
let data = Weak::new(); 
let cache = RefCell::new(LruCache::new());
cache.borrow_mut().insert("key1".to_string(), &data);

// 此时 data 已被释放,缓存中无法获取到任何数据
println!("缓存获取:{:?}", cache.borrow().get("key1")); // 输出:None

这里只需要记住:拥有关系用 Rc/Arc,非拥有关系用 Weak。Weak 无法维持数据的生命周期,必须有至少一个强引用作为数据的拥有者。

忽视 weak_count,误以为 Weak 不影响内存

很多开发者认为 Weak 不影响数据释放,所以可以随意创建,但忽略了 Weak 会增加 weak_count。虽然 weak_count 不决定数据是否释放,但会影响 Rc/Arc 内部元数据(计数结构体)的释放。若大量创建 Weak 且不清理,会导致元数据内存泄漏。

特别是在高频调用的代码中,反复创建 Weak 但不回收,导致 weak_count 持续增长,元数据占用的内存无法释放。

总结

综上,Weak 与 Rc/Arc 并非对立关系,而是互补关系。Rc/Arc 负责"拥有"数据、维持生命周期,Weak 负责"观察"数据、打破循环闭环。

相关推荐
techdashen6 小时前
Go设计取舍之四: map不变时能否并发修改不同value
开发语言·后端·golang
小小小米粒6 小时前
阿姆达尔定律(Amdahl‘s Law)
java·开发语言
知彼解己6 小时前
Java 版本演进
java·开发语言·spring boot
风样滴男人哟6 小时前
PHP特性之反射类ReflectionClass机制
android·开发语言·php
看昭奚恤哭6 小时前
Flutter 布局核心思想
开发语言·javascript·flutter
朱容zr3331337 小时前
为什么推荐使用自增主键?使用UUID作为主键的优缺点是什么?
java·运维·数据库·后端·mysql·面试·性能优化
用户608186527907 小时前
一文吃透 Avalonia 布局容器|Grid 到 UniformGrid 核心用法 + 语法糖实战
后端
IsSh9nj6q7 小时前
Python全栈应用搭建神器magic-dash .新版本介绍
开发语言·python·dash
Ai拆代码的曹操7 小时前
Dubbo 线程池爆满排坑:Linux 用户线程数限制,一个容易被忽略的根因
后端·源码阅读
NutShell Wang7 小时前
Vite 8.1 深度拆解:Rolldown 统一打包器如何终结前端构建的「双引擎时代」
前端·rust·开源·vite·开发者工具·vibe coding