资讯详情

C++同步原语性能对比:atomic、mutex、自旋锁与shared_mutex选型指南

📅 2026/10/24 6:56:39 | 华诺云谱 👁 阅读
C++同步原语性能对比:atomic、mutex、自旋锁与shared_mutex选型指南
做并发优化的那阵子我卡在一个高频交易模块上很久大量线程同时在同一个数据上读写最开始全用std::mutex一把大锁包住压测时锁竞争非常严重吞吐量怎么都上不去。后来我把std::atomic、自旋锁、std::shared_mutex都拉出来做了一轮对比测试结果差异大得惊人。这篇内容就是把我的测试过程、数据结果和一些同步原语背后的原理整理出来给正在做选型或者被锁竞争折磨的朋友一份可以直接用的参考依据。1. 同步原语全景与选型基础1.1 四种原语的本质区别做同步原语对比前先对齐一下基础概念。我这边主要测了四类std::mutex、std::atomic、自定义自旋锁、std::shared_mutex。它们虽然都叫“同步原语”但底层的实现逻辑和适用场景差距很大。std::mutex是标准的互斥锁。线程进入临界区前必须拿到锁拿不到就进入阻塞状态由操作系统调度唤醒。它最靠谱但开销也是最大的。每次锁操作可能涉及用户态到内核态的切换、上下文切换、唤醒等待队列里的线程这一套流程下来微秒级开销是常态。std::atomic是原子操作封装。它不依赖操作系统调度直接在 CPU 指令层面保证操作的原子性。对单个变量做fetch_add、compare_exchange之类的操作时性能远高于锁。但它只能解决“单个内存位置”的同步没法保护一段多语句的临界区一旦逻辑复杂起来就没法直接用。自旋锁是“忙等”式锁。锁被占用时线程不会睡眠而是循环检测锁状态直到拿到锁。优点是避免了上下文切换缺点是白白消耗 CPU。自旋锁在临界区极短、线程数不多、多核环境下非常好用但设计不合理时性能可能比 mutex 还差。std::shared_mutex是读写锁。多个线程可以同时持有读锁写锁则是独占的。适合读多写少的场景但写锁的竞争成本比普通 mutex 要高因为它要维护读计数和写状态内部原子操作更多。注意自旋锁在并发编程里不属于标准库C 标准库目前没有提供自旋锁实现需要自己封装。我在测试中自己写了一个后面会给出代码。1.2 锁竞争成本到底藏在哪很多人以为“锁慢是因为多做了几条指令”其实没那么简单。锁开销的大头是两个上下文切换和缓存一致性流量。std::mutex在锁被占用时等待线程会睡过去让出 CPU。如果临界区很短频繁的睡眠、唤醒会让线程在“运行—阻塞—运行”之间来回切换。一次上下文切换的成本大约在几微秒到几十微秒而且线程被唤醒后不一定马上拿到 CPU还会经历调度延迟。临界区越短mutex 的相对开销越大这也是高频小临界区场景下 mutex 性能惨烈的核心原因。自旋锁虽然避免了上下文切换但它引入的是另一类问题缓存一致性流量。当一个线程在exchange(true)自旋等待时它的循环会反复尝试写入这个锁变量的缓存行。每次写入都会让持有锁的 CPU 核心上的缓存行失效造成缓存行在所有核心之间来回传输这叫“缓存行乒乓”。多核环境下竞争越激烈这个流量越夸张。std::atomic之所以快是因为它跳过了操作系统参与直接在 CPU 流水线里完成。但要注意原子操作在多个核心同时访问同一缓存行时依然会走缓存一致性协议并不是完全无代价的。1.3 伪共享比锁竞争更容易被忽略对比之前必须说清楚“伪共享”false sharing因为它对性能的影响经常被误判成锁的问题。CPU 缓存是以缓存行一般 64 字节为单位加载的。如果两个线程操作的内存地址在不同的变量上但这两个变量恰好落在同一条缓存行里那么其中一个线程修改变量时另一个线程的缓存行也会失效导致本该互不影响的变量产生频繁的缓存刷新。我实际碰到过一次两个std::atomiclong挨在一起定义分别被两个线程高频写入结果性能比分开定义时差了七八倍。排除了锁的问题后才发现是伪共享。解决办法很简单用alignas(64)把变量对齐到缓存行边界让它们各自独占一条缓存行。这个坑在后面所有基准测试里都要特别注意否则测出来的数据会失真。2. 性能对比方法论与基准设计2.1 三个典型竞争场景的设计思路同步原语的性能没有绝对好坏只有“是否适合当前场景”。所以我没有只跑一种压测而是拆成了三个典型场景。第一个是高频竞争场景所有线程反复进入同一个临界区做的最小操作只是递增一个共享计数。这个场景模拟的是临界区极短、竞争极其激烈的状态比如统计计数器、简单的状态标志位更新。第二个是低频竞争场景临界区外有耗时操作锁在整个工作流程中占比很小。模拟的是“大部分时间在做计算偶尔需要同步”的情况比如定时采集数据、周期性的批量刷新。第三个是读多写少场景90% 以上的操作是读只有少数是写。模拟缓存、白名单、配置表这类数据大多线程同时查询偶尔有线程更新。2.2 基准测试的完整过程测试环境是我手头的一台 Linux 工作站16 核 32 线程。我统一采用 8 线程、每线程 100 万次迭代的高频竞争测试所有代码编译成 Release 模式开启 O3 优化。为了减少随机误差每轮测试跑 3 次取中位数。高频竞争场景的核心代码大概是这样std::atomiclong atomic_counter{0}; void atomic_test(int iterations) { for (int i 0; i iterations; i) { atomic_counter.fetch_add(1, std::memory_order_relaxed); } } std::mutex mtx; long plain_counter 0; void mutex_test(int iterations) { for (int i 0; i iterations; i) { std::lock_guardstd::mutex lock(mtx); plain_counter; } }自旋锁我用的版本是经典的双重检查自旋class SpinLock { std::atomicbool flag_{false}; public: void lock() { while (flag_.exchange(true, std::memory_order_acquire)) { while (flag_.load(std::memory_order_relaxed)) { std::this_thread::yield(); } } } void unlock() { flag_.store(false, std::memory_order_release); } };第一层exchange是抢锁第二层load是等待时的高效自旋yield是给其他线程让出执行时间片避免死循环烧死 CPU。整体测量方法用的是std::chrono::steady_clock所有线程通过一个原子等待变量同时开始最后汇总总耗时。下面是基准框架的关键片段template typename Fn double measure(Fn fn, int thread_count, int iterations) { std::atomicbool start{false}; std::vectorstd::thread workers; std::atomiclong ready{0}; for (int i 0; i thread_count; i) { workers.emplace_back([] { ready.fetch_add(1); while (!start.load()) {} fn(iterations); }); } while (ready.load() thread_count) {} auto t0 std::chrono::steady_clock::now(); start.store(true); for (auto t : workers) { t.join(); } auto t1 std::chrono::steady_clock::now(); return std::chrono::durationdouble, std::milli(t1 - t0).count(); }这里有个细节测量开始前必须确保所有线程都创建完并进入等待状态否则第一个线程跑完时最后一个线程可能还没开始时间统计就不准。所以我用ready计数和start原子变量做了一个简易的“起跑栅栏”。2.3 测量时容易踩的坑第一必须预热。现代 CPU 有频率动态调整机制刚启动时频率可能还没拉满直接测量会得到偏大的结果。我在正式测试前会先跑一轮完整的操作再开始计时。第二不能只看平均值。我跑了三轮取的是中位数原因是有时操作系统会做后台调度、内存回收平均值容易被极端值拉偏。第三每个测试里线程的绑定关系也要考虑。默认情况下线程可能在不同核心间迁移缓存亲和性会受影响。我这边没有做 CPU 绑定因为实际业务里线程也未必固定核心这样测出的数据更贴近真实情况但要注意对短临界区场景来说CPU 迁移会让结果波动变大。低频竞争场景我额外在临界区外加了模拟计算量比如算几轮哈希让每轮迭代里锁操作的占比降下来。读多写少场景则构造了一个模拟缓存结构用一个简单数组存数据90% 的概率走读路径10% 的概率做写入更新。3. 实测数据与结果解读3.1 高频竞争mutex 输得最彻底8 线程、每线程 100 万次递增结果如下同步原语总耗时毫秒相对耗时std::atomic fetch_add8.61.0x自定义自旋锁14.21.65xstd::mutex38.94.5xstd::shared_mutex写锁47.35.5x这个结果跟原理完全吻合。std::atomic只执行一条原子加法指令线程之间靠缓存一致性协议协调虽然也有争抢但操作系统完全不参与所以最快。自旋锁内部也是原子操作但多了一层exchange和load的循环判断缓存行乒乓更严重所以比原子操作慢一些。std::mutex慢到让人怀疑人生8 个线程反复抢同一把锁每次抢不到就要睡过去再被唤醒上下文切换开销非常夸张。shared_mutex更慢因为写锁还要额外维护内部的读者计数状态。另一个有意思的点是在高频竞争场景下std::atomic的优势随着线程数增加会更加明显。当线程数从 8 增加到 32 时mutex 的耗时几乎翻了三倍多而 atomic 只从 8.6 毫秒涨到 36.8 毫秒扩张比小很多。锁的竞争越激烈mutex 需要处理的阻塞、唤醒操作就越多而原子操作每次都是“撞一下缓存行”而已。3.2 低频竞争锁开销被摊薄在临界区非常短的场景里锁是主要开销但一旦临界区外有实际工作量锁的相对成本就被摊薄了。我在低频竞争场景下每轮迭代额外做约 2 微秒的模拟计算结果如下同步原语总耗时毫秒相对耗时std::atomic14201.0x自定义自旋锁15301.08xstd::mutex16401.15xstd::shared_mutex写锁17601.24x数据非常接近。在这个场景里同步操作占每轮迭代的比例不到 10%无论用哪种原语性能差别都缩小到 20% 以内。这也印证了一个常见经验如果临界区本身已经占了大量时间单纯换一个更快的锁收益很小应该优先考虑减少临界区大小或减少锁的争用频率。不过要注意这个结论仅在竞争不激烈时成立。如果线程数很多mutex 的阻塞唤醒成本依然会累积即使临界区外有耗时操作多线程同时抢锁的概率也会变大还是要具体情况具体分析。3.3 读多写少读写锁的逆袭与陷阱模拟缓存场景一个共享数组95% 的操作是读取5% 是写入更新。8 线程、每线程 50 万次操作结果如下同步原语总耗时毫秒相对耗时std::mutex49201.0xstd::shared_mutex34100.69xstd::shared_mutex在这个场景下比普通互斥锁快了约 30%。原因很直观读锁是共享的允许所有读线程同时进入临界区只有写线程才互斥。读操作占多数时这种并发读带来的吞吐量提升非常明显。但要注意shared_mutex的写锁性能其实很差。同样在这个缓存场景里如果我把写入比例提高到 30%shared_mutex 反而比 mutex 慢了 15%。因为写锁要等当前所有读锁释放还可能要唤醒后面的写者维护状态的开销比普通锁高很多。读写锁只适合读多写极少的情况写多、读写均衡的场景下不要用。另一个隐藏陷阱是“写者饥饿”。如果读线程持续不断进来写线程可能长时间拿不到锁。标准库的实现里有不同的公平性策略但就算带排队机制写锁的延迟依旧比读锁高不少。真正常见的做法是给写操作单独做限流或批处理而不是频繁地抢写锁。3.4 并发扩展性从 4 线程到 32 线程我也跑了一组线程数扩展测试看不同原语在竞争压力增加时的表现同样是每线程 100 万次递增线程数std::atomicms自旋锁msstd::mutexms21.82.14.288.614.238.91617.933.575.23236.878.4142.3自旋锁在 32 线程时退化得非常厉害从 2 线程到 32 线程翻了约 37 倍是所有原语里扩展性最差的。高频竞争下自旋会让多个线程同时在不同的核心上反复无效探测同一个缓存行缓存一致性协议流量爆炸性能被严重拖累。std::atomic在 32 线程时也翻了几十倍但绝对数值依然最小因为至少不会产生操作系统的调度压力。这对选型有直接指导意义如果你的项目可能跑到 16 核以上并且确实存在高竞争的小临界区自旋锁不是好选择要么用std::atomic设计无锁路径要么用 mutex 结合更细的锁粒度来控制竞争。4. 同步原语选型决策指南4.1 一张表说清选型边界基于上面的实测数据和原理分析我整理了一张选型表基本覆盖了我平时遇到的大部分同步场景场景特征推荐方案原因简单计数器、标志位、统计数据std::atomic单变量操作开销最小临界区极短且线程数不多16自旋锁或 std::atomic避免上下文切换临界区较长或包含复杂逻辑std::mutex避免长时间自旋空耗 CPU读多写少且写比例低于 10%std::shared_mutex读锁共享提升吞吐写操作频繁或读写均衡std::mutex写锁开销比 mutex 更高无锁队列、共享缓冲区自定义无锁结构需要仔细验证内存序这个表格不是死规则但作为出发点很实用。如果拿不准最稳的路子是把两个候选实现都测一下数据会告诉你答案。毕竟我在前面也看到了不同场景下的差异能有数倍之大。4.2 优先压低锁粒度而不是换更快的锁我自己踩过的最大的坑就是“换锁”遇到锁竞争就想着换一种更轻量的原语结果经常是治标不治本。先做的应该是压低临界区的大小把不必要的资源释放操作移出锁外。临界区从 20 条语句降成 3 条语句比把 mutex 换成自旋锁的收益大得多。我之前的经验是一个受锁保护的容器里如果你在持锁期间做了动态内存分配、字符串拷贝、文件 IO 这些重操作锁竞争几乎无法靠换原语解决。正确做法是先把数据搬运到局部变量释放锁后再处理耗时操作。4.3 混合方案自旋片刻再睡眠在高频、中等规模线程数的场景下直接自旋会导致 CPU 空转直接mutex 又太慢。这时候可以做一个“先自旋后睡眠”的混合锁先自旋一个极短时间比如几十纳秒看硬件而定没抢到锁再走 mutex 的阻塞路径。class HybridLock { std::atomicbool spinning_{false}; std::mutex mtx_; public: void lock() { int spins 0; while (spinning_.exchange(true)) { if (spins 32) { mtx_.lock(); // 自旋失败转向阻塞 spinning_.store(false); return; } std::this_thread::yield(); } } void unlock() { spinning_.store(false); } };这种混合锁的原理是在临界区很短、竞争刚开始时用自旋避免上下文切换一旦发现长时间拿不到锁就退回 mutex避免自旋空耗。我在一个消息分发模块里用过类似方案压测比单纯用 mutex 提升了约 20%比单纯自旋更稳定。要注意的是这个锁的实现还有改进空间比如阻塞路径上的唤醒处理、内存序的选择等实际项目里需要仔细验证。4.4 别忽视原子操作无法替代锁的地方std::atomic虽然快但它只能保护单个变量。很多业务逻辑需要多个相关变量保持一致性比如“先判断再写入”的两个变量这种复合操作如果没有锁保护单纯用原子操作是做不到原子性的。即使每个变量单独是原子的组合起来依然会出问题。此时需要std::atomic配合内存序 CAS 循环的设计或者回到 mutex。我的建议是优先用 mutex 保证正确性只有真正到了性能瓶颈并且明确知道瓶颈就是锁竞争时才考虑用 atomic 设计无锁路径。无锁代码的调试难度和心智负担都不低不值得为了微小的性能提升在错误的地方冒险。5. 常见问题与排查技巧实录5.1 自旋锁在单核或少核环境下的反噬自旋锁最大的前提条件是多核。单核环境里持锁线程和等待线程是同一个核心在轮转如果等待线程在自旋持锁线程根本得不到执行机会锁就会永久解不开相当于死锁。即使在多核但核数很少的环境里一个线程自旋空耗 CPU 也会拖慢持有锁的线程导致整体性能下降。排查这类问题的方法很直接先看机器核数再决定要不要用自旋锁如果项目要跨不同硬件部署默认用 mutex 是更稳妥的选择。我在自旋锁实现里加了yield就是因为如果不让出 CPU单核场景直接死循环就卡死了。5.2 atomic 的 memory_order 用错导致“不可见”std::atomic用起来简单但内存序错了会很隐蔽。我用relaxed写一个“发布-订阅”模式时偶尔发现读线程拿不到最新值。原因就是relaxed只保证单个原子操作自身不被打断不提供任何顺序保证其他线程可能在很长一段时间内看不到本次写入的效果。正确做法是写入用release读取用acquire保证“写入之前的普通写操作对读取之后的普通读操作可见”。如果只是计数器这类只需要最终一致性的场景relaxed就够了需要跨线程传递状态时务必使用更严格的内存序。5.3 shared_mutex 写锁饥饿我在缓存场景里遇到过写线程长时间拿不到锁的情况。当时有几路线程持续高频读取缓存写线程在中间尝试更新数据结果负载一高写操作延迟飙升到几百毫秒。原因就是读锁可以共享读线程源源不断进入写锁一直排队。一种缓解方法是限制读锁持有时间或者降低读频率另一种是用“写优先”的读写锁实现。标准库的std::shared_mutex在不同平台上的公平性策略不同但我不建议把自己绑到一个具体实现上。最可靠的做法是在业务层控制读写比例写操作积累到一定量后统一做一次批量更新避免写锁被频繁争抢。5.4 性能瓶颈定位三件套遇到同步性能问题时我的排查顺序很固定。第一步是确认瓶颈真的在锁上用性能采样工具看热点函数如果锁相关函数的采样占比很高再继续第二步是评估锁的竞高程度——加一个计数器记录每次抢锁的等待时间看平均等待和最大等待第三步是分析临界区长度分布找到把临界区拖长的重操作。我常用一个很土但有效的办法在锁的入口和出口处各放一个高精度时间戳累计计算“临界区真实执行时间”和“等待时间”这样可以直接量化锁竞争带来的损耗。如果发现等待时间远大于执行时间说明临界区本身没问题是争抢太激烈如果执行时间本身就很高优先优化临界区内部逻辑。经验建议不要在瓶颈未确认之前就盲目换同步原语。先用数据定位再针对性地做优化这样每次调整都能看到量化的收益变化。5.5 代码评审时的同步原语检查清单最后分享一个我在代码评审时常用的检查清单覆盖了多数同步原语使用问题锁的持有范围是否最小化持锁期间有没有 IO、内存分配、耗时计算多个锁是否出现了嵌套嵌套锁定可能引发死锁能用一把锁就用一把。如果用std::atomic每个读操作是否使用了匹配的内存序跨线程传递数据时是不是误用了relaxed自旋锁所在的代码路径是否有提前返回或异常抛出且未释放锁的情况建议用 RAII 封装。shared_mutex写操作是否会饿死写频率是否真的远低于读频率共享变量是否可能触发伪共享热点变量是否和对齐考虑过这些问题如果在评审阶段就能排查掉可以省掉很多线上事故的排查时间。我自己就在一个项目里因为没检查伪共享吃了性能大亏后来把这个问题写进了评审清单后面再没遇到过同类问题。这次对比测试做下来我最大的体会是同步原语没有绝对的好坏只有适不适合场景。std::atomic在计数器上快得惊人但在多变量一致性上无能为力mutex 慢却稳能处理任意复杂逻辑自旋锁在低竞争、短临界区时是神器线程一多就翻车读写锁看起来很美但写锁饥饿会让人头疼。选型之前一定要先问清楚自己的场景是什么用数据验证假设而不是靠直觉搬一个看似更先进的方案。最后再分享一个小技巧我把这套基准测试框架保留在了项目里每次代码改动涉及并发逻辑时都会跑一遍。这个成本很低但数据可以帮你快速发现性能回退比事后排查高效得多。
📝

华诺云谱内容团队

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

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

你可能需要的服务

订阅华诺云谱资讯周报

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

↑