ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++ weak_ptr 详解:破解循环引用与实现安全观察者模式

2026/8/25 17:04:27 拓冰建站 浏览量
C++ weak_ptr 详解:破解循环引用与实现安全观察者模式 1. 从一次内存泄漏的排查说起那天下午我正为一个线上服务的内存使用率缓慢爬升而头疼。服务逻辑不复杂但每隔几天就需要重启一次否则就会因为内存耗尽而崩溃。用 Valgrind 跑了一遍报告里确实有几个可疑的“possibly lost”块但指向的都是一些全局的、长期存活的管理器对象线索很模糊。直到我把注意力转向一个缓存模块这个模块维护着一组用户会话对象的共享指针shared_ptr当会话结束时理论上缓存项应该被自动清理。但我在析构函数里加了日志发现这些对象“赖着不走”了。问题的根源很快就锁定了缓存管理器持有着shared_ptrUserSession而每个UserSession对象内部又通过一个回调接口持有了一个指向缓存管理器自身的shared_ptr。这就形成了一个经典的循环引用——A 拥有 BB 也拥有 A。即使外部所有对它们的引用都消失了由于它们互相“拉着”对方引用计数永远无法归零导致内存泄漏。这正是std::shared_ptr的软肋。当时我的第一反应是去审视设计看能否打破这种双向的强依赖关系。而 C11 标准库提供的解决方案就是std::weak_ptr。weak_ptr中文常译作“弱智能指针”或“弱引用指针”。它的“弱”体现在哪里简单说它不“占有”对象不增加对象的引用计数。你可以把它想象成一张“观察券”或者一个“对象监视器”。它知道对象在哪里如果对象还存在可以随时去查询对象的状态但它没有权力阻止对象的销毁。当最后一个持有该对象的shared_ptr被销毁时对象就会被回收而此时所有指向它的weak_ptr都会自动感知到这一点进入“过期”状态。理解weak_ptr绝不能脱离shared_ptr。它们是协同工作的“黄金搭档”。shared_ptr提供了基于引用计数的自动内存管理解决了“谁最后使用谁负责删除”的难题。而weak_ptr则是在此基础上进一步解决了“循环引用”和“临时观察”这两个shared_ptr无法妥善处理的高级场景。很多初学者包括几年前的我自己在刚接触智能指针时往往只重视shared_ptr和unique_ptr觉得weak_ptr有些边缘。但实际上在稍微复杂一点的、存在对象间关联的系统里weak_ptr是构建健壮、无泄漏内存模型的关键工具。接下来我们就深入它的世界看看这张“观察券”到底该怎么用以及如何避开使用它的那些坑。2. weak_ptr 的核心机制不参与管理的“观察者”要用好weak_ptr首先得彻底理解它与shared_ptr的关系以及它自身独特的状态生命周期。这比单纯记忆几个成员函数重要得多。2.1 与 shared_ptr 的共生关系一个weak_ptr必须从一个shared_ptr或者另一个weak_ptr构造而来。它指向的是shared_ptr所管理的那个原始对象控制块而不是直接指向原始对象本身。这里有一个关键的数据结构控制块control block。当我们创建一个shared_ptr时如果它是第一个指向该对象的shared_ptr就会在堆上分配一个控制块。这个控制块里至少包含两个引用计数器强引用计数use_count记录有多少个shared_ptr正指向该对象。此计数归零时对象被销毁。弱引用计数weak_count记录有多少个weak_ptr以及控制块自身正指向该控制块。此计数归零时控制块被销毁。weak_ptr的构造会增加弱引用计数但绝不影响强引用计数。这就是它“弱”的本质。你可以拥有任意多个weak_ptr观察同一个对象但这不会延长该对象的寿命。对象的生死完全由shared_ptr家族强引用决定。#include memory #include iostream class MyClass { public: ~MyClass() { std::cout MyClass destroyed.\n; } }; int main() { std::shared_ptrMyClass sp1 std::make_sharedMyClass(); std::cout sp1 use_count: sp1.use_count() std::endl; // 输出: 1 // 从 shared_ptr 构造 weak_ptr std::weak_ptrMyClass wp1(sp1); std::cout sp1 use_count after wp1 created: sp1.use_count() std::endl; // 输出: 1 (未变!) std::cout wp1 expired? std::boolalpha wp1.expired() std::endl; // 输出: false // sp1 离开作用域强引用归零 { std::shared_ptrMyClass sp2 sp1; std::cout sp1 use_count after sp2 created: sp1.use_count() std::endl; // 输出: 2 } // sp2 销毁强引用减为1 std::cout sp1 use_count after sp2 destroyed: sp1.use_count() std::endl; // 输出: 1 } // sp1 销毁强引用归零MyClass 对象在此处被销毁 // 程序结束控制块可能仍存在因为 wp1 还在直到 wp1 也被销毁弱引用归零控制块才销毁。这段代码清晰地展示了weak_ptr的旁观者角色。wp1的存在丝毫不影响MyClass对象在sp1销毁时被回收。2.2 状态转换与过期检查一个weak_ptr可能处于两种状态有效未过期它所观察的对象仍然存在即至少还有一个shared_ptr指向它。过期expired它所观察的对象已经被销毁所有指向它的shared_ptr都已消亡。判断状态的成员函数是expired()。它返回一个bool值true表示已过期。这是一个快速、低成本的检查因为它只读取控制块中的强引用计数是否为0。注意expired()在多线程环境下存在竞态条件。即使你调用expired()得到false在下一行代码尝试使用该对象时另一个线程可能已经释放了最后一个shared_ptr导致对象被销毁。因此绝不能依赖if (!wp.expired()) { // 然后直接使用对象 }这样的模式。正确的、线程安全的方式是下一节要讲的lock()。2.3 安全获取可用的 shared_ptrlock() 操作这是weak_ptr最核心、最常用的操作。lock()成员函数尝试将“观察券”升级为“所有权凭证”。它的行为是原子性的检查weak_ptr是否过期强引用计数 0。如果未过期则创建一个新的shared_ptr该shared_ptr与原始的shared_ptr共享对象所有权因此会增加强引用计数然后返回这个新的shared_ptr。如果已过期则返回一个空的shared_ptr。std::weak_ptrMyClass wp; // ... 某个地方wp 被赋值指向某个对象 ... // 正确且线程安全的用法 std::shared_ptrMyClass sp_temp wp.lock(); if (sp_temp) { // 对象确定存在可以安全使用 sp_temp sp_temp-doSomething(); } else { // 对象已被销毁进行清理或错误处理 std::cout Object no longer exists.\n; }lock()的返回值直接告诉你操作是否成功。如果成功在if语句的作用域内你持有的sp_temp保证了对象不会被意外销毁。这是将“可能失效的观察”转化为“临时强引用”的标准做法。3. 破解循环引用weak_ptr 的经典应用场景回到开头的案例循环引用是shared_ptr的“天敌”而weak_ptr是标准的“解药”。其核心思想是在构成环的引用链中将至少一个引用从shared_ptr改为weak_ptr从而打破强引用的闭环。3.1 案例剖析父子节点与缓存管理器场景一双向关联的树形或图结构假设我们有一个树节点类TreeNode每个节点需要知道其父节点和子节点。// 错误示范循环引用导致内存泄漏 class BadTreeNode { std::shared_ptrBadTreeNode parent; std::vectorstd::shared_ptrBadTreeNode children; // ... 其他数据 ... };当构建一个父子关系时parent和child互相持有对方的shared_ptr引用计数永远 1无法释放。正确设计子对父使用 weak_ptrclass GoodTreeNode { std::weak_ptrGoodTreeNode parent; // 关键修改 std::vectorstd::shared_ptrGoodTreeNode children; // ... 其他数据 ... public: void setParent(std::shared_ptrGoodTreeNode newParent) { parent newParent; // 弱引用赋值 if (newParent) { newParent-children.push_back(shared_from_this()); // 假设此类继承自 enable_shared_from_this } } std::shared_ptrGoodTreeNode getParent() const { return parent.lock(); // 安全获取 } };在这个设计中子节点通过weak_ptr观察父节点。父节点的生命周期由外部或其他结构管理不因子节点的观察而延长。当需要访问父节点时通过lock()获取一个临时的shared_ptr。这完美地表达了“子节点知晓父节点但父节点的存在不依赖于子节点”的语义。场景二观察者模式与回调我的缓存管理器案例就是这种场景的变体。对象 A 注册到管理器 BB 需要回调 A。如果双方都用shared_ptr持有对方就形成循环。class CacheManager; // 前向声明 class CacheableObject { public: virtual ~CacheableObject() default; void setManager(std::shared_ptrCacheManager mgr) { manager_ mgr; // 这里如果用 shared_ptr 就错了 } private: std::weak_ptrCacheManager manager_; // 正确弱引用持有管理器 // 当需要回调管理器时 if (auto mgr manager_.lock()) { mgr-onEvent(*this); } }; class CacheManager { std::vectorstd::shared_ptrCacheableObject objects_; // ... };CacheableObject通过weak_ptr观察CacheManager。管理器控制着对象的生命周期持有shared_ptr而对象只是“知道”管理器的存在并不阻止其销毁。当管理器被销毁后对象的weak_ptr会过期后续的回调尝试会因lock()失败而安全跳过。3.2 设计原则所有权与观察权的分离使用weak_ptr时心里要有一张清晰的“所有权关系图”。问自己两个问题谁拥有这个对象即谁负责这个对象的生命周期答案应该对应到一到多个shared_ptr。所有权关系通常是树状或层次化的。谁需要知道这个对象即哪些其他对象需要访问它但不应影响它的生死这些地方就应该使用weak_ptr。一个好的经验法则是如果关系是单向的、或附属性的考虑使用weak_ptr。例如子节点对父节点、雇员对部门、订单对客户客户存在与否不应影响订单对象的销毁逻辑、UI控件对数据模型等。4. 进阶用法与性能考量weak_ptr并非零成本抽象理解其内部机制有助于在性能敏感场景下做出正确决策。4.1 控制块的生命周期与内存开销这是weak_ptr一个容易被忽略的细节。控制块包含强弱引用计数的内存是在第一个指向某对象的shared_ptr创建时分配的例如通过std::make_shared或std::shared_ptrT(new T)。这个控制块会一直存在直到弱引用计数也归零。 这意味着即使对象本身早已被销毁强引用归零只要还有weak_ptr指着它控制块就不会被释放。在极端情况下如果大量创建又很快销毁对象但同时留下了许多未及时清理的weak_ptr可能会导致控制块内存的累积虽然对象内存已释放。// 假设一个大对象 class LargeObj { char data[1024*1024]; }; void potentialOverhead() { std::weak_ptrLargeObj wp; { auto sp std::make_sharedLargeObj(); // 分配1. LargeObj对象内存 2. 控制块内存 wp sp; } // sp 销毁LargeObj对象内存被释放。但控制块内存还在因为 wp 存在。 // ... 如果 wp 长期不释放控制块就长期占用内存。 } // wp 销毁弱引用归零控制块内存最终被释放。std::make_shared有一个优化它可以将对象和控制块分配在单块连续内存中。这提高了性能但也意味着对象内存和控制块内存必须同时释放。如果使用了make_shared且存在weak_ptr那么对象内存的释放也要等到最后一个weak_ptr销毁。对于非常大的对象这可能不是期望的行为。此时可以考虑使用std::shared_ptrT(new T)分开分配但会牺牲一些性能。4.2 weak_ptr 作为缓存索引这是weak_ptr一个非常巧妙的用法。想象一个资源缓存如图片、字体、数据库连接缓存本身持有资源的shared_ptr保证活跃资源不被释放。当客户端请求资源时缓存返回一个weak_ptr给客户端。客户端通过lock()使用资源。如果lock()成功说明资源仍在缓存中可以安全使用。如果资源因为长时间未用被缓存淘汰缓存释放了它的shared_ptr客户端下次lock()时会失败此时它可以重新向缓存申请。这样做的好处是客户端不直接持有资源的强引用避免了客户端忘记释放导致资源无法从缓存中淘汰的问题。缓存完全掌握着资源的生命周期。4.3 与 enable_shared_from_this 的协作std::enable_shared_from_this是一个混入类用于在对象内部安全地获取指向自身的shared_ptr。它内部就使用了weak_ptr机制。 当你有一个继承自enable_shared_from_thisT的类并且该对象已经由某个shared_ptr管理时你可以在成员函数中调用shared_from_this()来获得一个共享所有权的shared_ptr。这个功能在传递this指针到回调函数时至关重要可以避免因回调持有裸指针而对象已被销毁的悬垂指针问题。weak_from_this()是 C17 引入的配套函数它返回一个指向自身的weak_ptr用于观察场景非常方便。class Session : public std::enable_shared_from_thisSession { std::weak_ptrSession self_weak_; public: Session() : self_weak_(weak_from_this()) {} // C17 void setupCallback() { // 将 weak_ptr 传递给异步操作 someAsyncOperation([weak_self weak_from_this()]() { if (auto self weak_self.lock()) { self-onOperationComplete(); // 安全回调 } }); } void onOperationComplete() { /* ... */ } };5. 实战中的陷阱与最佳实践即使理解了原理在实际编码中围绕weak_ptr仍有不少坑需要留意。5.1 线程安全须知如前所述expired()和lock()的单独使用都不是线程安全的组合。唯一线程安全的操作模式是调用lock()。这个操作本身是原子的。检查其返回的shared_ptr是否为空。使用这个shared_ptr。不要尝试先expired()再lock()中间的状态可能已经改变。5.2 默认构造与空状态一个默认构造的weak_ptr或者从一个空shared_ptr构造的weak_ptr是“空”的。它不观察任何对象。对这样的weak_ptr调用lock()会返回一个空的shared_ptrexpired()在 C11 及以后的标准中返回true因为没什么可过期的。在使用前最好检查一下它是否是从有效的shared_ptr构造而来。5.3 生命周期管理谁负责清理 weak_ptrweak_ptr本身是一个栈对象或类的成员它的生命周期由常规的 C 作用域规则管理。你需要确保weak_ptr不会比它观察的对象的控制块存活得更久虽然这通常不是问题因为控制块存活更久。一个常见的错误是在类中持有大量的weak_ptr成员却从不清理导致控制块堆积。对于缓存索引这类场景需要定期清理那些expired()的weak_ptr。5.4 性能影响与测量weak_ptr的操作构造、析构、赋值、lock()都需要操作控制块涉及原子操作因此比裸指针和unique_ptr慢。在绝大多数应用场景中这种开销可以忽略不计。但在极高频、性能至上的核心路径例如每帧调用数万次的游戏循环或超低延迟的交易系统需要谨慎评估。 如果确定要用一个优化点是避免频繁创建/销毁weak_ptr。可以将其作为成员变量缓存起来而不是在函数内部临时创建。lock()的开销相对固定但仍然是原子操作。5.5 设计模式替代方案思考weak_ptr不是解决对象间引用的唯一方案。在某些场景下替代方案可能更合适原始指针如果对象的生命周期由明确的、更高层次的结构如一个全局容器或一个单例管理器保证并且访问该对象的代码其执行时间肯定在该对象的生命周期内那么使用原始指针或引用可能更简单、高效。但这需要严格的设计纪律。ID/句柄在游戏引擎或大型系统中常用一个不透明的ID如整数或GUID来引用对象。通过一个中央管理器用ID来查找对象。这完全解耦了引用关系但增加了间接查找的开销。重新设计有时循环引用暴露出设计上的问题。是否真的需要双向引用能否通过事件、消息总线或中介者模式来解耦对象间的直接通信重构设计可能比引入weak_ptr更彻底。在我处理的那个缓存管理器案例中最终修复方案就是将UserSession内部持有的shared_ptrCacheManager改为了weak_ptrCacheManager。修改后上线内存泄漏曲线立刻变得平坦。这个经历让我深刻体会到在基于shared_ptr构建的系统中weak_ptr不是可选项而是必需品。它让你在享受自动内存管理的便利时依然能构建复杂、灵活的对象关系网络而不用担心陷入循环引用的泥潭。它就像给你的代码加了一道保险让你能大胆地表达对象间的“知晓”关系而无需背负“生死与共”的责任。