首页 / 资讯中心 / 文章详情

weak_ptr 详解:打破 shared_ptr 循环引用

weak_ptr 详解:打破 shared_ptr 循环引用 ★ FEATURED ARTICLE
shared_ptr用引用计数自动管理生命周期但计数只在「强引用归零」时析构对象——一旦两个对象互相持有对方的shared_ptr就形成环谁都不先归零谁都释放不了内存直接泄漏。这篇用真跑出来的输出证明泄漏再用std::weak_ptr弱指针weak pointer打破它并讲清weak_ptr为什么能既「观察」又不「拥有」。1. 引子一个永不析构的程序下面这段看起来人畜无害A 持有一个shared_ptrBB 持有一个shared_ptrA。退出作用域后你以为它们都该析构——但析构函数一个都没打印// wp_leak.cpp — 编译: g -stdc17 -Wall -O2 wp_leak.cpp -o wp_leak#includeiostream#includememorystructB;// 前向声明structA{std::shared_ptrBb;~A(){std::coutA destroyed\n;}};structB{std::shared_ptrAa;~B(){std::coutB destroyed\n;}};intmain(){{autoastd::make_sharedA();autobstd::make_sharedB();a-bb;// A 强引用 Bb-aa;// B 强引用 A - 形成环}// 退出作用域a、b 局部析构但环还在std::coutscope ended\n;return0;}scope ended只打印了scope endedA destroyed/B destroyed一个都没出现——这就是循环引用导致的内存泄漏实打实地发生了。原因局部a析构后A 的强计数从 2 降到 1B 还引用着没归零B 同理。两个对象互相吊着永远等不到释放。这类环在现实代码里比想象中常见父子节点互指、双向链表、观察者与被观察者、图结构的边——只要出现「我需要你、你也需要我」环就成立了。更麻烦的是它很安静程序不崩溃、断言不报错只是内存一点点涨上去只有 LeakSanitizer / valgrind 这类工具才抓得到。写代码时先问一句「这两个shared_ptr谁该拥有谁」比事后查泄漏便宜得多。官方文档std::shared_ptr 的引用计数语义 — cppreference2. 用 weak_ptr 打破环前后对比把其中一方的shared_ptr改成weak_ptr环就断了一边——weak_ptr不增加强引用计数只增加弱引用计数。这样有一个方向的强引用链断掉对象就能正常析构// wp_fixed.cpp — 编译: g -stdc17 -Wall -O2 wp_fixed.cpp -o wp_fixed#includeiostream#includememorystructB;structA{std::shared_ptrBb;~A(){std::coutA destroyed\n;}};structB{std::weak_ptrAa;// 改成 weak不持有强引用~B(){std::coutB destroyed\n;}};intmain(){{autoastd::make_sharedA();autobstd::make_sharedB();a-bb;// A 强引用 Bb-aa;// B 只弱观察 A}std::coutscope ended\n;return0;}A destroyed B destroyed scope ended两个析构函数都跑了泄漏消除。注意顺序局部a、b析构后A 的强引用只剩 0B 那头是弱引用不保活A 先析构A 析构时它持有的shared_ptrB也跟着析构B 的强引用归零B 才析构。官方文档std::weak_ptr — cppreference3. 引用计数随作用域变化的 ASCII 时序图把上面的过程画成时序图强计数S和弱计数W的变化一目了然时间 ──────────────────────────────────────────────────────► 创建 a make_sharedA() S(A)1 W(A)0 创建 b make_sharedB() S(B)1 W(B)0 a-b b S(B)2 b-a a (weak) W(A)1 ← 只增弱计数S(A) 不变 退出作用域 局部 b 析构 S(B)1 (未归零) 局部 a 析构 S(A)0 → A 析构 A 持有的 shared_ptrB 析构 S(B)0 → B 析构 B 内的 weak_ptr 随 B 析构 W(A)0 → A 的控制块析构 对比「全用 shared_ptr」的泄漏版 局部 a 析构 S(A)1 (B 还强引用不归零) 局部 b 析构 S(B)1 (A 还强引用不归零) → 两个对象都泄漏永不析构这里有个容易忽略的细节弱计数只决定控制块什么时候释放不决定对象什么时候析构。也就是说只要还有weak_ptr活着控制块就留着这样weak_ptr才能安全地问「对象还在吗」但对象早就没了。4. 三板斧expired() / lock() / use_count()weak_ptr不能直接解引用。想用对象先lock()把它原子地提升成shared_ptr若对象还活着lock()返回一个有效的shared_ptr同时强计数 1若对象已析构lock()返回空。lock()是原子的因此避免了「先expired()判断、再解引用」之间的竞态TOCTOUtime-of-check-to-time-of-use// wp_lock.cpp — 编译: g -stdc17 -Wall -O2 wp_lock.cpp -o wp_lock#includeiostream#includememorystructSensor{~Sensor(){std::cout~Sensor()\n;}};intmain(){autospstd::make_sharedSensor();std::weak_ptrSensorwpsp;std::coutexpired before reset? wp.expired()\n;std::coutuse_count wp.use_count()\n;{autolockedwp.lock();// 原子提升if(locked)std::coutlocked ok, use_count wp.use_count()\n;}// locked 离开强计数回落sp.reset();// 唯一强引用消失 - 对象析构std::coutexpired after reset? wp.expired()\n;autolocked2wp.lock();std::coutlock after reset - empty? (locked2nullptr)\n;return0;}expired before reset? 0 use_count 1 locked ok, use_count 2 ~Sensor() expired after reset? 1 lock after reset - empty? 1关键点wp.use_count()返回的是强引用计数和sp.use_count()一致不是弱计数——想数有几个weak_ptr在观察标准库没给你接口。expired()等价于use_count() 0同样只用于判断、不进逻辑。lock()返回空就别解引用——这正是安全使用弱引用的固定套路。weak_ptr还有几个不常用但值得记住的成员reset()主动放弃观察等价于赋空swap()交换两个观察者owner_before()提供「按控制块」而非「按对象地址」的严格弱序因此weak_ptr可以直接当std::map/std::set的键——两个都失效的weak_ptr只要源自同一个控制块就互相等价源自不同控制块则互不相等。官方文档std::weak_ptr::lock — cppreference · std::weak_ptr::expired — cppreference · std::owner_less — cppreference5. 典型场景带自动失效的缓存缓存是最能体现weak_ptr价值的场景缓存不该替使用者保活对象但又需要知道对象还在不在。存weak_ptr就两全其美——对象被别处释放后缓存项自动「失效」而不阻止回收// wp_cache.cpp — 编译: g -stdc17 -Wall -O2 wp_cache.cpp -o wp_cache#includeiostream#includememory#includestring#includeunordered_map#includeutilityclassTexture{public:explicitTexture(std::string name):name_(std::move(name)){std::coutcreate name_\n;}~Texture(){std::coutdestroy name_\n;}conststd::stringname()const{returnname_;}private:std::string name_;};classTextureCache{public:voidput(conststd::stringkey,conststd::shared_ptrTexturetex){entries_[key]tex;// 缓存只存 weak不保活}std::shared_ptrTextureget(conststd::stringkey)const{constautoitentries_.find(key);returnitentries_.end()?nullptr:it-second.lock();// 原子提升}private:std::unordered_mapstd::string,std::weak_ptrTextureentries_;};intmain(){TextureCache cache;{autotstd::make_sharedTexture(grass.png);cache.put(grass,t);if(autohitcache.get(grass))std::couthit: hit-name()\n;}// 唯一强引用消失std::coutowner gone\n;if(autohitcache.get(grass))std::couthit: hit-name()\n;elsestd::coutmiss: weak expired\n;return0;}create grass.png hit: grass.png destroy grass.png owner gone miss: weak expired注意get()里没有「先判expired()再取」这种两步操作而是一次lock()决定成败——这就是规避 TOCTOU 的写法。缓存、观察者列表、回调注册表凡是想「引用但不拥有」都是同一模式。6. 弱引用的典型用途与易错点用途为什么用 weak_ptr打破父子互指的环让「父→子」用shared_ptr、「子→父」用weak_ptr避免互保活缓存缓存用weak_ptr持有对象被别处释放后缓存自动失效不阻止回收观察者observer观察者列表用weak_ptr被观察对象析构后观察者lock()得到空不会悬空易错写法后果正确做法*wp直接解引用编译不过weak_ptr没有operator*先lock()判空再解引用if (!wp.expired()) use(*wp);两步之间对象可能被别的线程释放TOCTOU一次auto p wp.lock();定生死用weak_ptr存「必须一直活着」的关系对象说没就没逻辑随时断需要保活就用shared_ptr指望wp.use_count()数出自己有几个它返回的是强计数拿不到弱计数个数只用它做调试排查官方文档C Core Guidelines「R: Resource management」一节R.24 用 weak_ptr 打破 shared_ptr 的环7. 延伸阅读std::weak_ptr — cppreference全部接口与「不控制对象生命周期」的语义。std::enable_shared_from_this — cppreference类内部想拿shared_ptr时配合weak_ptr的机制见《enable_shared_from_this》。std::shared_ptr — cppreference控制块里强/弱两个计数的出处见《shared_ptr 完全指南》。C Core Guidelines「R: Resource management」R.22 要求用make_shared构造避免裸new引入第二个控制块。8. 一句话总结shared_ptr互相强引用会成环泄漏weak_ptr只增弱计数、不保活是打破环的标准解法。lock()原子提升成shared_ptr才能安全使用对象回避了expired() 解引用的 TOCTOU 竞态弱计数只管控制块何时释放、不管对象何时析构缓存与观察者场景该优先想到它。
阅读完成 · 觉得有帮助?
咨询建站