先说个结论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_ptrB b; ~A() { std::cout ~A\n; } }; struct B { std::shared_ptrA a; ~B() { std::cout ~B\n; } }; int main() { auto a std::make_sharedA(); auto b std::make_sharedB(); 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_ptrB b; ~A() { std::cout ~A\n; } }; struct B { std::weak_ptrA a; ~B() { std::cout ~B\n; } }; int main() { auto a std::make_sharedA(); auto b std::make_sharedB(); a-b b; b-a a; // 离开作用域后a 的强引用计数为 1来自 a-b 对 B 的依赖 // 但 b-a 是 weak_ptr不增加 A 的强引用计数 // 所以局部变量 a 析构后A 的强引用计数为 0A 被销毁 // A 销毁时释放 a-b于是 B 的强引用计数降为 0B 也被销毁 }这次运行会依次打印~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_ptrTreeNode child) { child-parent_ shared_from_this(); children_.push_back(std::move(child)); } private: std::shared_ptrTreeNode parent_; std::vectorstd::shared_ptrTreeNode children_; };这里的parent_就是典型的错误父亲拥有孩子孩子又反过来拥有父亲。哪怕外部把根节点释放根节点的 children_ 还持有所有孩子每个孩子又持回 parent_环里的每个节点都不会被销毁。改法是让parent_变成弱引用class TreeNode : public std::enable_shared_from_thisTreeNode { public: void AddChild(std::shared_ptrTreeNode child) { child-parent_ weak_from_this(); children_.push_back(std::move(child)); } std::shared_ptrTreeNode Parent() const { return parent_.lock(); } void RemoveAllChildren() { children_.clear(); } private: std::weak_ptrTreeNode parent_; std::vectorstd::shared_ptrTreeNode children_; };改动很小却把所有权方向理顺了所有权从根向下延伸孩子知道父母是谁但不拥有父母。当根节点被释放时children_ 的析构会递归清空每个子节点在销毁时会释放它自己的 shared_ptr 成员反过来子节点的 parent_ 是 weak_ptr不会阻止父节点销毁。这里要注意版本差异weak_from_this()是 C17 才有的。如果是 C11 或 C14通常只能在对象已经被 shared_ptr 管理之后通过shared_from_this()来创建一个临时 shared_ptr再赋给 weak_ptr。构造函数里不能直接调用shared_from_this()因为那时对象还没有被某个 shared_ptr 接管。这也是一个容易踩的小坑。3.3 事件回调里的反向引用同样要处理树形组件只是循环引用的一个典型代表。另一个我经常遇到的场景是事件源和监听器。假设有一个事件源 EventSource它用 shared_ptr 保存所有监听器class EventSource { std::vectorstd::shared_ptrListener listeners_; };Listener 在构造时想反查 EventSource于是很自然地写了一个std::shared_ptrEventSource source_。这就又形成循环了EventSource 拥有 ListenerListener 也拥有 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_ptrChild 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_ptrFoo 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::atomicint 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::atomicint LeakWatchDerived::live{0};然后把需要排查的类继承它struct Session : LeakWatchSession { // ... };测试结束时断言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 保存了 BB 保存了一个回调回调里捕获了 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 的用途写清楚并在代码审查时明确标注这条弱引用是观察关系不是所有权关系。减少歧义也就减少了下一轮“循环引用”复发的概率。