
1. 被一个 double free 逼着去读源码的那个下午第一次真正把shared_ptr当回事是被一个段错误逼出来的。那是个多线程的服务模块代码里到处都是裸指针new出来的对象靠人工在每个分支上delete。测试环境跑得好好的上线压测就随机崩gdb抓到的栈永远是free()里那行double free or corruption。查了两天最后发现是两个模块都以为自己对同一个对象有所有权各自delete了一次。那会儿我才意识到手动管理生命周期的成本,不是多写几行 delete而是你要在脑子里同时维护一张全局的对象所有权图一旦规模上去人脑根本算不过来。这就是shared_ptr共享指针要解决的问题——它不是让你少写 delete而是把这个对象还有几个地方在用这件事交给运行时去数。你在构造它的时候底层自动挂了一个计数器每当有一个shared_ptr指向同一个对象计数加一每当有一个shared_ptr消亡、被重置或指向别处计数减一计数归零对象自动析构。这套机制叫引用计数也是绝大多数语言里共享所有权语义的底层实现思路。C 的std::shared_ptr从 C11 进入标准库到现在已经是工程中最常用的智能指针没有之一。这篇内容我打算按真正会踩坑的顺序来写而不是按教科书顺序。会覆盖基本用法、控制块的运行时结构、make_shared的取舍、循环引用、线程安全边界、自定义删除器这些实际绕不开的点。适合已经会写shared_ptr但没深究过它怎么工作、或者最近被内存泄漏和崩溃折磨过的同学。如果你还在用裸指针管理共享对象那更应该看完——不是让你无脑全换而是搞清楚什么时候该用、什么时候不该用。这个概念,早就在我踩过的每个大坑里反复出现。2. 引用计数背后的运行时结构控制块到底存了什么很多人对shared_ptr的第一直觉是一个指针加一个int计数所以会以为sizeof(shared_ptrint)大概等于sizeof(int*) sizeof(int)。实测一下就会打脸#include memory #include cstdio int main() { std::printf(raw ptr : %zu\n, sizeof(int*)); std::printf(shared : %zu\n, sizeof(std::shared_ptrint)); std::printf(weak : %zu\n, sizeof(std::weak_ptrint)); std::printf(unique : %zu\n, sizeof(std::unique_ptrint)); return 0; }在 64 位 Linux / GCC 下输出通常是raw ptr : 8、shared : 16。也就是说shared_ptr是两个指针大小而不是一个指针加一个 int。多出来的那个指针指向的就是控制块control block。理解控制块是理解shared_ptr一切行为的钥匙。2.1 双计数设计强引用和弱引用为什么要分开数控制块里最核心的不是一个计数器而是两个强引用计数use_count和弱引用计数weak_count。前者统计有多少个shared_ptr共同拥有对象后者统计有多少个weak_ptr在观察这个对象。为什么要分开设想只有一个计数的世界weak_ptr想观察一个对象如果它也加这个唯一计数那它就变成了拥有者对象就永远等不到归零weak_ptr本身也没意义了如果它不加它就无法判断对象是否还活着。双计数巧妙地拆开了这两件事——weak_ptr只增加弱计数不阻止对象析构但它能通过检查强计数是否为零来判断对象死活。关键时刻在于析构分两步强计数归零时被管理对象调用析构函数资源释放但控制块本身不一定释放弱计数也归零时控制块才真正释放。这一步分离是weak_ptr能在对象已死、控制块还在的情况下安全调用的根本原因。下面这张表能把状态变化说清楚当前状态强计数弱计数对象是否存活控制块是否存活刚构造 1 个 shared_ptr11是是又拷贝 1 个 shared_ptr21是是1 个 shared_ptr 析构11是是全部 shared_ptr 析构01否已析构是等弱引用观察它的 weak_ptr 也析构00否否注意初始弱计数是 1 不是 0——因为还存在多少个强引用这件事本身也要被一个内部标记守住这个细节各实现略有差异但理解上不影响。2.2 控制块的创建时机为什么同一个对象不能有两套计数这是新手最容易犯的隐蔽错误同一个裸指针交给两个独立的shared_ptr构造会生成两个控制块各自计数最后双杀。int* raw new int(10); std::shared_ptrint a(raw); std::shared_ptrint b(raw); // 灾难第二个控制块 // a 和 b 生命周期结束时raw 被 delete 两次这段代码不会编译报错运行时才炸。原因就是两次shared_ptr构造各自创建了独立控制块强计数都是 1谁都不知道对方存在。所以有一条铁律永远不要用一个裸指针去初始化多个shared_ptr也不要对已经交给shared_ptr管理的裸指针做任何手动 delete 或再次包装。正确的做法是从一开始就用shared_ptr或工厂函数来产生第一个所有者auto a std::make_sharedint(10); std::shared_ptrint b a; // 共享同一个控制块计数变 2b a走的是拷贝构造直接复用a的控制块这才是共享该有的样子。2.3 计数增减藏在哪些看起来无害的操作里很多人以为只有构造和析构会动计数其实一票操作都会触发拷贝构造、拷贝赋值加计数赋值前会先给旧对象减计数移动构造、移动赋值不加计数只是把控制块指针掏过来并把源置空这是它比拷贝高效的原因传参按值、按值返回可能增删计数通常被编译器优化掉但语义上存在reset()给旧对象减计数可选地接管新对象use_count()只读不动计数。给一个能直接观察计数变化的例子建议你本地跑一遍比看文档记得牢#include memory #include iostream int main() { std::cout 0: 0 \n; std::shared_ptrint sp std::make_sharedint(7); std::cout after make: sp.use_count() \n; // 1 std::shared_ptrint sp2 sp; // 拷贝 std::cout after copy: sp.use_count() \n; // 2 std::shared_ptrint sp3 std::move(sp2); // 移动 std::cout after move, sp2 null: (sp2 nullptr) \n; // 1 std::cout after move, count: sp.use_count() \n; // 2 sp3.reset(); // 释放一个 std::cout after reset: sp.use_count() \n; // 1 return 0; }这里有个容易忽略的点对象析构的时机是强计数归零那一刻而不是reset()被调用的那一刻。上面sp3.reset()之后计数从 2 变 1对象还活着只有sp在main结束时析构计数归零int才真正被释放。理解这个时机对排查资源为什么还没释放很关键。3. 构造方式的取舍make_shared 还是 new写shared_ptr有两种最常见写法争论从 C11 一直持续到今天std::shared_ptrFoo a(new Foo(1)); // 构造函数直传裸指针 std::shared_ptrFoo b std::make_sharedFoo(1); // 工厂函数表面上make_shared只是少写一个new但它在内存布局和性能上有实打实的差异。我自己的默认选择是make_shared但有两个场景必须改用构造函数下面说清楚。3.1 一次分配和两次分配性能差异从哪来用new的写法会发生两次内存分配new Foo(1)在堆上分配一个Foo对象shared_ptr构造时在堆上再分配一个控制块。两块内存两次malloc两次缓存不友好地跳转。而make_shared的做法是一次性分配一块足够大的连续内存前半部分放控制块后半部分放对象然后就地构造。一次分配一路缓存友好对象和控制块紧挨着访问时命中率更高。对于那种会被高频创建的小对象比如事件、消息、配置项这个差异在压测里是能看出来的。我做过一个粗略的对比测试构造销毁一千万次小对象make_shared大致能快 15% 到 30%越小的对象越明显因为省下的那次分配和指针跳转相对开销更大。这里数值依赖具体环境和 allocator给个量级参考就好别当成绝对指标。3.2 make_shared 的隐藏代价对象内存被弱引用拖住make_shared不是没缺点它最坑的地方在于内存回收时机。回顾一下前面说的两步析构强计数归零时对象析构弱计数归零时控制块释放。但make_shared里对象和控制块是同一块连续内存没法单独释放前半块。这意味着只要还有任何一个weak_ptr活着弱计数不为零整块连续内存——包括已经析构的对象那部分——都不能还给系统。于是出现一个很反直觉的现象某个大对象假设占 1MB早就该析构了use_count()已经是 0但因为有个weak_ptr挂在旁边做缓存检查那 1MB 迟迟不释放。用new的写法就没事因为对象和控制块是两块独立内存对象那部分可以先还掉。所以判断标准很清晰场景推荐写法原因对象小、创建频繁make_shared一次分配、缓存友好、更快对象大、可能长期挂 weak_ptr构造函数new对象内存能随强计数归零先释放需要自定义删除器构造函数make_shared不支持传删除器需要在构造中精细控制构造函数可见裸指针能处理异常追求异常安全make_shared见下节3.3 异常安全为什么裸指针写法曾经会泄漏用new写法还有一个微妙的问题。考虑这种函数调用void sink(std::shared_ptrFoo a, std::shared_ptrBar b); sink(std::shared_ptrFoo(new Foo()), std::shared_ptrBar(new Bar()));C 标准不规定函数实参的求值顺序编译器可以自由决定先算哪个。如果顺序是先算new Foo()再算new Bar()再构造两个shared_ptr那么当new Bar()抛出异常比如内存不足时new Foo()拿到的裸指针还挂在空中——因为包裹它的shared_ptr还没来得及构造没人负责释放它Foo就泄漏了。make_shared把分配对象和构造控制块合并成一次原子性的动作不会出现这种中间态天然规避了这个问题。这也是为什么标准库和很多工程规范都推荐优先make_shared。我的实际原则是默认make_shared起步遇到大对象配weak_ptr、或需要自定义删除器时再退回构造函数。写出这条判断比死记某一种写法有用得多。4. 循环引用这张网weak_ptr 什么时候必须上场shared_ptr最著名的坑就是循环引用没有之一。只要两个对象互相持有对方的shared_ptr强计数永远不归零两块内存互相拴着谁都不肯先走。4.1 复现一个教科书级的内存泄漏父子节点互相引用的场景几乎人人都写过#include memory #include iostream struct Node { std::shared_ptrNode parent; std::shared_ptrNode child; ~Node() { std::cout Node destroyed\n; } }; int main() { auto a std::make_sharedNode(); auto b std::make_sharedNode(); a-child b; // b 被 a 强引用 b-parent a; // a 被 b 强引用 std::cout a count: a.use_count() \n; // 2 std::cout b count: b.use_count() \n; // 2 return 0; // main 结束a 和 b 各自的局部 shared_ptr 析构 }main返回后a和b这两个栈上的shared_ptr析构各自让计数减一。但因为a-child还指着b、b-parent还指着a两个计数都停在 1谁都不归零。结果就是~Node一行都不会打印两块内存泄漏。这段代码如果放进长跑的服务里每分钟泄漏一点迟早 OOM。更隐蔽的地方在于这种泄漏不会崩、不会报错你就是看着内存曲线一路往上爬然后翻遍代码找不到哪里 new 了没 delete——因为问题不在漏删而在该走的没走成。4.2 weak_ptr 的 lock 和 expired 到底该怎么用打破循环的标准动作是把环上的某一边从shared_ptr换成weak_ptr。weak_ptr不增加强计数所以它指向的对象可以正常析构。但它带来一个问题对象可能随时没了那怎么安全访问weak_ptr提供了三组接口expired()判断对象是否已死强计数为 0返回boollock()尝试提升为shared_ptr成功则对象保活到该临时shared_ptr析构失败返回空use_count()看当前强计数仅用于观察。正确的访问姿势必须用lock()而不是先expired()再解引用——因为中间可能有别的线程把最后一个强引用释放掉那两步之间就出现了竞态。看这段struct Node { std::weak_ptrNode parent; // 弱引用不阻止析构 std::shared_ptrNode child; ~Node() { std::cout Node destroyed\n; } }; int main() { auto a std::make_sharedNode(); auto b std::make_sharedNode(); a-child b; b-parent a; // 弱引用计数不涨 if (auto p b-parent.lock()) { // 正确原子提升 std::cout parent alive\n; } else { std::cout parent gone\n; } return 0; // a、b 正常析构~Node 打印 }一改完之后~Node就能正常打印了因为b-parent不再贡献强计数。4.3 打断环的几个固定套路除了父子互引用循环出现的地方还有一堆实践中我总结了几条常用处理方式明确单向所有权模型里总有谁真正拥有谁的答案。比如父拥有子shared_ptr子只观察父weak_ptr——这是最自然的写法观察者/回调一律用 weak_ptr订阅者对象可能提前销毁发布者如果持有强引用就永远不放手。回调里存weak_ptr触发时先lock()提升失败就跳过缓存条目用 weak_ptr缓存不应该延长对象寿命用weak_ptr存取的时候提升顺便还能清理失效条目定时器、事件监听用 weak_ptr 防悬挂这是悬挂指针的高发区对象没了但定时器还在跑回调里一解引用就崩实在拆不开就引入中介有些结构天然是多对多硬要在两边选一个当弱引用会很别扭那就把关系挪到一个独立的关系管理器里让管理器持有弱引用。排查循环引用有个偷懒但很有效的办法如果你怀疑某片内存泄漏了而use_count()在你以为该释放的地方还大于 0那大概率就是有环。GitHub 上那些内存调试工具比如 LeakSanitizer能帮你定位到具体的分配点但要确认是环还是漏,还是得靠理清所有权图。5. 线程安全边界哪些操作是原子的哪些不是shared_ptr是线程安全的吗这个问题在面试和实际开发里被问烂了而且答案不是简单的是或否。要分三层来回答引用计数的操作是原子的对象本身的读写不是同一个shared_ptr对象的并发读写也不是。5.1 引用计数为什么必须是原子的设想多个线程同时拷贝同一个shared_ptr每次拷贝都要让强计数加一。如果这个加法不是原子的就会发生丢失更新——两个线程各读到一个旧值各自加一写回结果只加了两次中的一次计数少了一个。计数一旦偏小对象可能被提前析构其他线程手里就攥着悬空指针。所以标准强制要求对同一对象控制块的计数增减必须原子执行标准库实现里用的是std::atomic或者平台提供的原子内建函数。标准里那句话的准确表述是多个线程对各自独立的shared_ptr对象进行读写只要这些对象指向同一块数据操作是安全的。注意限定词各自独立的。5.2 同一个 shared_ptr 被多线程读写为什么还是危险危险的正是在同一个shared_ptr对象本身上。因为它内部有两个字段对象指针和控制块指针拷贝和赋值这些操作要同时更新这两个字段。两个线程同时做拷贝一个读字段的过程中另一个在写读到的可能是新对象指针配旧控制块指针这种撕裂的组合然后引用计数就乱套了。看一个会翻车的模式std::shared_ptrFoo g_ptr; // 全局被多线程访问 void worker() { // 危险g_ptr 本身被并发读写 if (g_ptr) { // 读 g_ptr-doSomething(); } } void updater() { g_ptr std::make_sharedFoo(); // 写 }如果worker和updater同时跑g_ptr ...这个过程本身在特定编译器实现上可能分两步写先写对象指针再写控制块指针或反序读到一半的线程就看到不一致的状态后续-doSomething()可能作用在旧对象上甚至直接崩。正确的做法有两种加锁保护这个变量或者先用局部shared_ptr把它的值拷出来再操作void worker() { std::shared_ptrFoo local g_ptr; // 拷贝一份到局部 if (local) { local-doSomething(); // 局部对象独享后续安全 } }但请注意第二招只在updater那边的赋值也受同样保护、或者这个赋值动作本身不与之竞争时才有效。最稳的布局是如果g_ptr会被多线程反复整体替换就干脆用std::atomicstd::shared_ptrFoo或者老老实实上一把锁。我见过太多以为shared_ptr自带线程安全结果在替换句柄时出了随机崩溃的案例。5.3 对象本身的并发访问要另外上锁还有一层最容易被误解shared_ptr保证的是你能安全地拿到对象、安全地放对象但它完全不保证被指向对象的数据竞争安全。两个线程通过各自的shared_ptr同时改同一个对象里的int成员照样是数据竞争照样要你上锁或用原子变量。shared_ptr是生命周期管理工具不是并发控制工具这两件事不要混。梳理成一张表操作是否线程安全多线程各自持有的 shared_ptr 引用同一对象安全计数原子增减多线程读写同一个 shared_ptr 对象不安全需要加锁或 atomic多线程访问被指向对象的数据成员不安全与智能指针无关要自行同步多线程调用 weak_ptr::lock()安全内部有同步机制一个线程析构、另一个线程正在用对象不安全生命周期要自己协调这张表我建议贴在团队文档里能挡掉一大半相关的线上问题。6. 自定义删除器与数组、派生类场景的细枝末节shared_ptr比unique_ptr灵活的一个点是删除器不是类型的一部分而是存在控制块里的。这个设计直接决定了几个用法上的细节理解它就能避开一类看着对、跑起来不对的代码。6.1 删除器为什么可以任意类型unique_ptr把删除器作为模板参数删除器类型不同就是不同的unique_ptr类型于是你没法把删除器 A 的unique_ptr塞进删除器 B 的地方。shared_ptr不一样它在构造时把删除器存进控制块类型擦除掉了所以// 下面三个 shared_ptr 类型完全相同可以放进同一个容器 std::shared_ptrFILE f1(fopen(a, r), fclose); std::shared_ptrint p1(new int[10], std::default_deleteint[]()); std::shared_ptrint p2(new int(5), [](int* p) { std::cout custom delete\n; delete p; }); std::vectorstd::shared_ptrint vec {p1, p2}; // 合法这种删除器类型擦除的代价是shared_ptr内部多了一层间接调用以及控制块里要预留存放删除器的空间如果删除器很大控制块就大。这也是它比unique_ptr稍重的原因之一。但换来的是灵活性同一个容器能装走各种销毁方式的对象。6.2 数组场景别再用 default_delete 硬套shared_ptrint(new int[10])是个经典错误——默认删除器对数组用delete而不是delete[]行为未定义可能只析构第一个元素。正确做法有几种C17 起可以用std::shared_ptrint[](new int[10])模板参数写成数组类型删除器自动匹配delete[]或者显式指定std::shared_ptrint(new int[10], std::default_deleteint[]())更省事的是std::make_sharedint[](10)C20 起对数组的重载也有了支持。我自己现在的习惯是数组场景优先用std::vectorint或std::array真到了要和 C 接口打交道、必须传原始数组指针时才用shared_ptrint[]包一层。能用容器解决的数组管理就别为了用智能指针而用智能指针那是给自己找麻烦。6.3 通过基类指针析构基类析构函数必须是虚的这条其实和智能指针无关是 C 的老规矩但shared_ptr让这个坑更隐蔽你调用shared_ptrBase的析构底层执行delete ptr如果ptr实际指向Derived而Base的析构函数不是虚的就是未定义行为。struct Base { ~Base() { std::cout ~Base\n; } // 非虚危险 }; struct Derived : Base { ~Derived() { std::cout ~Derived\n; } }; std::shared_ptrBase sp std::make_sharedDerived(); // sp 析构时只会调用 ~Base~Derived 不执行成员泄漏只要类可能被继承并以基类指针形式管理析构函数一律加virtual。shared_ptr不会帮你检查这件事编译器也不会在运行前提醒你靠的就是写代码时养成习惯。顺带一提用了make_shared也一样,因为在make_shared内部它保存的仍然是Derived*销毁时调用的正是你指定的类型的析构。7. 句柄语义、enable_shared_from_this 与几个我踩过的坑最后这一部分聊聊shared_ptr相对少被提起、但在真实代码里反复出现的细节。这些点单独看都很小凑在一起就是为什么同样的写法别人稳我这边偶尔炸。7.1 拷贝、赋值、移动的计数变化要烂熟于心shared_ptr本质上是个句柄拷贝它意味着又多了一个所有者移动它意味着所有者转移旧的清空。这两件事在写泛型代码和容器操作时到处都是。几个必须记牢的结论拷贝 加计数可能触发控制块里的原子操作有开销移动 不加计数只挪两个指针O(1)用它传参和返回更划算赋值a b先给a旧对象减计数可能析构再给b的对象加计数swap互换的是两个句柄的内容不涉及对象销毁异常安全常用它。传参上我养成一个习惯参数如果只是使用不延长生命周期就传const shared_ptrT如果函数要参与所有权存起来或返回就传值或移动。传值会拷贝加计数方便但贵传引用不加计数但函数里用之前得注意对象可能被别处释放。7.2 enable_shared_from_this 不能用错有时候类内部需要把自己变成一个shared_ptr交给外部比如注册回调、加入某个容器。直接std::shared_ptrT(this)是灾难——它会给this建一个全新的控制块和你原本那个shared_ptr各数各的最后双杀。正确姿势是继承std::enable_shared_from_thisT然后调用shared_from_this()class Session : public std::enable_shared_from_thisSession { public: void start() { auto self shared_from_this(); // 复用已有控制块 // 把 self 交给异步任务 } };但这里有个大坑shared_from_this()必须在对象已经被某个shared_ptr管理之后才能调用。如果你的Session是在栈上直接构造的或者构造过程中就调用了shared_from_this()会抛std::bad_weak_ptr——因为此时还没有控制块它内部的weak_ptr是空的。所以注册回调、启动异步这类操作要放在对象确实被shared_ptr持有之后再做通常从工厂函数返回后调用专门的方法来触发。7.3 几个我踩过的具体坑写到最后分享几个文档上不一定写、但我自己确实撞过的问题。第一个是在构造函数里调用虚函数或shared_from_this。构造期间对象还没构造完shared_from_this必然抛异常虚函数也不会派发到子类。回调注册这类动作别放构造函数放一个显式的init()里。第二个是**use_count()的误导性**。它返回的只是个瞬时快照多线程下读完就过时别用它来做业务判断比如如果计数是 1 就独占修改对象。真要独占修改得靠锁或者语义上保证独占。第三个是把shared_ptr当成万能钥匙。能独占就别共享unique_ptr更轻、没有计数开销、移动即转移语义也更清晰。共享所有权应该是有明确理由才用的——比如对象要被多个模块同时持有、生命周期无法静态确定。我现在的默认是unique_ptr只有在真正需要多方共享时才换成shared_ptr在这个基础上再遇到互相观察的场景才上weak_ptr。这套默认独占、按需共享、观察用弱引用的选用顺序比一上来就全用shared_ptr清爽得多。第四个是跨模块传递shared_ptr时的 ABI 问题。如果动态库两边编译器的标准库版本或构建配置不一致控制块的内存布局可能不兼容跨边界传shared_ptr会导致诡异崩溃。稳妥做法是在接口边界上换成裸指针加显式的借用约定或者在接口里用稳定的抽象类型别让底层实现细节穿出去。把这几条和自己的代码对一遍你会发现大部分智能指针的坑其实都不在指针本身而在对所有权和生命周期的思考是否清晰。工具只是把这份思考显式化了——想清楚谁拥有谁、谁观察谁、谁什么时候撒手崩溃和泄漏自然就少了。这也是为什么我后来越来越愿意花时间读shared_ptr的实现而不是只会拼它的接口。