排查了大半天的内存泄漏最后发现根源是两个shared_ptr互相引用——这类循环引用问题可以说是 C 智能指针领域最经典的坑之一。今天这篇把weak_ptr解决shared_ptr循环引用的来龙去脉完整梳理一遍底层原理、引用计数到底在数什么、实际改造手法、内存上涨时怎么一步步定位以及我踩过的几个细节坑。新手可以把它当入门教程老手也可以对照一下排查思路有没有可借鉴的地方。先还原一个现场。假设你在维护一个长期运行的网络服务突然发现内存随时间只涨不跌压测跑一天就吃掉几个 GB翻遍业务代码又找不到哪里 new 了没 delete。这时候十有八九问题出在对象之间的相互持有关系上——它们谁都不肯先放手。1. 先复现一次销毁不掉的对象循环引用的现场1.1 一段看起来人畜无害的代码#include iostream #include memory struct Node { std::shared_ptrNode next; ~Node() { std::cout Node destroyed std::endl; } }; int main() { auto a std::make_sharedNode(); auto b std::make_sharedNode(); a-next b; b-next a; // 问题就出在这一行 // main 结束前a 和 b 两个局部 shared_ptr 会依次析构 return 0; }你猜程序跑完会打印几遍 Node destroyed答案是零遍。局部变量a和b确实在 main 末尾析构了但这两个 Node 对象本身仍然存活因为各自还被对方的一个shared_ptr成员牵着。两个对象就这样互相保全对方形成一个谁也出不去的环。1.2 引用计数变化的全过程我把数字推演一遍。初始时a这个 shared_ptr 指向 A 对象A 的强引用计数 1b这个 shared_ptr 指向 B 对象B 的强引用计数 1。执行a-next b后B 的强引用计数变成 2一个是局部变量 b一个是 A::next 成员。再执行b-next a后A 的强引用计数也变成 2。到 main 结束时a、b两个局部变量依次析构各自释放一个强引用A 从 2 变 1B 从 2 变 1。注意无论谁先析构结果都是两个计数各自停在 1——因为析构的是外部的指向而对象内部的指向A::next 和 B::next还在。计数没有归零析构函数自然不会被调用这块内存就永远留在堆上。循环引用造成的泄漏通常不会在退出小程序的瞬间暴露成崩溃因为进程一结束操作系统会把所有内存收回去。真正难受的场景是服务端程序每次请求都可能构造类似的对象环环里的对象析构不掉内存像漏水的桶一样慢慢涨直到被 OOM 杀掉。这种问题的隐蔽之处在于它不报错、不崩溃只会在运行几小时甚至几天后给你一记surprise。1.3 这不是某个指针的错是所有权设计出了问题很多人遇到这个问题第一反应是shared_ptr 有 bug。不是。shared_ptr的行为完全符合语义它承诺最后一个持有者负责销毁对象。但当你让 A 持有 B 的同时让 B 持有 A就相当于告诉编译器你们两个永远都是对方的最后一个持有者于是谁都不会成为最后一个。共享所有权的前提是所有权有明确的语义边界不能构成环。打个比方两个人各拿一把对方房子的钥匙谁也不愿意先放手房子就永远没人退租。weak_ptr的作用就是让其中一个人改成只有参观权、没有掌控权——钥匙不攥在自己手里需要用到的时候再临时申请这样就打破了僵局。2. 引用计数和强弱引用搞清楚 shared_ptr 到底在数什么2.1 控制块里其实有两个计数器要理解 weak_ptr得先看清楚 shared_ptr 的底层结构。一个 shared_ptr 内部通常有两个指针一个指向对象本身另一个指向控制块control block。控制块里存着两样关键数据强引用计数strong ref count有多少个 shared_ptr 正在拥有这个对象当它归零时对象被销毁。弱引用计数weak ref count有多少个 weak_ptr 正在观察这个对象它不参与对象生命周期管理但会阻止控制块过早释放。为什么需要弱引用计数因为 weak_ptr 在调用lock()时必须能安全地判断对象是否还活着。如果强引用计数归零时整个控制块直接被释放那所有指向它的 weak_ptr 就变成悬空指针再访问就是未定义行为。所以标准库的做法是强引用计数归零 → 销毁对象但保留控制块弱引用计数也归零 → 才释放整个控制块。这也是为什么循环引用泄漏时泄漏的不只是对象本身还有控制块。2.2 weak_ptr一个不持有、只观察的引用weak_ptr 的 API 很小但每个都是重点lock()如果对象还活着返回一个临时的 shared_ptr并原子性地把强引用计数加一如果对象已经销毁返回一个空的 shared_ptr。expired()等价于use_count() 0判断强引用计数是否归零。reset()释放当前 weak_ptr 对控制块的引用。owner_before()提供严格弱序用于把 weak_ptr 放进 map/set 等关联容器。注意weak_ptr 没有operator-也没有operator*。这是故意设计的它不拥有对象就不该让你绕过生命周期检查直接触碰对象。想用对象必须先lock()得到 shared_ptr在持有这个临时强引用的期间对象绝对不会被销毁。弱引用、强引用和裸指针三者的差别可以看这张表指针类型是否参与所有权生命周期安全访问语法典型用途shared_ptr是强引用计数自动释放-/*多人共享同一个对象weak_ptr否只观察需 lock 判空lock()打破循环、观察者、缓存裸指针否需人工保证存活-/*非拥有的临时访问2.3 为什么 weak_ptr 能解开循环回到第一小节的计数推演。如果把其中一个成员的shared_ptr换成weak_ptr例如 B::next 指向 A 时用的是弱引用那么在 main 结束时b局部变量析构B 的强引用计数从 2 降到 1剩下的 1 来自 A::nextB 的 next 是弱引用指向 A 时不增加 A 的计数接着a局部变量析构A 的强引用计数从 1 降到 0A 被销毁A 析构会释放它内部的 shared_ptr 成员next这个成员指向 B于是 B 的强引用计数从 1 降到 0B 随后也被销毁。链条从外部的最后一个强引用开始像多米诺骨牌一样逐环倒下。关键点在于整个环上至少有一条边是弱引用环就不存在永远互相保全的条件。这里顺带解释了为什么很多人搜c智能指针底层原理智能指针本质上是裸指针 RAII 包装 引用计数管理。shared_ptr 解决的是多个持有者共同管理生命周期的问题weak_ptr 解决的是既想安全观察又不想参与管理的问题。理解这两层比死记 API 有用得多。3. 三个典型场景的弱引用改造实战3.1 双向链表next 强引用prev 弱引用链表是智能指针最容易出循环的场景因为双向天生就暗示了互相指向。改造原则很简单顺着所有权方向用强引用逆着方向用弱引用。#include iostream #include memory struct DListNode { int value; std::shared_ptrDListNode next; // 后驱强引用 std::weak_ptrDListNode prev; // 前驱弱引用 explicit DListNode(int v) : value(v) {} ~DListNode() { std::cout destroy node value std::endl; } }; int main() { auto head std::make_sharedDListNode(1); auto tail std::make_sharedDListNode(2); head-next tail; tail-prev head; // 弱引用不参与计数 // head 和 tail 都能正常销毁 return 0; }跑一下会看到两句 destroy node 都打印出来。prev 指向 head 时不再增加 head 的强引用计数等到 head 的最后一个强引用消失它就能正常销毁tail 的析构函数也不用担心谁还指着我因为 prev 是弱引用。实际业务里链表节点往往还存数据、可能被多段代码同时使用这时候还要问一句这个节点到底归谁所有如果整条链表由某个容器独占管理unique_ptr其实更合适更不存在循环问题只有确实需要多个地方共享节点时才上 shared_ptr weak_ptr 的组合。3.2 观察者模式通知者不该拥有观察者观察者模式是弱引用的典型应用场景Subject 需要通知一堆 Observer但 Subject 不应该因为通知过你就把 Observer 的命续上。如果 Subject 用std::vectorstd::shared_ptrObserver存观察者而 Observer 又持有指向 Subject 的回引两者就会互相保命谁也别想销毁。正确做法是 Subject 内部存弱引用列表通知时临时 lockclass Observer; class Subject { public: void registerObserver(const std::shared_ptrObserver obs) { observers_.push_back(obs); // 存入的是 weak_ptr } void notify(int eventID) { for (auto wobs : observers_) { if (auto sp wobs.lock()) { sp-onEvent(eventID); } } removeExpired(); // 顺手清掉失效观察者 } private: void removeExpired() { observers_.erase( std::remove_if(observers_.begin(), observers_.end(), [](const std::weak_ptrObserver w) { return w.expired(); }), observers_.end()); } std::vectorstd::weak_ptrObserver observers_; };Observer 那边如果有回指 Subject 的需求同样应该用弱引用或者干脆在通知回调的参数里直接传 Subject 的引用而不是在 Observer 里存一个 shared_ptr 回引。这里有一个很容易被忽略的细节弱引用列表里的条目不会自动消失。某个 Observer 销毁后vector 里对应 weak_ptr 会变成 expired但元素还在。如果不定期清理时间长了列表里堆满尸体每次 notify 都要空转遍历一遍这是使用层面的坑我后面还会再提。3.3 父子对象回引用用 weak_ptr正向持有用 shared_ptrUI 控件、游戏实体、配置树这类父容器管理子对象的模型也很常见。父对象持有子对象的 shared_ptr子对象需要访问父对象时持有一个父对象的 weak_ptrclass Parent : public std::enable_shared_from_thisParent { public: void addChild(std::shared_ptrChild child) { child-setParent(shared_from_this()); children_.push_back(std::move(child)); } private: std::vectorstd::shared_ptrChild children_; }; class Child { public: void setParent(const std::shared_ptrParent p) { parent_ p; } void doSomething() { if (auto p parent_.lock()) { // 在持锁期间p 保证存活可以安全调用 } else { // 父对象已经没了走降级逻辑 } } private: std::weak_ptrParent parent_; };注意一个细节子对象在构造函数里不能拿到指向自己的 weak_ptr因为此刻它还没有被任何 shared_ptr 管理。所以上面的写法是先创建子对象再通过 setParent 注入父引用。关于shared_from_this()和weak_from_this()的限制我会在后面的避坑章节展开。4. 排查循环引用泄漏从内存上涨到定位循环边4.1 第一步析构日志和对象计数器先确认没被销毁排查循环引用最快的探测手段就是在析构函数里打印日志或者用一个静态计数器统计存活实例数。如果对象本应在流程结束后销毁但日志没打印、计数器不降就基本可以断定存在生命周期泄漏。光有日志还不够你还需要知道哪些对象互相持有以及销毁链路在哪里断开。更实用的技巧是写一个自定义 deletertemplate typename T std::shared_ptrT makeTracked(T* p, const char* name) { return std::shared_ptrT(p, [name](T* ptr) { std::cout [delete] name std::endl; delete ptr; }); }自定义 deleter 的好处是只要强引用计数归零deleter 立刻触发不管对象是从哪个分支走出来的。它能帮你确认析构链路是否被切断也能配合日志看销毁顺序判断哪条边是最后放手的那条。提示deleter 里不要只打印不释放否则会二次泄漏。打印的目的是观察释放仍然要delete ptr。4.2 第二步交给工具去定位确认有泄漏后必须用工具把泄漏对象抓出来。Linux 下我习惯先上 Valgrindvalgrind --leak-checkfull --show-leak-kindsall ./your_program如果看到一串 definitely lost 和 indirectly lost 的堆栈且多个泄漏块之间有互指关系输出里通常会显示next、prev这类成员名基本就能锁定循环引用。不过 Valgrind 跑起来很慢适合小规模复现。日常开发我更推荐 AddressSanitizer 自带的 LeakSanitizerg -g -fsanitizeaddress -fno-omit-frame-pointer main.cpp -o main ASAN_OPTIONSdetect_leaks1 ./mainLSAN 的输出会直接告诉你泄漏点在哪个文件哪一行定位效率比 Valgrind 高不少。再配合 heaptrackheaptrack ./main生成分析文件用heaptrack_print查看还能同时看到对象的分配栈和释放栈对分析谁创建了它、谁应该销毁它非常有帮助。4.3 第三步代码审查时直接看所有权图工具能告诉你哪里有泄漏但为什么泄漏还得靠人。我自己的习惯是在代码里画一张所有权关系图用文本注释就够把所有类的成员变量里的std::shared_ptr都列出来画成 A → B 的边。如果发现从某个对象出发沿着 shared_ptr 的边能走回它自己这里就是一个环必须打断。检查时重点关注三类成员数据成员里的std::shared_ptr字段尤其是next、prev、parent、callback、observer这类名字构造函数里互相传入shared_from_this()的地方容器里存放shared_ptr元素的类型例如vectorshared_ptr...如果元素的回引用也是 shared_ptr同样会形成环。打断的原则是所有权方向不变的那条边保留强引用回指边改成 weak_ptr。如果环里实在分不清谁拥有谁说明这里的共享所有权设计本身就暧昧这种情况我放到最后一部分再说。5. 日常使用 weak_ptr 的细节和容易踩的坑5.1 lock() 的返回值必须检查最常见的新手错误是拿到lock()的结果不做判空直接解引用。对象是否存活是运行时状态编译器帮不了你std::shared_ptrParent sp child-getParent().lock(); // 可能是空 if (sp) { sp-doSomething(); }还有一种写起来看起来更严谨、实际更危险的写法if (!weak.expired()) { auto sp weak.lock(); // 不好expired 之后对象可能已被销毁 sp-doSomething(); // 这里的 sp 可能是空的 }expired()返回后到lock()执行前另一个线程完全可能把对象销毁掉。正确的姿势是只调一次lock()拿到结果后检查非空一个原子操作同时完成判断是否存活和增加强引用计数两件事。提示不要写expired()lock()的先判断再用模式直接lock()再判空既简洁又线程安全。5.2 从 this 拿到 weak_ptrenable_shared_from_this 与 weak_from_this很多类需要在成员函数内部拿到指向自己的弱引用用于对外暴露。这时候要继承std::enable_shared_from_thisTC17 之后可以直接用weak_from_this()class Item : public std::enable_shared_from_thisItem { public: std::weak_ptrItem self() { return weak_from_this(); } };注意几点不要在构造函数里调用shared_from_this()或weak_from_this()。原因前面提过对象还没有进入任何一个 shared_ptr 的控制块调用是未定义行为。想拿自己的引用必须等对象构造完成并且确实由 shared_ptr 管理之后不要以为继承了 enable_shared_from_this 之后栈上创建的对象也能调用——不行它要求对象本身是被 shared_ptr 管理的在 C17 之前没有weak_from_this()只能先shared_from_this()得到一个 shared_ptr再隐式转成 weak_ptr兜一圈。5.3 多线程下的生命周期保证lock()内部是原子操作这在 C11 之后是标准行为。你完全可以在一个线程持有 weak_ptr、另一个线程负责销毁对象因为只要lock()成功返回 shared_ptr在临时 shared_ptr 存活期间对象绝对不会被销毁即使持有 weak_ptr 的线程刚刚检查完 expired销毁线程立刻销毁对象下一次lock()依然会安全地返回空。但有一点要特别提醒use_count()在多线程场景下不可靠。它返回的是某个时刻的快照读取的瞬间可能已经被别的线程修改。所以use_count() 1这类判断只能用于单线程调试不能作为并发逻辑的依据。5.4 容器里的 weak_ptr 会积累过期条目前面观察者模式里已经提到vector 里的 weak_ptr 不会自动失效删除。实际项目中如果用 weak_ptr 做缓存或订阅列表一定要考虑清理策略要么在每次遍历时顺手 erase 掉 expired 条目懒清理代码量最小要么用owner_before把 weak_ptr 放进 set/map 里通过键的失效做索引维护要么在对象析构时主动通知容器把它移除这要求容器是全局的注意别把耦合搞复杂。很多人在小项目里忽视了这一点等到运行几周后才发现容器越撑越大。weak_ptr 本身不会泄漏但放任过期条目堆积会导致容器膨胀这属于使用层面的坑我踩过不止一次。6. 更本质的问题循环引用是所有权设计失误的信号6.1 先问谁拥有谁再决定用哪个智能指针接触过一段智能指针后你会发现循环引用与其说是技术问题不如说是所有权边界不清的设计问题。建议在动手前先回答三个问题这个对象被创建后谁负责销毁它另一个对象只是用一下它还是要管着它如果持有者全部消失这个对象是否应该随之消失回答越清晰代码越简单。能明确只有一个所有者的场景直接unique_ptr根本不会出现循环确属多人共享、共同拥有的场景才用shared_ptr只是临时访问、不敢保证对方活着的场景用weak_ptr或裸指针裸指针要求你能严格保证访问时机做不到就用 weak_ptr。如果你的系统里大量对象互相用 shared_ptr 引用那多半不是缺一个 weak_ptr 的问题而是对象的职责边界画得太乱。6.2 一个更彻底的思路把共享状态抽出来有时候两个对象确实必须互相活着才能工作比如图形界面里一个控件的模型和视图。这时候硬把一条边改成 weak_ptr虽然打破了循环但业务语义上可能变弱——视图访问模型时发现模型已经被销毁降级逻辑写起来很别扭。更干净的做法是把双方都依赖的那部分共享状态抽成第三个对象由这个共享对象被外部统一持有模型和视图各自持有它的引用可以是裸指针或 weak_ptr看你确信谁会先消失。这样所有权变成清晰的外部拥有、内部借用循环自然消失。6.3 我的一点经验每个 shared_ptr 成员都值得被追问最后分享一个我在 code review 时常用的习惯看到一个类的成员变量是std::shared_ptr我会下意识追问这个成员真的需要参与所有权吗十次里有七八次答案是不需要——它只是需要安全地访问另一个对象。把 shared_ptr 改成 weak_ptr lock 之后不仅破了循环还顺带让代码更明确这段关系是借用不是占有。排查工具虽然能帮你找到泄漏位置但真正避免循环引用靠的是在写每一行代码时就把所有权关系想清楚。把这个指针是拥有的吗当成一道常规检查题比事后跑 Valgrind 划算得多。还有一个细节顺带提一下C17 之后shared_ptr可以直接管理数组比如std::shared_ptrint[]它会正确地用delete[]释放不要再用shared_ptrint(new int[10])这种历史写法了。数组指针、链表指针这些经典话题换到智能指针语境下核心其实都是同一件事裸指针强调的是指向哪里智能指针强调的是这份内存由谁负责管理——想清楚后者循环引用就不再是噩梦。