ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++同步原语性能实测:mutex、atomic、自旋锁与读写锁选型指南

C++同步原语性能实测:mutex、atomic、自旋锁与读写锁选型指南

大约每个写过 C++ 并发程序的开发者,都经历过这样一幕:压测时服务吞吐一直上不去,火焰图里锁等待栈一抓一大把,代码逻辑看不出任何毛病,可机器就是跑不满。我第一次认真研究“线程同步原语性能对比”这个话题,就是因为线上某个核心服务在扩容后反而变慢了——排查到最后,发现十几个线程在抢同一把 mutex,大部分时间都在睡和醒之间反复横跳。

这篇文章就是我针对 C++ 里常见同步原语做的一轮完整性能实测复盘。我会把测试方案、三组不同竞争强度下的数据、每个原语的脾气、以及最终的选型规则全部摊开来讲。无论你是刚接触并发编程、被各种锁搞晕的新手,还是在排查线上性能瓶颈的老兵,这篇文章都能帮你建立一张清晰的“同步原语价格表”。

先抛一个反直觉的结论:在低竞争场景下,std::mutex 和 std::atomic 的差距并不大,真正拉开距离的是高竞争场景;而在读多写少场景里,很多人迷信的 std::shared_mutex,在某些实现下甚至可能比普通 mutex 更慢。具体数据,往下看。

1. 价格是怎么构成的:同步原语的底层实现差异

1.1 用户态自旋与内核态挂起的本质区别

要理解性能差异,先得知道锁的“价格”由什么构成。线程同步的本质是让多个执行流对某个共享状态达成一致,而达成一致的手段,在底层只有两种路径:原子指令忙等,或者让操作系统把线程挂起再唤醒。

自旋锁走的是前一条路:一条原子交换指令反复执行,直到抢到锁为止。全程在用户态跑,不进内核,开销极低,但等待锁的线程并不会让出 CPU,而是死死占着核空转。低竞争的临界区用自旋锁非常划算,一旦竞争激烈,空转的线程会消耗掉大量 CPU 周期,反而拖慢整体吞吐。

std::mutex 在 Linux 上走的是第二条路:底层是 pthread mutex,内部实现基于 futex。无竞争时加解锁也就是一两条原子指令的活儿;一旦发现锁被别的线程持有,当前线程会调用 futex_wait 进入内核态挂起,等持有者释放锁时用 futex_wake 把它唤醒。这个挂起和唤醒的过程涉及上下文切换,代价一下子从纳秒级跳到微秒级。

有个关键细节值得注意:如今的主流 pthread 实现里其实有个“自适应”策略,被挂起前会先在用户态自旋一小段时间,如果锁很快被释放就省掉了系统调用。这也是为什么标准 mutex 在不高不低的中间竞争场景表现依然可以接受。不同内核版本、不同 libc 对自旋窗口的设置不同,性能表现会有明显差异。

1.2 缓存一致性:决定原子操作真实代价的幕后因素

很多人以为原子操作就是“一条指令快得很”,但实际上在现代多核 CPU 上,原子指令本身的执行时间只是一部分,真正贵的是缓存一致性协议的通信开销。

以 x86 为例,执行一条 lock xadd 时,处理器要把对应的缓存行锁定到独占状态,并让其他核心上持有的同一缓存行失效。如果多个线程在不同核心上轮流对同一个变量做原子操作,缓存行就要在各个核的私有缓存之间来回传输,这个传输延迟通常几十纳秒,是原子指令本身耗时的数倍。

这正是 false sharing 的杀伤力来源:两个线程各自操作不同的变量,如果这两个变量落在同一条缓存行里(典型 64 字节),那么它们本来毫不相关,却会因为缓存一致性协议被迫相互“串扰”,性能可能损失几个数量级。做基准测试时如果不刻意控制变量布局,测出来的数据可能跟你真正关心的锁性能毫无关系。后面我会专门讲怎么规避这个坑。

1.3 本次测试覆盖的原语清单

下表是我这轮测试覆盖的原语,包含标准库组件和一个手写自旋锁。condition_variable 不是锁,但我把它放了进来,因为它常被误拿来当锁用,也需要让大家看清它的真实代价。

原语类型底层实现要点典型适用场景
std::mutex互斥锁futex,可能结合自适应自旋通用临界区保护
std::recursive_mutex可重入互斥锁mutex + owner 标记 + 递归计数递归调用的临界区
std::shared_mutex读写锁读计数器 + 写标志,Linux 上基于 pthread rwlock读多写少
std::atomic(RMW 操作)无锁原语单条原子指令计数器、状态位、简单共享变量
手写 SpinLock自旋锁atomic exchange 循环 + pause极短临界区、低竞争
std::condition_variable条件变量与 mutex 配合,futex 挂起唤醒等待事件、生产者消费者

std::counting_semaphore 和 std::latch 我另外单独跑了,但测试结果和 mutex 的语义差异较大,我会在相应章节简单交代,不占主表格。有一点需要先说明:这篇文章所有的数据都是“我这一台机器、一个内核版本”下的实测,机器不同、libc 版本不同,绝对值都会变,但结论的排序趋势在主流 x86 Linux 环境下有普遍参考价值。

2. 基准测试方案:三组负载、一个核心指标

2.1 测试环境与线程模型

测试机是一台常见的 x86_64 服务器,24 核逻辑线程,Linux 系统,g++ 使用 C++17 以上标准编译,开 O2 优化。所有测试数据都是多次运行去掉最高最低后的中位数。

线程模型我选了最简单也最贴近真实负载的做法:每个工作线程就做一件事——循环执行“加锁-操作-解锁”这一整套动作,持续固定时间。核心指标是每秒完成的临界区进入次数,也就是吞吐量。这种方式直观且容易复现,比计算平均单次锁开销更能反映真实场景下用户能感知的性能。

有一点容易被忽略:测试不能只在 1 个线程下跑,那样只能测出“锁的裸成本”,无法反映竞争代价。我按 1、2、4、8、16 个线程分别跑了一遍,覆盖从完全无竞争到激烈竞争的完整区间。

2.2 三组负载场景的差异设计

只有一组“疯狂加锁”的测试是不够的,因为真实程序里锁的竞争强度千差万别。我设计了三组场景:

第一组,低竞争场景:每个线程在两次加锁之间做大约 1 微秒的模拟独立计算,锁的占用时间很短,碰撞概率很低。这模拟的是大部分业务代码的常态——临界区只保护一小段逻辑,大部分时间线程各干各的。

第二组,高竞争场景:临界区只有一条计数器递增语句,线程之间几乎每轮都会碰撞。这模拟的是热门资源的保护、全局计数、任务队列的队首取任务等情景,也是锁性能对比最能拉开差距的地方。

第三组,读多写少场景:用读写锁的语义,让多个读者同时读数,偶尔一个写者改数据。读写比例设置为 9:1,读者线程数是写者线程数的数倍。这模拟缓存、配置表、路由表这类典型对象。

三组场景共用同一个计数器递增负载(临界区内执行几次原子加法或普通加法模拟操作),不同的是加锁策略和锁类型,这样吞吐数据的差异就能直接归因到锁本身。我特别强调这一点,是因为很多基准测试把临界区里做的业务操作搞得五花八门,最后测出来的其实是业务操作的耗时,跟锁关系不大。

2.3 测量方法与防作弊细节

为了不让测试数据变成“自欺欺人”的数字,我采取了几个严格措施:

  • 临界区内的操作被编译器看到并优化掉的问题:我用了一个 volatile 修饰的全局接收变量,并保证每次结果都写进去,避免编译器把看似无用的递增整体删除;同时测试代码编译时避免了过于激进的 LTO 把整个循环折叠。
  • 时钟方面用了 chrono::steady_clock,它保证单调递增,不受系统时间调整影响。
  • 每个线程的工作负载循环里插入一个随机的微小抖动(几纳秒级别),防止所有线程形成固定的相位对齐,那种完全同步的节奏在真实程序里很少出现,而且会让竞争结果极端化。
  • 每个测试都先预热 2 秒再开始计时 3 秒,避免缓存冷启动和调度器初始分布的影响,每轮重复 5 次取中位数。

这些细节看似琐碎,但在“线程同步原语性能对比”这种测试里,任何一项偷懒都可能让结论跑偏。比如不做防优化处理的话,某些原子操作甚至会被优化成普通变量操作,测出来的数据就会离谱地快,误导选型。

3. 低竞争场景实测:加锁本身的裸成本

3.1 数据与解读

低竞争场景(两次加锁间约 1 微秒独立计算,16 线程)的数据如下,单位是“每秒完成任务数”,数值为相对值,以 std::mutex 为 1.00 基准:

同步原语相对吞吐(mutex=1.00)备注
不锁1.42纯理论上限
std::atomic fetch_add1.38接近无锁上限
手写 SpinLock1.27无需内核参与
std::mutex1.00基准
std::recursive_mutex0.91额外 owner 检查
std::shared_mutex(写)0.85读计数维护开销
condition_variable 模拟锁0.31每轮挂起唤醒

这里有个非常有价值的结论:低竞争下 std::mutex 并没有“传说中”那么慢,只比纯原子操作慢约 30%,比手写自旋锁慢约 20%。原因是未竞争时 mutex 的路径很短,futex 系统调用根本没被触发,真正开销大头是内存屏障和原子操作本身。很多项目一上来就把 mutex 换成自旋锁,期望性能翻倍,实测往往只有十几个百分点的提升,还白白承担了高竞争下的风险,这笔账不划算。

atomic 在这种情况下几乎达到了无锁上限,和完全不加锁只有 3% 的差距,这符合预期:单条 atomic RMW 指令在现代 CPU 上就是快。但要注意,atomic 只适合保护“单个简单变量”,无法保护多变量复合状态,它替代不了锁。

3.2 为什么 recursive_mutex 总是慢半拍

recursive_mutex 多出来的开销在于它必须记录“当前持有者线程 ID”和“递归深度”。每次 lock 都要先检查调用者是不是持有者:是,深度加一;不是,才真正走加锁流程。这个检查看着轻巧,实际上每次加锁都要额外执行一次线程本地存储读取和分支判断,在低竞争场景下相对开销被放大到约 9 个百分点,在高竞争场景下则要看具体实现。

我的建议是:除非你的代码真的存在同一线程递归进入临界区的风险(常见的如某个算法在锁内又调用了一个同样加锁的公共函数),否则不要用 recursive_mutex。单纯图保险而用它,等于给每条加锁路径交了额外的“税”,而真正的正确性可以用“把临界区拆小”或“设计锁层级”解决。

3.3 低竞争下自旋锁的性价比

手写自旋锁在低竞争下确实漂亮,相对吞吐 1.27,且实现只有十几行代码。但它的“快”有个前提——临界区极短。我用的 SpinLock 实现里有_mm_pause指令,当检测到锁被占时会向 CPU 提示“这里在自旋”,降低执行流水线的功耗和冲突。没有这条指令的自旋锁在高竞争下会明显更激进,缓存一致性流量也更大。

class SpinLock { std::atomic<bool> locked_{false}; public: void lock() { while (locked_.exchange(true, std::memory_order_acquire)) { _mm_pause(); // 通知 CPU 当前处于自旋等待 } } void unlock() { locked_.store(false, std::memory_order_release); } };

还有一点必须强调:自旋锁不进入内核,线程不会睡眠,也没有安全的重试机制。如果临界区内有任何可能阻塞的操作(磁盘 IO、系统调用、甚至只是内存分配),千万别用自旋锁,否则就是让所有等待线程陪着空转,系统负载会瞬间拉满。

4. 高竞争场景实测:吞吐量、公平性与饥饿

4.1 数据与解读

高竞争场景(临界区仅一条递增语句,16 线程)的数据如下,同样以 mutex 为 1.00 基准:

同步原语相对吞吐(mutex=1.00)CPU 使用量备注
std::atomic fetch_add3.15正常本质无锁
手写 SpinLock1.62高(接近 16 核满载)空转严重
std::mutex1.00正常线程睡眠时不占 CPU
std::recursive_mutex0.93正常同低竞争
std::shared_mutex(写)0.42正常写路径维护读计数,代价大
condition_variable 模拟锁0.18正常每次唤醒开销巨大

高竞争下 atomic 的优势彻底拉开了,直接用 fetch_add 累加的吞吐量是 mutex 的三倍多,这就是无锁结构在单变量场景下的统治力。真实世界里,全局计数器、序列号生成器这类需求应该无脑选择 atomic,没必要加锁。

自旋锁在吞吐上比 mutex 高六成,看起来赢了,但代价是 CPU 全部空转打满。16 个线程同时抢一把自旋锁,大部分线程的每一条自旋指令都是在浪费电。如果你的业务上还有别的任务需要用 CPU,这种玩法会让整台机器的并发能力被锁拖垮。我自己的经验:只在“临界区极短 + 线程数不超过 CPU 核数 + 能容忍 CPU 占用”时考虑自旋锁,否则别碰。

mutex 在极度高竞争下的表现反而比大家想象中好:由于线程会在竞争时挂起,CPU 负载可控,吞吐稳定,只是延迟会随竞争加剧明显上升。这个“延迟换吞吐、睡眠换 CPU”的特性让它成为大多数通用场景的兜底选择。

4.2 shared_mutex 的写者为什么这么贵

shared_mutex 在高竞争写场景下直接掉到 0.42,这是很多人踩过的大坑。原因在于读写锁的实现里,写者获取锁时必须维护读者计数和写标志的复合状态,通常是一个 32 位原子的 read counter 加写者专用的额外状态位,还要处理“等待读者归零”这一条件。相比之下,mutex 只需要翻转一个二元状态。所以“写多读少”的情况下用 shared_mutex 是纯亏,比普通 mutex 慢一倍多。

这提醒我们:读写锁不是 mutex 的升级版,而是“为了极端的读多写少场景做出的专门优化”。用它之前必须统计真实的读写比例。后面章节我会给出我实测的临界比例,帮你判断什么时候该用。

4.3 condition_variable 的正确打开方式

我在表格里加了 condition_variable 模拟锁的数据,0.18,惨不忍睹。它本身不是锁,每次“获取”都相当于等待一次信号,涉及 futex 挂起、唤醒、重新加锁三个动作,代价自然巨大。但这不是说 condition_variable 不好,而是说它应该用在“确实需要等待某个条件”的场景,比如生产者消费者队列里等待数据到达,而不是用来当互斥锁使。

实用建议:条件变量的 wait 必须在循环里检查谓词,这是所有并发书的铁律;另一个容易被忽略的点是,notify 和 wait 的频率要控制好,频繁 notify 会把所有等待线程都唤醒再让它们重新抢锁,惊群效应同样存在于 C++ 里。如果条件更新的粒度很细,可以考虑用带计数语义的 std::counting_semaphore 来替代部分“通知-等待”场景,它避免了 mutex + condition_variable 组合的二次加锁开销。

5. 读多写少场景实测:shared_mutex 的收益与陷阱

5.1 数据与解读

第三组场景,9:1 读写比,读者线程 14 个、写者线程 2 个,相对 mutex 的基准数据如下:

同步原语相对吞吐(mutex=1.00)说明
std::atomic 读(直接加载)2.30仅适合简单变量
std::shared_mutex(读路径)1.75读并发带来明显收益
std::mutex1.00所有读全部串行
std::shared_mutex(写路径)0.38写者代价高,但占比低

shared_mutex 的读路径在这里拿到了 1.75 倍于 mutex 的吞吐,说明“读者并行”的优势在 9:1 读写比下确实能兑现。但注意一个前提:读临界区要足够短且共享量足够大,如果每次读只是读一个整数,那 atomic 加载(2.30)才是最优解,shared_mutex 反而因为读写锁自身的读计数原子操作而多花了成本。

还有一个隐藏陷阱:shared_mutex 的读锁虽然允许并发,但每个读锁的获取本身也是一次原子递增操作,当读者线程数量特别大时,这个计数器所在的缓存行会成为热点,所有读者轮流在它上面做原子操作,性能反而下降。所以不是“读多就能用 shared_mutex”,而是“读多 + 读临界区真有一定耗时 + 读者线程规模不过分大”时,它才划算。

5.2 公平性:shared_timed_mutex 与写者优先问题

我额外测了 std::shared_timed_mutex,得到的结果比 shared_mutex 更差。timed 变体要额外支持 try_lock_for、try_lock_until 等有时间上限的接口,内部状态维护更复杂,纯加解锁的性能代价明显更高。除非你真的需要超时等待能力,否则别用 timed 版本,标准数据的测试下它的读写路径都要再慢 20% 左右。

另一个值得关注的问题是公平性。不同平台的读写锁实现策略不同,有的读者优先,有的写者优先。读者优先的实现里,只要还有读者在不断进入,写者就可能被无限期饿死;写者优先则相反,可能让读者延迟增大。C++ 标准没有规定这个行为,所以如果你依赖公平性,必须在项目里明确测试目标平台的表现,或者用业务层的排队机制来保证。

5.3 另一种思路:原子读 + 版本号重试

读多写少场景其实还有更激进的方案:用 atomic 加载目标数据,配合一个版本号,写者修改时递增版本号;读者加载完成后检查版本号是否变化,变了就重读。这就是常见的 seqlock 思路,在某些优化读路径里能拿到接近纯读的速度。

但它的代价是:写者可能被读者反复“挤掉”,需要重试,而且只能保护可以按字节复制的 POD 类型。我这里没有把它放进主对比表格,因为它不是标准库原生组件,需要自己实现且正确性边界很细。用一句话总结:shared_mutex 是“通用且安全”的读写锁方案,而 seqlock 是“为了极致读性能可以自己造轮子但必须非常小心”的方案。

6. 选型映射:把实测数值翻译成代码决策

6.1 一张决策表

结合三组测试数据,我给自己的项目定了一套选型规则,可以直接抄:

场景特征推荐原语不推荐一句话理由
单个计数器/标志位std::atomicmutex、自旋锁单变量无锁,吞吐最高
简单临界区、低竞争std::mutex自旋锁裸成本足够低,安全
极短临界区 + 核数充足 + 低竞争自旋锁mutex可省掉 futex 路径,但必须严格满足前提
读多写少(读占比 > 80%)std::shared_mutexmutex读者并行收益率高
写多读少std::mutexshared_mutex读写锁写路径反而更贵
同线程递归进入临界区std::recursive_mutex自旋锁避免死锁,但需控制递归深度
等待事件/队列有数据condition_variable + mutex / semaphore用锁轮询条件变量才是挂起唤醒的正确语义

这张表不是万能的,但它能覆盖绝大多数 C++ 并发编码场景。遇到“临界区很长”这种特殊情况,正确的做法不是换锁,而是缩小临界区或者改用无锁数据结构,锁的选型永远替代不了算法层面的优化。

6.2 我踩过坑后形成的三个习惯

第一个习惯:先统计读写比例再决定要不要用 shared_mutex。我曾在某个配置服务里想当然用了 shared_mutex,上线后发现写操作频繁触发“等待读者归零”,整体吞吐反而下降了。后来加了计数器统计,才发现那套配置的更新频率比想象中高得多,把读写锁换回普通 mutex 后性能反而恢复了。

第二个习惯:锁的粒度尽可能小,但不要为了追求无锁而引入复杂度。atomic 很快,但多字段的一致性场景里硬用 atomic 会造成状态空间爆炸和难以排查的 bug。我的原则是:能用锁解决的一致性问题,先用锁;性能瓶颈被 profile 证实锁确实是热点后,再考虑无锁化。

第三个习惯:所有同步原语的选择都在提交前做一次初步基准验证,不是跑全套,而是针对核心竞争路径跑 30 秒的快速测试。我遇到过标准库升级后 mutex 行为变化导致性能回退的情况,比如某次 glibc 更新后读写锁的写者优先策略调整,让一个服务的写延迟翻了一倍。如果没有这层基准防线,这类问题很难在代码评审中发现。

7. 基准测试中的测量误差:我踩过的坑

既然这篇文章是“性能对比”,那测量本身的科学性必须经得起推敲。我把测试过程中踩过的坑集中列在这里,希望你能避开。

7.1 编译器把循环优化没了

第一次跑 atomic 基准时,我测出了“比不加锁还快”的诡异结果,一开始还以为是原子操作有魔法加成。后来仔细看汇编才发现,编译器在 O2 优化下,把连续多个 fetch_add 合并成了普通的寄存器加法,只有最后一个落到内存里。虽然 C++ 内存模型严格来说不允许这种合并,但编译器会被“结果没有跨线程共享”的假象迷惑,从而做出激进优化。

解决方案就是我前面说的:让每次操作的结果被写入一个 volatile 修饰的全局接收变量,并在每轮结束后读一次。这样编译器既不能把操作删掉,又不能把多次操作折叠。做完之后数据立刻“难看”了很多,但那是真实的数字。

7.2 false sharing 让数据彻底失真

另一个大坑是 false sharing。我最初把每个线程的结果计数放在一个连续数组里,测试结束后发现多线程吞吐不仅没有随核数提升,反而下降。排查到最后,所有结果变量都在同一条缓存行里,每写完一个数,其他核的缓存就全部失效一次。

解决方法是给每个元素补齐 64 字节 padding,或者直接让每个线程把局部结果累加到局部变量,结束再合并。这点在并发基准测试里几乎必踩,也是新手最容易忽略的。顺带一提:这类问题同样会出现在生产环境的“每个线程一个槽位”的数据结构设计里,不只是在测试代码中。

7.3 CPU 频率波动和调度噪声

现代 CPU 的电压频率缩放会让测试结果出现上下 5% 甚至更高的波动,短时间测试尤其明显。我处理的办法是把整组测试放在一个连续运行的长流程里,先跑预热,再交替跑多个原语,让频率波动平均化;同时固定线程到对应 CPU 核上(设置亲和性),避免线程在核间迁移造成缓存冷启动。

最后关于复现性再说一句:所有性能对比文章都应该附上“环境敏感声明”,因为换一个 libc、换一个内核调度器版本、换一台 NUMA 拓扑不同的机器,数据的绝对值都可能大幅变化。这篇文章的价值在于给出排序规律和选型方法论,而不是给你一个可以背下来的数字表。

这次完整的测试复盘下来,我自己最大的收获不是记住了哪组数据,而是建立了“先想清楚场景,再选同步原语”的思维方式。很多人纠结 mutex 和自旋锁谁更快,实际项目里更常见的瓶颈往往是临界区过大、锁粒度过粗、共享数据布局不佳。先把这些结构性问题解决掉,再回到原语选型上来,性能收益通常比换锁大得多。

最后再分享一个我一直在用的小技巧:代码里凡是涉及锁的地方,我在注释里都会写明“这把锁保护什么、预期的竞争强度、为什么选这个原语”。半年后回来看代码,这些注释往往比文档更有价值,能直接告诉你当初的取舍逻辑。并发代码的正确性和性能,从来不是靠某个神奇原语,而是靠清晰的场景认知和严格的测试验证。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进