shared_ptr 引用计数争用:为什么每任务拷贝一次会让多线程负扩展
编译优化系列的第六篇。上一篇讲清了移动语义与 noexcept 怎样决定容器扩容的成本,本篇进入多线程场景,讨论一个被「智能指针零开销」口号掩盖的坑:shared_ptr 的引用计数是原子变量。单线程里拷贝一个 shared_ptr 看起来只是指针赋值,进入多线程后,同一个控制块上的原子增减会让多个核在一条缓存行上反复争夺所有权,极端情况下线程越多程序越慢。本篇用四种所有权模式在 1 到 12 线程上扫档,把正常的近线性加速与争用导致的负扩展放在同一张图里对照,再用反汇编里的 lock 前缀指令坐实机制。
本文要回答的问题
shared_ptr的引用计数存在哪里,拷贝和析构时具体执行什么指令?- 只读共享一个
shared_ptr也有原子开销吗,它和裸指针差距多大? - 为什么每个任务都拷贝一次
shared_ptr会让 12 线程比单线程还慢? - 缓存行乒乓是什么,它和 MESI 协议里的失效通知怎样对应?
- 工程上跨线程传递共享所有权,哪些写法便宜、哪些写法是隐性串行点?
一、控制块与原子引用计数
shared_ptr 本身只是两个指针:一个指向对象,一个指向堆上的控制块。控制块里记录着强引用数、弱引用数和删除器。拷贝一个 shared_ptr 意味着强引用数加一,析构意味着减一,归零才真正销毁对象。因为拷贝可能发生在任意线程,这两个计数操作必须是原子的——x86-64 上对应带 lock 前缀的读改写指令,如 lock xaddq(原子交换并相加)或 lock incq(原子自增)。
原子指令单看不贵,无争用时十几到几十个周期。贵的是争用:多个核同时对同一地址做原子读改写时,缓存一致性协议要保证这条缓存行在任何时刻只在一个核上处于可写状态。一个核刚拿到写权限做完自增,另一个核的同类指令就被迫先把该行失效、再从对方缓存里夺过来,一来一回就是上百周期的跨核流量。MESI 协议的状态迁移描述的正是这个过程:共享态(S)的缓存行被写入前必须先广播失效、升级成独占修改态(M),下一个核写入时再重复一遍,这条行就在核间像乒乓球一样来回弹。
这里有一个常被忽略的对照:移动一个 shared_ptr 不触碰引用计数。移动构造只是把对象指针与控制块指针从源对象偷到目标对象、源对象置空,计数既不加也不减,与上一篇里移动构造偷资源句柄是同一种机制。所以「按值传 shared_ptr」要分两种情况看:传进来的是右值、函数内 std::move 接管,全程没有原子操作;传进来的是左值拷贝,才会发生计数增减。工程代码里大量无谓的计数流量,来自本可以移动却写成了拷贝的传递。另一个实现细节是 std::make_shared 会把对象和控制块分配在同一块内存里,一次分配、缓存局部性好,但代价是 weak_ptr 存活期间对象占用的内存也无法释放——控制块与对象同生共死,对大对象加观察者的场景要权衡。
二、基准设计:四种所有权模式 × 六档线程数
固定总工作量 400 万个任务,每个任务对同一对象做 64 轮乘加,线程数按 1、2、4、6、8、12 扫档,任务均分到各线程。四种模式共享同一份 work() 逻辑,唯一变量是线程怎样持有对象。
| 模式 | 持有方式 | 引用计数流量 |
|---|---|---|
unique | 每线程一个 unique_ptr 独占对象 | 完全没有 |
raw | 全局对象的只读裸指针 | 完全没有 |
shared_ro | 主 shared_ptr 每线程启动时拷贝一次 | 每线程仅两次原子操作 |
shared_copy | 每个任务都拷贝一次主 shared_ptr | 每任务两次原子操作,全部打在同一控制块 |
shared_copy 不是凭空构造的稻草人:它对应一类真实写法——把 shared_ptr 按值传进高频回调、在任务队列的每个任务里按值捕获、或在热循环里反复按值传参。每次按值传递都是一次合法但高频的计数增减。基准在每格运行前做不计时预热,抹平模式顺序带来的缓存冷启动;每个模式的计算结果汇总成 checksum 打印,四档同线程数下 checksum 完全一致,先对齐语义再比耗时。
下图画出一次 shared_copy 任务里,两个核的原子操作怎样在控制块缓存行上交替,红色是跨核失效与夺占的往返。
读图抓住一个反差:下方对象本身只读、多核共享毫无争议,争用全部集中在上方那个小小的控制块上。也就是说,慢的不是业务计算,而是每次使用对象前后附带的所有权簿记。
三、扫档实测:一条曲线向上,三条曲线向下
每格跑 7 次取耗时中位,结果如下。注意纵轴跨度极大,下图左面板用对数刻度。
左面板三条低位曲线是健康形态:unique、raw、shared_ro 的耗时随线程增加从约 12 ms 一路降到 2 ms 上下,近线性加速。右面板给出量化的加速比,三者在 12 线程时达到 5.9–6.8 倍——没有更高是因为这台机器是 6 个性能核加 6 个能效核的异构结构,能效核主频更低,12 线程档的边际收益天然受限。
| 线程数 | unique (ms) | raw (ms) | shared_ro (ms) | shared_copy (ms) |
|---|---|---|---|---|
| 1 | 12.842 | 12.943 | 11.226 | 68.111 |
| 2 | 6.551 | 6.426 | 5.643 | 229.535 |
| 4 | 3.295 | 3.430 | 2.890 | 238.282 |
| 6 | 2.272 | 2.457 | 2.003 | 239.667 |
| 8 | 2.504 | 2.319 | 2.182 | 250.324 |
| 12 | 2.157 | 1.894 | 1.899 | 221.887 |
红线讲了另一个故事。单线程时 shared_copy 已经要 68 ms,约为只读模式的 5–6 倍,这是无争用原子指令本身的价格:每个任务两次原子操作、400 万任务就是 800 万次串行化的读改写。到两线程,耗时不降反升到 230 ms,此后一直趴在 220–250 ms 区间,12 线程相对自身单线程的加速比只有 0.27–0.31——加线程换来的是三成的效率。控制块那一条缓存行成为全局串行点,所有核的大部分时间花在等待缓存行所有权,而不是做乘加。
完整记录如下,含每格七次中位与反汇编证据段。

图中同档四模式的 checksum 相同,证明四条曲线跑的是等价计算;use_count_peak 随线程数从 2 涨到 13(主控块加各任务内的临时拷贝),说明引用计数确实在并发地大起大落。
四、反汇编证据:7 条 lock 指令与模式边界
曲线只能说明快慢,机制要看二进制。对基准二进制反汇编后统计带 lock 前缀的指令,共 7 条,形态是两类:lock xaddq 用于引用计数的原子交换相加,lock incq 用于自增快路径。
lock 前缀指令总数: 7
3 lock xaddq %rax, 0x8(%r14) # 控制块强引用计数的原子读改写
2 lock xaddq %rax, 0x8(%rbx)
2 lock incq 0x8(%r14) # 引用计数自增快路径这些指令只出现在 shared_ptr 拷贝与析构的代码路径里,unique、raw 两个模式的热循环一条都没有。对照第三节的数字可以建立定量直觉:无争用时单条原子指令约十几到几十周期,800 万次摊出约 55 ms 的额外成本;一旦两个以上核同时敲同一地址,缓存行失效与跨核取回把单次代价推高到上百周期,总耗时再翻三倍多,并且随核数增加不再改善。这也解释了 shared_ro 为什么与 raw 几乎同速:它的原子操作只发生在线程启动的一次性握手,400 万任务的热循环里没有任何 lock 指令,引用计数流量被摊薄到噪声以下。
五、改造前后与工程纪律
把争用写法改成正确的所有权传递,性能变化可以直接对照下表。
由此可以沉淀四条纪律。第一,跨线程传只读对象,优先传引用、裸指针或 std::ref,所有权留在外层;shared_ptr 的拷贝只发生在线程或任务的入口处,循环体内拿到的是 T& 或 T*。第二,确实需要共享所有权时,在线程启动时拷贝一份本地 shared_ptr 持有,整个线程生命周期复用,不要在热路径反复按值传参;能移动就移动,移动不产生计数流量。第三,留意按值捕获:lambda 按值捕获 shared_ptr 会在每次构造闭包时增减计数,高频回调场景改成引用捕获或传裸指针。第四,警惕多个 shared_ptr 实例恰好共享缓存行的伪共享问题——控制块通常独立分配,本篇的争用是真共享,但自定义分配器把多个控制块排满同一缓存行时会引入更隐蔽的伪共享,排查思路与伪共享实测同源。
真实代码库里怎样发现这类问题?采样剖析器(Linux 上的 perf record/report、macOS 上的 Instruments 计数器模板)是入口:若热栈里反复出现 shared_ptr 的计数函数、反汇编对应 lock xadd 一类指令,且并行度上不去、CPU 使用率随线程数升高却不见吞吐增长,基本可以锁定原子争用。没有剖析器时,临时手法是给可疑类型的拷贝构造加静态计数,跑一遍真实流量看单位时间拷贝次数——与上一篇定位隐式拷贝的计数器手法完全相同。定位之后的修复顺序也有讲究:先把热路径的按值传递改成引用或移动,这是零风险的本地修改;再考虑把共享对象改成线程私有副本或消息传递,从结构上消除共享所有权;最后才是无锁或分片计数这类复杂方案,绝大多数场景走不到这一步。
实验环境
- 机器/系统:Mac mini(6 性能核 + 6 能效核,12 逻辑线程)/ macOS 26
- 工具版本:Apple clang 21.0.0(clang-2100.1.1.101),编译参数
-O2 -std=c++17 -Wall - 日期:2026-11-11
- 完整命令:
make构建bin/bench→bash run.sh执行 7 轮线程扫档(1/2/4/6/8/12 × 四模式),按格取耗时中位 → 同一脚本用otool -tvV统计 lock 前缀原子指令
代码获取
完整可复现工程(四模式 runner、线程扫档驱动、预热与 checksum 防消除、run.sh 编排与实测 results.txt)已公开:
- 仓库地址:https://github.com/derekzhuo/geek-doc.cn/tree/main/experiments/cpp-shared-ptr-refcount-contention
- 复现方式:克隆后进入该目录执行
bash run.sh,约两分钟输出 24 格计时中位与 lock 指令证据;调整main.cpp顶部的线程扫档表可以在任意核数机器上复测,异构核机器上加速比上限会低于逻辑线程数,属预期现象。
这一篇在知识体系里的位置
本篇属于 C++ 性能实测系列的值语义与内存管理分组。RVO 与移动语义实测解决单线程内对象怎样少拷贝,本篇解决多线程间所有权怎样传递不引入串行点;机理上引用的缓存行乒乓与 MESI 协议、伪共享排查构成同一条缓存一致性知识线。后续同组会展开 std::function 的小对象缓冲阈值、字符串 SSO 与 string_view 的去拷贝边界。
总结
在四模式 × 六档线程的扫档基准上,shared_ptr 的原子引用计数成本呈现清晰的两面性:只读共享与每线程一拷两种模式热循环内没有 lock 指令,12 线程保持 5.9–6.8 倍的近线性加速、1.9 ms 收工;每任务拷贝一次则让 800 万次原子读改写全部打在同一控制块缓存行上,单线程已慢约 5 倍,多线程下缓存行乒乓把加速比压到 0.27–0.31、耗时趴在 220–250 ms,反汇编中的 7 条 lock xaddq/lock incq 指令正是这个串行点的物证。工程结论是所有权在外层一次性交接、热路径只传引用或裸指针,让原子簿记离开循环。