资讯详情

C++ shared_ptr性能揭秘:从原子操作到优化实践

📅 2026/9/15 21:01:08 | 华诺云谱 👁 阅读
C++ shared_ptr性能揭秘:从原子操作到优化实践
先说说我为什么要把 shared_ptr 放进高性能组件这个系列来写。去年我们重构一个实时数据分发模块的时候压测到第二周性能始终卡在每秒几十万次消息的关卡上不去。火焰图拉出来一看占比最高的不是网络、不是序列化而是shared_ptr的拷贝和析构。当时团队里的 C 老手说了句话让我印象很深你以为你在用智能指针其实你在用一套隐蔽的内存管理系统。 从那天起我就把 shared_ptr 当成一个正经的组件来研究而不是一个普通的语法糖。这篇文章是系列第一篇把 shared_ptr 的底细、开销模型、优化手段和工程规范一次讲透适合正在写高吞吐服务、实时渲染引擎、中间件这类对延迟敏感代码的 C 开发者阅读也适合那些已经用上 shared_ptr 但总觉得哪里不得劲的人。1. 在组件库的语境下shared_ptr 到底解决了什么1.1 所有权语义比内存安全更重要的东西很多人一提 shared_ptr 就说防止内存泄漏这个理解太窄了。在大型 C 项目里裸指针真正的问题不是悬垂而是所有权不明确。接口签名里写一个T*调用方不知道要不要 delete、什么时候 delete、能不能保存这个指针。于是一个指针被七八个模块轮流用最后要么 double free要么被提前 release要么干脆泄漏。shared_ptr 出现之后所有权这个事第一次有了可执行的文档性类型本身就在告诉你这个对象由引用计数管理大家共享没人用的时候自动销毁。但对高性能组件来说光有语义还不够。一个组件被大量实例化、大量传给下游、在并发环境里被反复读取和释放它的管理成本必须摊得足够薄否则就会从解决方案变成性能事故。shared_ptr 之所以能成为标准库里的默认共享语义工具是因为它把这份管理成本压缩到了一个 16 字节的对象和一次原子自增/自减里这个成本对于绝大多数业务的调用频率来说是可以接受的。1.2 为什么说 shared_ptr 是一个组件而不是一个指针从实现角度看shared_ptr 由两段内存组成指针本体和控制块control block。指针本体就是你在栈上或成员变量里持有的那 16 个字节两个裸指针宽度控制块则是堆上的一块独立内存里面存着引用计数、弱引用计数、删除器、甚至对象本身取决于创建方式。这个双元结构决定了它的行为模式和裸指针完全不同你拷贝一个 shared_ptr等于同时拷贝了我要用这个对象的意愿你析构一个 shared_ptr等于告诉控制块有个人不干了你数数还剩几个人。这种设计天然适合作为组件去分析因为它的性能特征完全由这两块内存的分配、访问和释放方式决定。后续所有优化手段本质上都是在调整这两块内存的分配位置和访问频率。所以先把模型建立起来shared_ptr 栈上指针本体 堆上控制块后面的开销计算都基于这个模型展开。2. shared_ptr 内部解剖控制块、原子计数与两级指针2.1 控制块的内存布局与两种创建路径控制块的标准布局大致是这样的一个强引用计数、一个弱引用计数可能还有一个删除器、一个分配器某些实现里还直接内嵌对象本身。标准库实现libstdc、libc、MSVC STL细节略有差异但基本结构相似。这里必须引入一个关键概念shared_ptr 的创建路径直接影响控制块和对象是否在同一块内存上。两种方式分别是// 路径一先 new 出对象再交给 shared_ptr auto p1 std::shared_ptrWidget(new Widget); // 路径二用 make_shared 一次性创建 auto p2 std::make_sharedWidget();路径一做的是两次堆分配第一次new Widget分配对象内存第二次shared_ptr构造函数分配控制块内存。路径二做一次堆分配把控制块和对象放进同一块内存。这一条差异的性能影响不是 2 倍那么简单后面单独算。另外std::make_shared还能消除new和shared_ptr构造之间的异常窗口。比如某个函数接收两个 shared_ptr 参数你用裸 new 传参时第一个 new 成功了第二个 new 抛异常第一个对象就没人管了。make_shared 不存在这个问题因为它把对象创建和计数管理放在同一条路径里完成。2.2 引用计数的原子操作开销怎么算引用计数必须用原子操作因为不同线程可能同时拷贝同一个 shared_ptr。控制块里的强引用计数是std::atomiclong一类的变量每次拷贝执行一次fetch_add(1)每次析构执行一次fetch_sub(1)减到 0 时触发删除器。一个常见的误解是原子操作很贵。实际上在 x86-64 平台上无竞争情况下的原子自增自减是 LOCK 前缀指令或较新的 XADD消耗几十个 CPU 周期。如果计数本身在 L1/L2 缓存里命中单次拷贝析构的开销可以控制在 20~50ns 以内。这个量级对于绝大多数业务代码是感知不到的。但问题出在竞争和缓存行迁移。如果多个 CPU 核同时操作同一个 shared_ptr那么控制块所在的缓存行会在各核之间反复横跳即 cache line ping-pong。一次跨核缓存行同步的代价可能达到几百纳秒如果这个操作出现在每个消息的关键路径上累积起来就是秒级延迟劣化。所以shared_ptr 慢这个说法要分场景单线程内拷贝不慢多线程共享高频拷贝才慢。2.3 为什么 shared_ptr 不是 T* 的简单替代这是很多人写代码的时候忽略的一点shared_ptrT的语义是管理 T 的共享所有权它不等于 T* 的自动释放版本。从性能视角看两者的差别非常具体T*的拷贝是一次寄存器/内存移动代价近似于 0shared_ptrT的拷贝涉及一次原子自增代价至少是一次 RMWRead-Modify-Write内存操作。T*不持有任何额外状态shared_ptrT自带一个堆上控制块为了这个控制块要付出额外的分配、访问、释放成本。T*不能表达共享意图shared_ptr 把共享意图内建于类型系统里这是功能增益但也是运行时成本。所以在性能敏感接口里我会先问这里真的需要共享所有权吗如果只是临时借用对象传const T或T*就够了。只有在对象的生命周期必须跨越多个独立的作用域、且无法确定谁最后退出时才值得动用 shared_ptr。3. 实测真实场景下的性能开销与瓶颈定位3.1 三类操作的基准数据我写了一个小基准程序在 x86-64、GCC 12、O2 优化下跑了几组数字单位纳秒/次用来给团队做决策参考。绝对数值取决于机器但相对关系具有普遍的参考价值操作大致耗时ns说明裸指针拷贝1~2就是一次 movshared_ptr 拷贝单线程15~30原子自增 内存访问shared_ptr 析构计数不为 015~30原子自减 判断shared_ptr 析构计数减为 0数十到数微秒触发删除器可能销毁大对象make_shared 创建80~150一次堆分配 对象构造 计数初始化new shared_ptr 构造150~300两次堆分配更慢shared_ptr 赋值覆盖旧值30~60旧计数自减 新计数自增注意最后一行的赋值操作是双向收费的这个特性在写循环代码时会放大开销。3.2 从火焰图看 shared_ptr 引发的缓存失效有一次排查线上高延迟perf 拉出来看热点函数本身很快但它的调用者反复传入和传出 shared_ptr。问题出在对象的生命周期被拖长了一个本应在函数栈帧结束时释放的临时对象因为被多个异步回调捕获为 shared_ptr导致它的控制块在堆上存了很久而且每次网络线程和计算线程交接时都要碰一次计数。缓存失效的链条是这样的线程 A 创建 shared_ptr 时把控制块读入本地缓存线程 B 通过 MessageQueue 拿到同一个 shared_ptr 的副本执行原子自增这个操作必须先把 A 那边弄脏的缓存行同步过来然后 B 操作完把缓存行标脏A 下次再碰又得同步回去。如果 A/B 在不同物理核甚至不同 NUMA 节点上这个往返距离是很可观的。火焰图里的锯齿就是这样形成的——你不是在算业务逻辑你是在帮 CPU 搬运缓存行。3.3 什么时候 shared_ptr 根本不是瓶颈话说回来我也见过不少团队把性能问题一股脑归咎于 shared_ptr结果优化完毫无波澜。原因很简单热点不在对象的拷贝上而在网络协议解析、日志格式化、无意义的深拷贝字符串上。shared_ptr 的单次开销是几十纳秒级别如果你一次请求里有几十次 shared_ptr 拷贝加起来才一两微秒而一次系统调用、一次锁等待可能就是几微秒到几十微秒。我的排查顺序是先看火焰图确认 shared_ptr 相关的符号_Sp_counted_base::_M_refcount、_M_release之类占到总 CPU 的多少百分比。如果占比超过 5%值得优化如果连 1% 都不到先去找真正的大头。这不是说 shared_ptr 没有优化空间而是说优化要打在刀刃上。4. 常见性能杀手shared_ptr 的错误打开方式4.1 用 shared_ptr 管理不该共享的对象这是我认为最普遍的问题。很多代码里一个对象只有明确的单一持有者但因为头文件里写了 shared_ptr大家都在传平白无故多了一堆引用计数的加加减减。比如一个只属于某个模块内部生命周期的对象根本没有共享的必要完全可以用std::unique_ptr。unique_ptr 的拷贝语义是被删除的它强制移动运行时开销近似于裸指针而且语义上表达了唯一所有权。我会在团队规范里明确一条默认用 unique_ptr需要真正共享时才用 shared_ptr。理由很简单unique_ptr 把所有权转移写进了类型系统编译器能在移动时生成极简代码往往就是一次指针搬移而 shared_ptr 的语义是所有权稀释每次拷贝都是计数交互。从设计层面讲能明确唯一所有权的地方就不要模糊化。4.2 循环引用与 weak_ptr 的正确介入时机循环引用是 shared_ptr 的教科书级坑点A 持有 B 的 shared_ptrB 持有 A 的 shared_ptr双方引用计数永远到不了 0内存泄漏。解决办法是在其中一个方向用weak_ptr打破环。但我要说的是weak_ptr 也不是免费的。当你通过weak_ptr::lock()获取一个 shared_ptr 时需要对弱引用计数做一次原子自增防止并发析构然后对强引用计数做一次原子自增这个过程比单纯拷贝 shared_ptr 更贵。所以在热路径上频繁 lock 是不可取的。我见过一种典型的误用一个缓存组件把对象存成 shared_ptr查找时每次都weak_ptr::lock()一次高并发下锁命中率 99%但大量的原子操作把性能拖下来了。更优的做法是如果业务上能保证缓存对象不会被主动删除直接把缓存值里保存成 shared_ptr读取时拷贝一份即可或者用专门的无锁缓存方案避免绕道 weak_ptr。4.3 在热路径里反复拷贝 shared_ptr这是性能问题的重灾区。比如一个每秒处理百万消息的循环void process(const std::shared_ptrRequest req) { auto copy req; // 原子自增 dispatch(copy); // 又一次拷贝 // ... } // copy 析构自减req 原本那个在退出时再自减每一层函数调用都拷贝一次一次请求下来可能产生七八次计数操作。优化思路有两个方向一是降频能传引用就传引用只在需要延生命的时候才拷贝二是降成本用移动构造代替拷贝。有时候你会看到std::shared_ptrconst T被反复传递但其实整个调用链没有任何地方存储它这种场景下完全可以全部改成const T。存储才是 shared_ptr 存在的理由传递不是。5. 高性能场景下的 shared_ptr 优化手段5.1 用 std::make_shared 一次分配控制块这个优化我放在第一个说因为它是性价比最高的。std::make_shared把对象和控制块放在同一块堆内存里一次分配、一次释放缓存局部性更好。前面已经提到过这消除了第二次堆分配。// 不推荐两次分配 std::shared_ptrBigObject p(new BigObject(1, 2, 3)); // 推荐一次分配 auto p std::make_sharedBigObject(1, 2, 3);另一个不那么容易察觉的优势是内存占用两次分配的场景里对象所在的一个堆块和控制块所在的另一个堆块各自带着 allocator 的头部元数据合并之后头部只要一份。如果一个系统里有几百万个 shared_ptr 对象这个差距就是几十 MB 级别的内存占用差异。缺点也有make_shared 会把对象和控制块绑在同一块内存上如果还有 weak_ptr 存在即使强引用计数已经归零控制块也要等弱引用计数归零才会释放整块内存对象内存无法提前归还给堆。对于超大对象 长期存活的 weak_ptr 这种场景你要么接受这个延迟归还要么退回到分开放置的方案来换取更及时的内存释放。工程上没有银弹只有权衡。5.2 移动语义与批量计数操作移动构造shared_ptr的开销基本为 0因为它只是把源指针搬运过来源目标置空不碰原子计数。所以在写函数返回值、容器元素插入这类的代码时尽量触发移动而不是拷贝。std::vectorstd::shared_ptrTask tasks; tasks.push_back(std::make_sharedTask()); // C11 里可能是拷贝或移动 tasks.emplace_back(std::make_sharedTask()); // 直接原地构造最优另一个技巧是批量 reset。如果你有一个 shared_ptr 的 vector要全部清掉重新填充逐个析构会触发多次原子自减。如果能确认没有其他线程持有这些 shared_ptr通常 vector 是线程私有时满足可以整体clear()编译器生成的析构循环也是逐个操作计数的这无法避免但你可以通过减少临时副本的数量来最小化计数操作总次数。核心原则是让每个 shared_ptr 的生命周期尽量单一避免同一份所有权被复制到多个临时变量里。5.3 当 shared_ptr 还不够intrusive_ptr 与写时复制在一些极端场景下shared_ptr 的控制块内存布局并不适合你。比如你已经有一个结构体它自身带一个ref_count字段或者你需要把引用计数与对象一起嵌入某个更大的内存池、共享内存段中。这时标准 shared_ptr 无能为力可以考虑 COM 风格的 intrusive 计数或者使用 Boost 的intrusive_ptr。intrusive_ptr 的思路是让对象自己管理计数intrusive_ptr_add_ref和intrusive_ptr_release由你实现控制块不需要单独分配。它的优势是内存布局紧凑、可以放进共享内存、计数由你控制比如用普通 int 加锁或者干脆不做原子操作如果对象只在单线程内使用。代价是你必须自己保证计数实现的正确性。另外还有一类通过写时复制Copy-on-Write, CoW优化 shared_ptr 的场景。比如你有一段数据被多个持有者共享但偶尔有人要修改局部内容如果直接修改会破坏其他人的视图一个思路是先拷贝出新的 shared_ptr修改时检查use_count()如果大于 1 就深拷贝一份再改如果等于 1 就原地修改。这个手法在构建不可变数据结构时非常有用能大幅减少拷贝次数。注意use_count()本身不是线程安全快照只适合在你有充分理由相信其他线程不在共享的情况下使用。5.4 只读共享shared_ptr 的隐藏含义用std::shared_ptrconst T是一件很容易被忽视的好事。它表达两个层面的意思数据只读、所有权共享。在缓存系统和配置管理模块里这个组合非常常见——多个模块要读取同一个配置对象但谁也不能改它。写代码的时候接口参数用shared_ptrconst T可以避免很多无谓的拷贝因为调用方可以很自然地把已有的 shared_ptr 转成 shared_ptr 而接收方又保证不会修改内容从而避免为了安全性而深拷贝。这里有个细节std::shared_ptrconst T和const std::shared_ptrT是两个不同的东西。前者是指向 const T 的共享指针指针本身可变可以重新指向别的对象但指向的对象不能改后者是const 的 shared_ptr指针本身不能改指向但可以修改对象。在高性能场景里我更多用前者因为它对并发读取友好线程可以各自持有一份 shared_ptr 同时读取。而const 的 shared_ptr 更多用于描述这个句柄不允许重新赋值属于接口设计层面的约束对性能没有直接影响。6. 工程实战组件库里的 shared_ptr 设计规范6.1 接口边界上的 shared_ptr 使用准则一个组件库的性能很大程度上由接口签名决定。我给自己定了一套 shared_ptr 的接口使用准则四个字存才传智。如果函数只是借用/观察对象不保存、不延长生命周期参数用const T或T*绝对不用 shared_ptr。这避免了一次计数自增和一次自减。如果函数需要异步保存对象比如投递到队列、存入缓存参数用 shared_ptr而且是按值传触发移动语义。如果函数返回一个对象的为新所有权优先返回std::unique_ptrT调用方再按需转成 shared_ptr。这可以从源头保证所有权清晰。如果 API 的语义就是共享同一个对象接口里明确写std::shared_ptrconst T并在文档里标注本函数会保留一个引用。用这套规则我们重构之后热点路径上的 shared_ptr 计数操作大约减少了一半火焰图里计数符号占比从 6% 降到了 2% 以下。6.2 线程安全语义的边界引用计数安全不等于对象安全这是使用 shared_ptr 必须刻在脑门上的原则**shared_ptr 只保证引用计数本身的线程安全不保证指向的对象线程安全。**多个线程同时持有一个引向同一对象的 shared_ptr它们可以各自安全地拷贝、析构自己的 handle但对象内部的成员变量需要自己加锁或用其他同步机制。实际工作中最危险的误用是以为 shared_ptr 能保护一个正在被析构的对象。如果一个线程持有 shared_ptr另一个线程把最后一个指向该对象的 shared_ptr reset 了第一个线程仍然能访问对象吗答案是如果第一个线程已经通过拷贝持有 shared_ptr那么 reset 后计数不会归零对象安全但如果第一个线程只是保存了一个裸指针没有扩大计数那 reset 之后这个裸指针就悬垂了访问就是未定义行为。所以规则是你想保护谁就拷贝谁的 shared_ptr别图省事传裸指针。6.3 我在实际项目中踩过的坑与排查思路最后聊几个具体坑。第一个坑是shared_ptr与std::bind/lambda 捕获导致的句柄蔓延。我曾经在一个事件分发组件里用 lambda 捕获 shared_ptr然后 lambda 被存入一个容器容器又传给另一个线程。问题出在 lambda 的拷贝语义上每次捕获 shared_ptr 都会计数自增这个 lambda 最终存了十几份对象退出核心流程很久以后还挂在内存里。排查时用gdb看引用计数发现比预期多了很多。修复方法是重新设计事件生命周期让事件对象在完成后主动释放所有 shared_ptr 引用然后用 weak_ptr 处理异步观察。第二个坑是没有删除器的 shared_ptr 并不总是安全的。标准 shared_ptr 的删除器默认是delete ptr但对于数组类型delete和delete[]是不同的早期代码里有人直接shared_ptrint[](new int[1024])析构时只 delete 了第一个元素。正确写法是删除器改成std::default_deleteint[]或者直接用std::shared_ptrint[]C17 起支持数组特化。这个坑不会立刻崩溃但堆内存损坏会在之后某个随机时刻爆发排查极痛苦。建议在代码评审阶段就卡住所有裸 new shared_ptr的组合强制要求std::make_shared。第三个坑是控制块与对象生命周期不一致导致的内存延迟释放。前面提到的 make_shared 把对象和控制块绑在一起如果还有 weak_ptr 存活对象所在的内存块就不会被释放。比如一个昂贵的缓存对象业务上认为它已经被淘汰了但因为某个订阅者的 weak_ptr 没释放整块内存一直占着。我用valgrind --toolmassif和heaptrack排查过这类内存膨胀发现占用来源于一个从未被 observer 清理的注册表。后来把 observer 的注册改为弱引用 定期清理问题才解决。这个场景下需要明确weak_ptr 的存在本身是有代价的不只是编译期还有运行时内存滞留。第四个坑是关于接口返回 shared_ptr的。如果你把一个函数声明为返回 shared_ptr每次调用它即使没有实际变化都会产生一次计数自增/自减。如果实现内部是用 make_shared 新建的那每次调用都很贵。有些地方其实应该返回const T如果对象生命周期由调用方保证或者返回 shared_ptr 的引用。但返回引用要非常小心一旦调用方没有按引用接收就会产生一次拷贝。这类坑往往要靠团队编码规范和 Review 来解决不能指望编译器提示。最后再分享一个我个人的实操习惯我定义了一个本地调试小工具在控制块析构时会打印对象残留的强引用计数。起初看着控制台里喷出的一堆非归零析构我一度怀疑是内存泄漏后来发现大部分都是本可以避免的临时拷贝。把零时拷贝消除之后不少程序的内存峰值下降了 20% 以上。这也是为什么我在组件库设计里会反复强调shared_ptr 是个好组件但它像一个精密的计数器每次多出来的拷贝都在暗处消耗你的 CPU 周期和缓存带宽。真正的优化不是查手册而是回到使用场景里一个问题一个问题地问这个所有权真的需要共享吗如果不需要就用更轻的东西。下一篇文章我会接着写 weak_ptr 和 enable_shared_from_this 的正确姿势以及它们在异步框架里的典型用法。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。