先说个结论:C++ 的 shared_ptr 用起来越顺手,循环引用这只“黑天鹅”就越容易咬人。我在维护某个跨平台组件的时候,就见过一个会话管理模块,退出登录后内存一直涨,排查了好几天才发现是两个对象互相持有 shared_ptr,谁都不肯先松开。这篇文章就围绕智能指针循环引用这个核心问题展开,讲清楚它为什么发生、weak_ptr 怎么解开、以及从根上重新设计所有权边界的思路。适合刚接触现代 C++ 的新手,也适合已经写过一段时间、正在被资源释放问题折磨的开发者。
1. shared_ptr 的“房间里的大象”:循环引用为什么是代码中的死结
1.1 先看一个最小复现
很多人以为 shared_ptr 是“自动垃圾回收”,只要把所有指针都换成 shared_ptr,内存就不会泄漏。这句话只对了一半。shared_ptr 用的其实是引用计数:每个 shared_ptr 构造时把引用计数加一,析构时减一,减到零才销毁对象。问题在于,它只关心“还有几个 shared_ptr 指向同一个控制块”,并不关心这些 shared_ptr 之间的指向关系形成什么形状。
一旦这个形状里出现了环,就麻烦了。
看这段代码:
#include <iostream> #include <memory> struct B; struct A { std::shared_ptr<B> b; ~A() { std::cout << "~A\n"; } }; struct B { std::shared_ptr<A> a; ~B() { std::cout << "~B\n"; } }; int main() { auto a = std::make_shared<A>(); auto b = std::make_shared<B>(); a->b = b; b->a = a; // 函数结束,局部变量 a、b 析构 // 但实际不会打印 ~A 和 ~B }运行一下,屏幕上什么都没有。为什么?因为 a 指向 A 对象的引用计数是 2:一份来自 main 里的局部变量 a,一份来自 b 内部的b->a。同样,B 对象的引用计数也是 2:一份来自局部变量 b,一份来自 a 内部的a->b。main 结束时,局部变量 a、b 各自析构,把各自的计数从 2 减到 1。计数没有变成零,所以 A、B 两个对象都不会被销毁。
这不是异常分支,也不是多线程竞争,就是最普通的代码路径,照常泄漏。
1.2 为什么“作用域结束”没能救回来
很多初学者会困惑:局部变量 a 的作用域不是结束了吗?shared_ptr 的析构函数不是该执行了吗?没错,析构函数确实执行了,但那只是把“外部握着的这把引用”拔掉。环内部仍然互相推着对方,就像两个人都以为对方会先松手,结果谁也没松。
用一张图来理解:
- A 对象持有 B 对象的 shared_ptr,所以 B 的计数里多 1。
- B 对象持有 A 对象的 shared_ptr,所以 A 的计数里多 1。
- 外部不再有人持有它们时,两个计数都停留在 1。
- 引用计数永远不会自然降到 0,程序退出后,操作系统回收进程内存时才顺带清掉,但对长期运行的服务来说,这就是实实在在的内存泄漏。
这种问题尤其容易出现在父子关系、树形结构、双向链表、回调注册这些场景里。你很容易觉得“根节点清理掉,子节点就会跟着清理”,但实际代码里往往藏着一条反向的 shared_ptr,把根节点又撑住了。
1.3 shared_ptr 的控制块机制
想真正理解循环引用,得稍微往下看一层。shared_ptr 并不只是持有一个裸指针,它还会持有一个控制块。控制块里保存着两个和生命周期强相关的数据:
- 强引用计数(shared count):有多少个 shared_ptr 实例指向同一个对象。
- 弱引用计数(weak count):有多少个 weak_ptr 实例关联这个控制块,但我们先不展开。
当强引用计数归零时,控制块会立刻销毁对象,也就是调用delete或自定义删除器。注意,即使弱引用计数不为零,对象本身也会被销毁,只是控制块可能还暂时留着,给 weak_ptr 判断对象是否存活用。
所以,只要环里的强引用计数不为零,对象永远轮不到被 delete。哪怕你写了很多看似“清理”的代码,只要有一条 shared_ptr 路径没有断开,结果都是白搭。
2. weak_ptr 是怎么解开死结的:心智模型与核心解法
2.1 weak_ptr 的本质:只观察,不拥有
weak_ptr 通常和 shared_ptr 配合出现。它指向同一个控制块,但不增加强引用计数,因此它不会阻止对象被销毁。它只是提供了一种“安全观察”的手段:对象还活着,我通过lock()获得一个临时的 shared_ptr;对象已经死了,我得到空 shared_ptr。
所以正确的理解方式是:weak_ptr 是观察者,不是所有者。它是一根虚线箭头,画在所有权图上只是“看一眼”,而不是“扶一把”。
还是刚才那段代码,把 B 中的成员改成 weak_ptr:
#include <iostream> #include <memory> struct B; struct A { std::shared_ptr<B> b; ~A() { std::cout << "~A\n"; } }; struct B { std::weak_ptr<A> a; ~B() { std::cout << "~B\n"; } }; int main() { auto a = std::make_shared<A>(); auto b = std::make_shared<B>(); a->b = b; b->a = a; // 离开作用域后,a 的强引用计数为 1,来自 a->b 对 B 的依赖 // 但 b->a 是 weak_ptr,不增加 A 的强引用计数 // 所以局部变量 a 析构后,A 的强引用计数为 0,A 被销毁 // A 销毁时释放 a->b,于是 B 的强引用计数降为 0,B 也被销毁 }这次运行,会依次打印~A和~B,顺序可能略有不同,但两个析构函数都跑到了。
2.2 使用 weak_ptr 的铁律:必须 lock,不能裸取
weak_ptr 不能直接访问对象,必须调用lock()。lock 的语义是:如果对象还活着,返回一个 shared_ptr;如果对象已经销毁,返回空的 shared_ptr。
正确写法是:
if (auto sp = weakObj.lock()) { // 在 sp 生命周期内,对象一定不会被销毁 sp->DoSomething(); } else { // 对象已经不在了,按业务逻辑处理 }这里有个特别常见的坑:先调用expired()判断,再调用lock()。这不是不行,但存在竞态风险。假设另一个线程刚好在expired()返回 false 之后、lock()之前销毁了对象,那lock()可能返回空,或者更糟的情况是你用旧指针直接访问,行为未定义。所以不要分开写,直接lock()就行。如果对象已经死了,lock 会安全地给你空指针。
2.3 weak_ptr 的另一个隐藏事实:对象没了,控制块可能还在
很多资料只说 weak_ptr 不会增加引用计数,但没说它会不会产生额外生命周期。实际上,weak_ptr 会保存控制块信息,所以只要还有 weak_ptr 存活,控制块就不会立刻释放。控制块里有强引用计数、弱引用计数、删除器、分配器这些数据,通常很小,但也是内存。
如果你把一堆已经过期的 weak_ptr 长期放在全局容器里,那这些控制块就会一直留存。业务对象本身已经被销毁了,功能上不算泄漏,但从内存占用角度看,还是会造成堆积。定期清理过期 weak_ptr,是使用弱引用缓存时不能省略的一步。
3. 父子结构实战:双向链表与树形组件的改造过程
3.1 先画一张所有权图再动手
我在实际项目中见过太多人上来就改代码,把某个成员从 shared_ptr 改成 weak_ptr,改完发现还是泄漏,或者干脆崩了。原因是他们没搞清楚“谁拥有谁”。在动手之前,建议把对象之间的所有权图画出来:
- shared_ptr 箭头指向“我拥有你,你的生命周期由我决定”。
- weak_ptr 箭头或原始指针箭头指向“我可能要用你,但我不决定你的生死”。
- unique_ptr 箭头指向“我是唯一所有者,你不能被别人共享”。
画完之后,找图里的环。只要所有权环存在,就一定会在某个时刻泄漏。你要做的不是“把所有环都剪断”,而是“剪掉不必要的所有权,保留观察关系”。
举个例子,一个树形控件:
- Window 持有根 Control 的 shared_ptr。
- 根 Control 持有所有子 Control 的 shared_ptr。
- 每个 Control 需要知道自己属于哪个父 Control。
如果你用 shared_ptr parent_,那每个内部节点都会和它的父节点互相持有,形成一条反向所有权边,整棵树就是一个巨大的循环。正确设计是:父节点拥有子节点,子节点的 parent_ 用 weak_ptr。子节点需要访问父节点时再 lock。
3.2 树组件改造:父指针改成弱引用之后
改造之前,节点大概是这样的:
class TreeNode { public: void AddChild(std::shared_ptr<TreeNode> child) { child->parent_ = shared_from_this(); children_.push_back(std::move(child)); } private: std::shared_ptr<TreeNode> parent_; std::vector<std::shared_ptr<TreeNode>> children_; };这里的parent_就是典型的错误:父亲拥有孩子,孩子又反过来拥有父亲。哪怕外部把根节点释放,根节点的 children_ 还持有所有孩子,每个孩子又持回 parent_,环里的每个节点都不会被销毁。
改法是让parent_变成弱引用:
class TreeNode : public std::enable_shared_from_this<TreeNode> { public: void AddChild(std::shared_ptr<TreeNode> child) { child->parent_ = weak_from_this(); children_.push_back(std::move(child)); } std::shared_ptr<TreeNode> Parent() const { return parent_.lock(); } void RemoveAllChildren() { children_.clear(); } private: std::weak_ptr<TreeNode> parent_; std::vector<std::shared_ptr<TreeNode>> children_; };改动很小,却把所有权方向理顺了:所有权从根向下延伸,孩子知道父母是谁,但不拥有父母。当根节点被释放时,children_ 的析构会递归清空,每个子节点在销毁时会释放它自己的 shared_ptr 成员;反过来,子节点的 parent_ 是 weak_ptr,不会阻止父节点销毁。
这里要注意版本差异:weak_from_this()是 C++17 才有的。如果是 C++11 或 C++14,通常只能在对象已经被 shared_ptr 管理之后,通过shared_from_this()来创建一个临时 shared_ptr,再赋给 weak_ptr。构造函数里不能直接调用shared_from_this(),因为那时对象还没有被某个 shared_ptr 接管。这也是一个容易踩的小坑。
3.3 事件回调里的反向引用同样要处理
树形组件只是循环引用的一个典型代表。另一个我经常遇到的场景是事件源和监听器。
假设有一个事件源 EventSource,它用 shared_ptr 保存所有监听器:
class EventSource { std::vector<std::shared_ptr<Listener>> listeners_; };Listener 在构造时想反查 EventSource,于是很自然地写了一个std::shared_ptr<EventSource> source_。这就又形成循环了:EventSource 拥有 Listener,Listener 也拥有 EventSource。事件源销毁时,listeners_ 的计数不为零,因为每个 Listener 都还握着 source_;而 Listener 的计数也不为零,因为事件源还握着它。
这种场景的惯例是:事件源拥有监听器,监听器对事件源的回引用应该改成 weak_ptr。监听器需要发事件时,先 lock 一下,如果事件源已经不存在,就默默放弃这次回调,或者走失败逻辑。这比用裸指针更安全,因为裸指针没法判断事件源是否已经销毁。
4. 比 weak_ptr 更根本的思路:重新设计所有权边界
4.1 所有权图必须是 DAG,不能有环
weak_ptr 确实能解决很多循环引用,但它不是万能灵药,更不是唯一的解决办法。我在代码评审里见过一种倾向:大家遇到循环引用,第一反应就是“把某个 shared_ptr 换成 weak_ptr”。换完之后循环确实破了,但代码也变复杂了,每个访问点都要处理 lock 失败的情况。
如果能回到设计阶段,更好的做法是直接问:这两个对象之间,真的需要 shared_ptr 吗?谁创建谁?谁销毁谁?
如果所有权图是严格的有向无环图,Shared_ptr 环就不会出现。父对象负责创建和管理子对象,子对象不反向持有父对象,或者只持有弱引用,这本身就是对所有权边界的明确划分。shared_ptr 应该只在“真正需要共享所有权”的地方出现,而不是默认所有关联关系都用它。
4.2 优先用 unique_ptr 表达独占关系
很多所谓的“父子关系”并不需要共享所有权。父对象拥有子对象,子对象只属于父对象,这种情况用std::unique_ptr更合适。
class Owner { private: std::unique_ptr<Child> child_; };unique_ptr 不会产生循环引用,因为它是独占所有权,永远只有一个 owner。子对象需要访问父对象时怎么办?用一个非拥有的原始指针或 weak_ptr 都可以。Qualified。
原始指针的缺点是:如果子对象比父对象活得久,或者说父对象先销毁,而子对象还没有被释放,那这个原始指针就会悬空。但如果生命周期设计正确,比如父对象析构时必然先销毁子对象,原始指针就是零成本的观察者,比弱引用更轻量。
4.3 解决循环引用的三个层级
循环引用可以按层级来治理:
| 层级 | 手段 | 特点 |
|---|---|---|
| 设计层 | 单一所有者、所有权图 DAG、接口回调 | 从根本上避免,最推荐 |
| 代码层 | weak_ptr、unique_ptr、回调 token | 顺手,但要小心边界 |
| 应急层 | 引入“断开”方法,显式 reset 互相持有的 shared_ptr | 老代码过渡可用,风险最高 |
应急层里所谓的“断开”方法,就是给每个对象加一个Disconnect(),把所有 shared_ptr 成员全部 reset 掉。这个方法的问题在于,你必须保证所有路径都在合适的时机调用它。如果某个异常分支漏掉了,循环照样形成。对象析构函数里也不能安全地做这件事,因为析构时成员变量还在,但外部持有者可能已经不再触碰这个对象,贸然 reset 可能引发更隐蔽的问题。所以应急层只适合做短期过渡,不建议作为正式方案。
4.4 shared_from_this 可能隐藏的循环
另一个容易被忽略的场景是enable_shared_from_this。子类成员函数里调用shared_from_this()拿到一个 shared_ptr,把它存到某个回调对象里,而那个回调对象又被当前对象的一个成员持有,这就形成了一个反方向的强引用。表面上你没写shared_ptr<Foo> foo_成员,但std::function捕获了shared_from_this(),性质完全一样。
这种隐藏循环最难排查,因为代码里看不到“成员是 shared_ptr”,只看到“注册了一个回调”。在写异步任务、定时器、局域网消息回调时,务必检查这个 lambda 是被谁持有,是否和持有者形成了环。如果回调持有对象,对象又持有回调容器,你就要在回调容器中存 weak_ptr,或者用销毁时清理回调的方式打破循环。
5. 循环引用排查三板斧:从计数器到健壮测试
5.1 第一板斧:析构日志与对象计数器
循环引用最常见的表征是“析构函数没跑”。所以排查的第一步,就是让每个可疑类的析构函数留下痕迹,或者给类加一个全局计数器。
我自己的做法是写一个轻量的计数基类,专门用在排查阶段:
#include <atomic> #include <cassert> template <typename Derived> struct LeakWatch { static std::atomic<int> live; LeakWatch() { live.fetch_add(1); } LeakWatch(const LeakWatch&) { live.fetch_add(1); } ~LeakWatch() { live.fetch_sub(1); } static int LiveCount() { return live.load(); } }; template <typename Derived> std::atomic<int> LeakWatch<Derived>::live{0};然后把需要排查的类继承它:
struct Session : LeakWatch<Session> { // ... };测试结束时断言Session::LiveCount() == 0。如果不等,立刻知道有对象没释放,再配合析构日志定位到是哪个环节漏了。在模拟项目 X 的问题排查中,我就是靠这个计数器发现节点树退出后还剩 17 个对象没销毁,才顺着所有权图找到了一处回调循环。
5.2 第二板斧:内存检测工具
计数器有局限性:只能知道“对象没释放”,不能知道“为什么没释放”。这时候可以借助常规的内存检测工具。常见的组合是编译时打开 AddressSanitizer/LeakSanitizer,跑一遍准备关闭或退出系统的流程,工具就会报告有哪些内存在退出后仍然不可访问。
不过这类工具的报告需要人工解读。“definitely lost”“indirectly lost”通常意味着存在明确的未释放块,这里很可能就是循环引用。但工具不会直接告诉你“A 和 B 互相持有 shared_ptr”。它只是给你一个线索,你仍然要回到代码里画所有权图。
5.3 第三板斧:画所有权图,找闭环
真正定位循环引用的核心手段,还是打开代码,把所有成员变量里指向其他对象的 shared_ptr 列出来。每遇到一个 shared_ptr,就画一条实线箭头,指向它拥有的对象。画完以后,在图里找闭环。
检查的重点清单:
- 类成员中的 shared_ptr 指向了曾经保存过当前类对象指针的位置吗?
- lambda 捕获列表里是否捕获了 shared_ptr,而 lambda 又被当前类保存?
- 缓存池、观察者列表、回调表里是否长期保存 shared_ptr?
- 容器析构时,元素的析构是否会反向影响容器本身?
很多循环引用的根不在当前类,而在消息分发框架或插件系统里。如果 A 保存了 B,B 保存了一个回调,回调里捕获了 A 的 shared_ptr,看起来 A 和 B 没有直接互指,但还是形成了 A -> B -> 回调 -> A 的环。这种间接环最容易被漏掉。
6. 这些坑我替你踩过了:weak_ptr 的边界与注意事项
6.1 别把 lock 的结果随手降级成裸指针
weak_ptr 保护的是“lock 返回的 shared_ptr 生命周期内,对象一定存活”。一旦你把那个 shared_ptr 转换成裸指针,并让裸指针活到 shared_ptr 作用域之外,保护就失效了。
auto raw = weak.lock().get(); // 错误示范 if (raw) { raw->Do(); // 对象可能已经销毁 }上面这行代码,分号一结束,临时的 shared_ptr 就析构了,对象可能立刻被释放。raw 变成了悬空指针,后续访问就是未定义行为。正确写法是把 shared_ptr 先保存到一个局部变量里,在需要访问的整个区间都持有它。
其实更推荐直接在一个 if 作用域里做完所有访问:
if (auto sp = weak.lock()) { sp->DoSomething(); sp->Update(); }6.2 不要重复检查 expired 和 lock
我见过不少代码这样写:
if (!weak.expired()) { weak.lock()->DoSomething(); }这是典型的非原子“先判断后使用”。在多线程环境下,expired 返回 false 之后,另一个线程完全可能把最后一个 shared_ptr 释放掉,让对象在lock()之前就销毁。虽然lock()安全地返回空,但你调用->DoSomething()的那个语句用的是weak.lock()的返回值,这个临时 shared_ptr 可能为空,直接解引用空指针,照样崩。
所以唯一安全的写法只有一种:
if (auto sp = weak.lock()) { sp->DoSomething(); }lock()本身就是原子操作,自带了“判断对象是否存活”和“获取强引用”两个步骤。不要自己拆开。
6.3 热路径里频繁 lock 是可行的,但要注意开销
weak_ptr::lock 涉及原子操作,和裸指针相比确实有开销。我见过一个高频回调里每次都 lock,本地测试发现性能下降了一截。但这不意味着不能用,而是要把锁定次数降下来。
合理做法是:在某个局部作用域内先 lock 一次,得到一个 shared_ptr,然后所有访问都用这个 shared_ptr。循环里如果反复需要访问弱引用对象,就把 shared_ptr 提到循环外面,而不是每轮循环都 lock 一次。
6.4 弱引用容器要定期清理过期项
如果某个全局管理器用 weak_ptr 记录已经创建过的对象,方便之后查找,那你就能遇到“对象销毁后,weak_ptr 依然存在”的情况。前面说过,这些 weak_ptr 会让控制块继续存活。虽然业务对象本身的内存释放了,但控制块和 weak_ptr 自身还占着空间。长期运行的系统里,这种“弱引用控制块泄漏”也会积少成多。
建议定期遍历缓存容器,把expired()的项移除。有些实现会在查找时顺手清理,也可以单独做一个定时清理任务。这个问题在把 weak_ptr 用作“对象池缓存”时尤其明显。
6.5 析构顺序仍然要管
即便是 weak_ptr 方案,也不意味着你可以完全撒手不管对象图的销毁顺序。weak_ptr 只是“不阻止对象销毁”,但业务逻辑还得保证:某个对象在使用弱引用访问另一个对象时,另一个对象不应该刚好被销毁。
举个例子,一个子任务正在执行,它的回调持有父任务的 weak_ptr。如果父任务被一个线程置为销毁状态,而子任务线程在 lock 父任务时拿到空指针,那代码必须有能力处理这种“父任务已经没了”的情况。常见做法是返回默认值、跳过本次操作、或者重新进入初始化流程。千万不要假设 lock 一定非空。
6.6 代码评审时,我建议先问两个问题
现在我回头看别人的代码,只要发现两个类互相出现 shared_ptr 成员,我一般不会急着建议改成 weak_ptr。我通常会先问这两个问题:
- 谁创建了谁?
- 谁销毁谁?
答案通常很明确:父组件创建并销毁子组件,所以子组件回指父组件的引用必须是弱引用;容器创建并销毁元素,所以元素回指容器的引用必须是弱引用;回调发起方拥有回调对象,所以回调对象回指发起方的引用必须是弱引用。
如果这两个问题都回答不上来,那这个对象图本身就设计得不清不楚。比起改一行 weak_ptr,更需要重新梳理生命周期和所有权边界。
我在实际项目中最终的体会是:weak_ptr 是解决问题的好工具,但你真正想要的是“让代码里根本没有循环引用可解决”。早期定好所有权方向,比后期到处加 weak_ptr 要省太多力气。如果代码库已经存在这种历史包袱,那至少要把每一处 weak_ptr 的用途写清楚,并在代码审查时明确标注:这条弱引用是观察关系,不是所有权关系。减少歧义,也就减少了下一轮“循环引用”复发的概率。