☰
深入理解weak_ptr:打破shared_ptr循环引用的关键钥匙
2026/10/21 9:48:21 网站建设 项目流程

先说一个我自己的真实经历。前几年做某个网络服务模块的内存优化,压力测试跑一晚上,内存占用直线上升,明显是泄漏。当时排查了半天,一个个shared_ptr的引用链拆下来看,最后发现是两个类互相持有对方的shared_ptr,形成了循环引用。引用计数成了“你等我释放,我等你释放”的死局,谁都不肯先归零,内存就这么被白白占住。那次之后,我把weak_ptr从头到尾啃了一遍,才发现这东西不是“智能指针全家桶”里凑数的角色,而是专门解决这类生命周期困局的钥匙。

这篇内容围绕weak_ptr展开:先说清楚循环引用到底怎么发生,再拆解weak_ptr在底层干了什么,然后给出几个真正适合它出场的实战场景,最后聊聊我在项目里踩过的那些坑。适合正处于“会用shared_ptr但没搞透生命周期管理”阶段的C++开发者,也适合准备在项目里落地智能指针规范的朋友。

1. 循环引用的本质:引用计数不是万能的

1.1 一段典型的内存泄漏代码

很多人第一次接触shared_ptr,觉得它简直完美:自动管理内存、拷贝赋值自由、引用计数归零自动释放。确实,单方向的持有关系用起来很顺手,但一旦两个对象互相持有对方的shared_ptr,问题就来了。看下面这段再典型不过的代码:

#include <memory> #include <iostream> class Node; // 前置声明 class Node { public: std::shared_ptr<Node> next; std::shared_ptr<Node> prev; int value = 0; explicit Node(int v) : value(v) { std::cout << "Node 构造: " << value << std::endl; } ~Node() { std::cout << "Node 析构: " << value << std::endl; } }; void createCycle() { auto a = std::make_shared<Node>(1); auto b = std::make_shared<Node>(2); a->next = b; // b 被 a 持有 b->prev = a; // a 被 b 持有 // 函数结束,a、b 本应析构,但这里不会析构 } int main() { std::cout << "=== 开始 ===" << std::endl; createCycle(); std::cout << "=== 结束 ===" << std::endl; // 你会看到没有输出 Node 析构 return 0; }

运行这段代码,你会发现构造信息打了两行,但析构信息一行都没有。a和b在createCycle结束时局部变量销毁,但它们各自的控制块里强引用计数还是1,因为对方还持有自己。于是两个对象就这么“抱团取暖”直到进程结束,这段内存在程序生命周期内永远回不到系统手里。

很多同学的第一反应是:那我手动reset()一下不就行了?比如在函数末尾加一句a->next.reset()。对,针对这个场景确实能解决。但真实项目里,对象之间的引用关系往往横跨多个模块、多条业务链路,你根本不知道某个shared_ptr在哪个时间点被谁复制了一份。手动管理这些细节等于把智能指针退化成了裸指针,该泄漏还是泄漏。

1.2 为什么引用计数会陷入“死锁”

要理解这个问题,得看清shared_ptr的引用计数机制本质。每个shared_ptr指向一个控制块,控制块里记录两件事:强引用计数(当前有多少个shared_ptr指向这个对象)和弱引用计数(当前有多少个weak_ptr指向这个控制块)。

当一个shared_ptr被复制、赋值或销毁时,强引用计数做相应增减。强引用计数归零,对象被析构并释放内存;控制块本身是否删除,还得看弱引用计数。这个机制设计得很严密,但它有一个前提假设:引用关系构成一个有向无环图。一旦出现环,逻辑上的“所有权”就模糊了——A的所有权属于B,B的所有权又属于A,系统无法判定谁该先释放。

这里有个很直观的类比:两个人各自拿着对方家的钥匙,都说“你先开门我才开门”,结果谁也进不了屋。weak_ptr就是来打破这种僵局的——它负责“看着对方”,但不声称自己“拥有对方”。

2. weak_ptr的内部工作方式:控制块里的第二个计数器

2.1 weak_ptr到底是什么

一句话先总结:weak_ptr是一个不拥有对象所有权的智能指针,它只能“观察”一个由shared_ptr管理的对象,不能直接解引用。要访问对象,必须临时借助lock()提升为shared_ptr。

为什么weak_ptr能够打破循环?因为它构造和析构时,只修改控制块里的弱引用计数,不碰强引用计数。这意味着,即使B持有一个指向A的weak_ptr,A的强引用计数也不会增加。当A的强引用计数归零,A照常析构;而B手里的weak_ptr会自动变成“失效状态”,也就是expired()返回true。

这个机制的关键价值在于:weak_ptr允许你在不延长对象生命周期的情况下判断对象是否还活着,并且能在确认活着的前提下安全地临时获得一个强引用。这正是解决循环引用的根本思路——把环中的一条边从“强持有”降级为“弱观察”。

看一段对照代码:

#include <memory> #include <iostream> class Node { public: std::weak_ptr<Node> prev; std::shared_ptr<Node> next; int value = 0; explicit Node(int v) : value(v) { std::cout << "Node 构造: " << value << std::endl; } ~Node() { std::cout << "Node 析构: " << value << std::endl; } }; void createCycle() { auto a = std::make_shared<Node>(1); auto b = std::make_shared<Node>(2); a->next = b; // 强持有 b->prev = a; // 弱观察,不再强持有 std::cout << "a 强引用计数: " << a.use_count() << std::endl; // 输出 1 } int main() { std::cout << "=== 开始 ===" << std::endl; createCycle(); std::cout << "=== 结束 ===" << std::endl; return 0; }

这次运行结果里,a的use_count()是1而不是2,说明prev并没有增加强引用计数。createCycle结束时,a的强引用计数归零析构,b的强引用计数也归零析构。循环被打破了。

2.2 控制块、lock()与线程安全的边界

weak_ptr访问对象的唯一正规路径是调用lock()。lock()返回一个shared_ptr;如果对象还活着,这个返回的shared_ptr会让强引用计数加一;如果对象已经析构,返回一个空的shared_ptr。

有人会想:先调用expired()检查,再调用lock(),不就行了吗?问题是这两步之间对象可能恰好被其他线程释放。expired()返回true,你正打算放弃;但它返回false的瞬间,另一个线程可能把最后一个强引用释放了,等你调lock()时对象已经没了。所以expired()和lock()之间有竞态窗口。

正确姿势是:直接使用lock()的返回值判断。lock()内部是在控制块上进行同步的,要么成功增加强引用计数,要么知道对象已死返回空指针,这两件事是原子的。实际代码里,你几乎不需要单独调用expired(),if (auto sp = wptr.lock()) { ... }就够了。

多线程环境下还有一点容易被忽略:多个线程各自拷贝同一个weak_ptr是安全的,因为拷贝不会修改强引用计数。但如果你要把同一个weak_ptr绑定到某个回调函数里,要留意回调触发时对象可能已经不在了——这正是需要lock()的场景。

2.3 为什么说weak_ptr“不能直接解引用”

weak_ptr不提供operator*和operator->,这不是API设计的疏漏,而是有意为之。如果允许直接解引用,你就可能在对象已经析构的情况下访问到悬垂内存,编译器都帮不了你。lock()这一步强制你思考一个问题:你现在真的需要这个对象的强引用吗?如果需要,就说明访问期间你应该保证它活着,那么把它提升为shared_ptr是唯一正确的方式。

提示:任何“绕过lock()直接使用weak_ptr底层指针”的操作都是在玩火,轻则崩溃,重则内存被重复释放。我在代码审查里看到这种写法,一律打回重写。

3. 实战选型:三个真正适合weak_ptr的场景

3.1 观察者模式中的反向引用

观察者模式是weak_ptr最典型的应用场景之一。很多项目里有这样一个结构:主题对象维护一组观察者列表,事件发生时通知所有人。观察者需要注册主题,但如果不小心让主题反向持有了观察者的shared_ptr,就可能出现主题持有观察者、观察者又持有主题的循环。

正确做法是:主题内部的观察者列表存储weak_ptr。这样观察者被外部业务逻辑释放后,主题列表里留下的只是一个失效的weak_ptr,通知时用lock()提升,提升失败就跳过并顺手清理。这里有个我在某消息分发模块里的简化模型:

#include <memory> #include <vector> class EventListener { public: virtual ~EventListener() = default; virtual void onEvent(int eventId) = 0; }; class EventDispatcher { public: void addListener(const std::shared_ptr<EventListener>& listener) { listeners_.push_back(listener); } void dispatch(int eventId) { for (auto it = listeners_.begin(); it != listeners_.end();) { auto sp = it->lock(); if (sp) { sp->onEvent(eventId); ++it; } else { it = listeners_.erase(it); // 顺带清理失效观察者 } } } private: std::vector<std::weak_ptr<EventListener>> listeners_; };

这个设计解决了两个痛点:第一,观察者不会因为被主题持有而无法释放;第二,通知时不再需要观察者显式做“反注册”,失效自动清理。代价只是每次通知要做一次lock(),性能上可忽略。

3.2 缓存与工厂:内存复用与失效重建

缓存是另一个特别适合weak_ptr的场景。缓存对象希望“在外部还有人用的时候复用同一份实例”,但“当所有外部引用都消失时,缓存本身不应该继续霸占内存”。这正好和weak_ptr一拍即合。

举个例子,某数据解析模块里,同一份配置想被多处代码共享,但配置对象不应该永久驻留。工厂函数内部用一个weak_ptr记录当前缓存,外部每次请求时先尝试从缓存拿,拿不到再创建。

#include <memory> #include <unordered_map> class Config { public: explicit Config(std::string data) : data_(std::move(data)) {} std::string data_; }; class ConfigFactory { public: std::shared_ptr<Config> getConfig(const std::string& key) { auto it = cache_.find(key); if (it != cache_.end()) { auto sp = it->second.lock(); if (sp) { return sp; } cache_.erase(it); // 缓存已失效,删除占位 } auto sp = std::make_shared<Config>("config-data-" + key); cache_[key] = sp; // 用 weak_ptr 记录 return sp; } private: std::unordered_map<std::string, std::weak_ptr<Config>> cache_; };

这个模式在业务里不罕见。对象被外部长期持有时,后续每次获取都是同一份实例,减少了重复构造开销;一旦外部全部释放,缓存也随之失效,不构成额外内存负担。

3.3 树形结构与父子关系:谁强谁弱要分清楚

树、图、双向链表这类结构,天然有“父子”“前后”关系。如果所有边都用shared_ptr,任何双向关系都会形成环。我的实践经验是:方向性层级关系必须确定一条“所有权边”和若干“反向引用边”。比如树结构中,父节点强持有子节点,子节点通过weak_ptr回指父节点。这样整棵树的释放可以从根节点开始顺理成章地传播下去。

回到开头那个Node的例子,next保持shared_ptr,prev换成weak_ptr就是同一思路。再复杂一些,资源管理器持有一组资源对象,每个资源对象需要知道自己的管理器——此时资源对象里保存一个管理器的weak_ptr即可,避免管理器与资源对象互相强持有。

这个选型原则一句话概括:从“所有者”指向“被所有者”的方向用shared_ptr,反方向一律弱化。如果你发现某个对象持有另一个对象却不承担释放责任,那这条边就应该用弱引用。

4. 实战中容易踩的坑

4.1 从shared_ptr反向构造weak_ptr的隐患

weak_ptr只能从shared_ptr构造(或从另一个weak_ptr拷贝构造)。初始化一个weak_ptr时,它会指向同一个控制块,但这不会让强引用计数增加。问题来了:如果你用一个临时的shared_ptr去构造weak_ptr,临时对象销毁后强引用计数可能归零,你的weak_ptr立刻失效。

看这个场景:

std::weak_ptr<Node> wptr; void init() { auto tmp = std::make_shared<Node>(42); wptr = tmp; // 函数结束,tmp 销毁,强引用计数归零,wptr 失效 } void use() { auto sp = wptr.lock(); if (sp) { // 不会进入这里,因为对象已经没了 } }

这种代码的教训是:weak_ptr不保证对象一定活着,它只保证“活着时我能安全地拿到强引用”。所以在构造后立刻检查expired()是有意义的,但更关键的是想清楚对象的强引用到底由谁维持。

4.2 enable_shared_from_this里的weak_this机制

很多人第一次用enable_shared_from_this时,会在构造函数里直接调用shared_from_this(),结果程序抛异常终止。原因是基类内部保存的weak_this是“弱引用”,而构造函数执行期间对象还没有被任何shared_ptr管理,弱引用里自然是空的。

这个限制很多人踩过。我给自己定过一条规矩:绝不能在构造函数中调用shared_from_this();如果确实需要在初始化阶段拿到自身强引用,把对象构造拆成两步,或者在外部先make_shared再调用显式初始化方法。

另外注意,enable_shared_from_this要求对象必须由shared_ptr管理。如果你在栈上创建对象,然后调用shared_from_this(),一样会抛bad_weak_ptr异常。这个错误通常藏得深,因为编译期完全不报错。

4.3 析构顺序与生命周期依赖

就算用了weak_ptr,如果对象之间的强引用链上没有设计好生命周期顺序,依然可能出问题。一个典型场景:某个管理器对象持有所有子对象的shared_ptr,子对象持有管理器的weak_ptr。管理器析构时,如果没有任何其他shared_ptr指向它,它会被释放,那么所有子对象的weak_ptr全部失效——但子对象本身还被管理器的某个容器持有着。

这时候子对象访问管理器,发现lock()返回空,代码里又没有防空判断,直接解引用空指针崩溃。所以,只要代码里出现了lock(),返回空指针的路径就必须被显式处理。我见过太多崩溃都是因为开发者潜意识里认为“这里不可能失效”,结果偏偏失效了。

提示:所有lock()返回空后的行为都要当成正常业务分支来设计,而不是当作异常。这是用weak_ptr的基本素养。

4.4 多线程下的安全边界

关于weak_ptr的线程安全,有一个容易混淆的点。shared_ptr本身有两种线程安全性:一是控制块内的引用计数操作是原子的,这一点得到标准保证;二是多个线程同时写同一个shared_ptr实例是不安全的,因为没有实例级的内部锁。weak_ptr也一样。

具体到实践:

// 多线程下安全的做法:每个线程持有一份 weak_ptr 的拷贝 void worker(std::weak_ptr<Node> wp) { auto sp = wp.lock(); if (sp) { sp->doSomething(); } } // 不安全:多个线程同时操作同一个 weak_ptr 实例 std::weak_ptr<Node> shared_wp; // thread1: shared_wp = ... // thread2: auto sp = shared_wp.lock();

多个线程分别持有一份weak_ptr拷贝,各自lock()是安全的;但如果多个线程共享同一个weak_ptr变量,一个在写、一个在读,就产生了数据竞争。正确的分发姿势是:在进入多线程之前先拷贝好各自的副本。

5. 诊断循环引用的实用手段

5.1 代码审查阶段怎么快速定位

靠肉眼找循环引用,最重要的是抓住两类信号。第一类是成员变量里出现std::shared_ptr<自己相关类型>,尤其是两个类互相前置声明时;第二类是use_count()在某些操作后高于预期,且长期不下降。

我审查代码时有个习惯:看到shared_ptr从A类成员指向B类、同时B类成员指向A类,就直接问一句“这条链的所有权方向是谁”。如果回答不出来,就得重构。另外,构造函数里出现“把自己的this存到外部容器”的操作,多半意味着生命周期外泄,也要重点看外部容器拿的是shared_ptr还是weak_ptr。

5.2 运行时怎么确认泄漏源头

如果项目已经跑到线上才发现内存异常,靠日志定位不如用工具来得快。给定环境下,最实用的手段是valgrind整工具链里的massif或heaptrack做堆栈采样的方式抓内存快照。这类分析工具能帮你看到对象创建和保持引用的调用栈,从而定位到具体的持有者。

还有一种“土办法”:周期性打印关键对象的use_count(),观察它是否持续不为零。例如:

std::weak_ptr<Config> probe = weakConfig; // 保存一个弱引用 // 某个定时器回调里: std::cout << "config use_count=" << probe.use_count() << std::endl;

use_count()本身也有一个坑:它返回的数值只在当前调用瞬间有效,多线程下可能不准确;但用来做“这个对象是不是没被释放”的初步判断足够用了。真正要抓持有者,还是得靠分析工具。

5.3 设计层面的预防

等泄漏发生了再去拆,成本总是高的。更聪明的做法是在设计阶段就把“所有权语义”写清楚。我现在的做法是:类成员声明的注释里必须标注这条边是own还是observe。

class Session { std::shared_ptr<Connection> conn_; // own:连接由 Session 管理 std::weak_ptr<Server> server_; // observe:Server 可能先于 Session 销毁 };

同样,接口函数参数如果接受shared_ptr,隐含意思是“调用期间保持对象的有效性”,调用方有责任保证;如果接受裸指针或引用,则双方约定好生命周期范围。这样审查代码时,规则就变得很清晰:当你发现一条边的所有权归属说不清,它就是重构的重点。

最后再分享一个小技巧

如果你现在正维护一个老项目,里面到处是裸指针和shared_ptr混用,我建议你从一个很小的模块开始改,不用全盘推翻。先把“必然需要共享”的对象统一用shared_ptr管理,再逐个处理反向引用。改完一个模块后,跑一遍内存分析,对比泄漏量,效果立竿见影。

我个人经验是:weak_ptr的学习曲线不陡,但一开始很容易把它当作“麻烦版shared_ptr”,用着用着就开始绕开它。真正想通“不拥有所有权,只观察状态”这个定位之后,很多生命周期设计问题都会变得顺手很多。希望这篇整理能帮你少走我当年走过的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询