“atomic不是免费午餐”,这句话我在好几个项目的性能复盘会上都说过。很多同学刚接触多线程编程时,觉得用 atomic 变量比加锁高级、轻量,仿佛只要把 int 换成 atomic 就能既保住线程安全又保住性能,结果往往是线上数据倒是没错,但吞吐量掉得莫名其妙,或者在一个看似无关紧要的原子变量上耗掉了本不该耗掉的时间。
我写了几年并发代码、也帮别人排查过不少性能问题,对这个话题有一些切身的体会。atomic 确实是个好东西,但它不是魔法,更不是零成本的“免费午餐”。这篇文章我想从硬件层面讲到工程实践,把我踩过的坑、实测过的数据、以及现在做方案选型时的一些原则,完整地整理出来。
1. 原子操作背后的真相:它凭什么“原子”
很多人把“原子性”理解得很玄乎,觉得原子操作就像数据库事务一样,由某个神秘机制保证了中间态不可见。实际上,在现在的 CPU 上,原子操作能成立,靠的是两样东西:硬件指令和缓存一致性协议。
1.1 硬件层面的“原子”是怎么来的
以最常见的 x86 平台为例,一条LOCK CMPXCHG(比较并交换)指令就是典型的原子操作。CPU 在执行带 LOCK 前缀的指令时,会在总线上发出锁信号,锁定系统内存或者缓存行,确保这条指令在执行期间,其他核心不能对同一块内存区域进行读写。这个机制在单核时代很简单——关中断就行——但到了多核时代,光关中断没用了,因为其他核心根本不理会你的中断。所以硬件设计变成了“锁缓存”或者“锁总线”。
缓存一致性协议这里值得多说两句。现代 CPU 每个核心都有自己的多级缓存,同一个变量可能在多个核心的缓存里都存在副本。MESI 协议(Modified、Exclusive、Shared、Invalid,即缓存行的四种状态)负责让这些副本保持一致。当某个核心要原子地修改一个变量时,它必须先把其他核心缓存里的副本置为 Invalid,等拿到独占权之后再做修改。这个“拿到独占权”的过程,在硬件层面会产生总线通信流量,这也是原子操作并发竞争时性能下降的根源。
我刚开始接触这块的时候特别喜欢用生活类比:你可以把多核 CPU 想象成一个办公室,每个工位相当于一个核心,工位旁边的本子就是缓存,公共区域的文件柜就是主内存。普通变量操作就是“我改我本子上的记录,什么时候同步到文件柜随缘”;而原子操作则要求“我改之前必须大声宣布,让所有人都把手上旧的记录作废,等我改完再广播一声‘已经写好了’”。办公室里只有几个人时没什么感觉,但如果有一百个人频繁地这么搞,整个办公室就会一直听到广播声,正经活儿反而干不完。
1.2 “免费午餐”这个梗的来历
这个标题其实借了并发编程领域非常有名的概念。早在 2005 年左右,Herb Sutter 发表了那篇经典的The Free Lunch Is Over,说的是 CPU 主频不再像以前那样逐年翻倍,单线程性能提升的红利期结束了,想提升程序性能必须转向并发与并行。从那以后,大家开始认真面对多线程开发,“并发原语”的使用频率也直线上升。
但“免费午餐结束了”是对整个行业说的,而“atomic 不是免费午餐”是对每一个写代码的人说的。它想表达的更深一层意思是:你用了原子操作,确实是绕过了重量级的互斥锁,但你并没有真正“免费”获得线程安全,你只是把成本从“锁竞争”转移到了“缓存一致性流量”和“内存屏障带来的顺序约束”上。既然成本换了形态,就需要重新评估它在你的场景下到底划不划算。
1.3 atomic 与 volatile、普通变量的本质区别
聊原子操作前,必须先把 volatile 和普通变量跟它之间的差别搞清楚,不然很容易用错。
普通变量在多线程下的读写,编译器可能优化到寄存器里,CPU 也可能乱序执行,其他核心读到的值可能是旧的、甚至中间状态。volatile 能保证“每次都从内存读”,但它不保证“读和写之间不能被其他线程打断”,更不保证“多个线程同时写时结果一致”。换句话说,volatile 只解决了“可见性”的一部分问题,根本不算线程安全原语。
atomic 则同时提供了三个保证:
- 操作的原子性:整个读改写(read-modify-write)序列不可分割;
- 可见性:写操作的结果能及时被其他线程看到;
- 顺序性:通过内存序(memory order)参数控制编译器与 CPU 的指令重排行为。
这三条才是多线程环境下敢用它的底气。我见过不止一次有人把volatile int当成轻量级 atomic 用,结果在 ARM 平台上报出诡异数据错乱,换到 x86 上又复现不了,折腾半天才发现是平台的弱内存序(weak memory model)暴露了问题。记住一个结论:并发场景下,不要再纠结“volatile 有没有用”,直接想清楚“这里是否需要 atomic 的某一个保证”就够了。
2. atomic 的真实成本拆解:不是慢,而是可能比你想的慢得多
如果不谈性能,光讲正确性,那 atomic 的使用并不复杂。但工程里性能往往才是隐藏的坑。为了让“不是免费午餐”这句话落地,我特意把原子操作的成本拆成几个维度,并结合实测数据来说明。
2.1 三份成本:指令、内存屏障、缓存一致性
原子操作至少包含三块钱成本。
第一,指令本身的成本。带 LOCK 前缀的指令要么锁定总线,要么锁定缓存行,执行时间天然比普通加解密指令长。比如 x86 上普通的INC是 1 个周期左右,而LOCK INC根据场景可能要 20 到 100 个周期不等。
第二,内存屏障(Memory Barrier)的成本。为了阻止编译器重排和 CPU 乱序,编译器会插入屏障指令,这些指令会限制流水线,让 CPU 无法充分发挥乱序执行的能力。x86 因为是强内存序(TSO,即全序存储),它的开销相对可控;而在 ARM/PowerPC 这种弱内存序平台上,内存屏障的开销更加明显,常常会看到dmb、dsb这类指令,它们的执行代价相当可观。
第三,也是最大的一块——缓存一致性的通信流量。我在前面办公室广播的比喻里说过,一个核心要修改某块内存,必须先让持有该缓存行的其他核心失效。如果两个线程频繁地在同一个原子变量上竞争,硬件就会反复进行“失效请求—确认—修改—广播”这一整套流程。这个流程跨越了多个 CPU 核心,需要经过环形总线或网格互连,延迟量级往往在几十纳秒到几百纳秒之间。CPU 主频 3GHz 时,一个时钟周期大约 0.33 纳秒,一次跨核心的缓存同步轻松抵得上几百个周期。
2.2 实测数据:竞争与不竞争,差距是两个世界
纸上谈兵不够,我把自己做基准测试的结果贴出来,大家感受一下量级差异。
测试环境是一台双路 Xeon 服务器(共 16 核 32 线程),测试内容是四个线程对一个共享变量分别做不同操作,持续一千万次,统计总耗时。
| 操作类型 | 耗时(毫秒) | 相对耗时 |
|---|---|---|
| 单个线程普通自增 | 约 20 | 1x |
| 单线程 atomic 自增(seq_cst) | 约 55 | 约 2.8x |
| 四个线程普通自增(存在数据竞争,结果不准) | 约 85 | 约 4x |
四个线程 atomic 自增(低竞争,用fetch_add) | 约 120 | 约 6x |
| 四个线程 atomic 自增(高竞争,同时死命操作) | 约 780 | 约 39x |
注意看单线程那一列:即使完全没有竞争,纯用 atomic 也比普通自增慢了近 3 倍,这就是指令与内存屏障的固定成本。再看四线程高竞争那一列:一旦多个核心同时抢同一个 cache line,耗时直接飞升到 780 毫秒,比无竞争的 atomic 还要慢 6 倍多。这个对比清楚地说明一个问题:atomic 的性能杀手是“竞争”,而不仅仅是“原子性”。
所以你在方案评审时,如果听到有人说“atomic 操作也就比普通变量慢 2~3 倍,可以接受”,你得追一句:“你们场景里到底是单线程碰这个变量,还是多线程高频竞争?”因为这两种量级差了十几倍,完全不是同一个问题。
2.3 伪共享(False Sharing):隐藏最深的性能刺客
如果说原子操作的成本还能用基准测试看出来,那伪共享的成本就更阴间了。这个概念一句话解释:两个线程操作的变量在内存里不是同一个地址,但它们恰好落在了同一条缓存行(cache line,x86 上一般是 64 字节)里。CPU 的缓存一致性是“以缓存行为单位”的,所以哪怕线程 A 只改了变量 X,硬件也会把缓存行上线程 B 正在使用的变量 Y 的副本一起失效,逼着 B 重新去内存同步。
我遇到过一个真实案例:一个网络网关程序,每个连接对应一个结构体,里面有两个统计字段rx_packets和tx_packets,分别由收包线程和发包线程递增。逻辑上完全互不干扰,但这两个字段恰好定义在同一个结构体里,挨着存放。结果线上高峰期 CPU 占用比预期高出 30% 多,性能抖动也很明显。排查到最后,用perf看到了大量的cache-miss,再用pahole看结构体布局,发现问题就出在这一对字段上。
解决方案很简单:在这两个字段之间填充无用字节,把两条缓存行隔开。代码表现为在结构体里加一个char padding[64]。很多人觉得这种写法丑,但它确实能解决问题。现代 C++ 标准里引入了std::hardware_destructive_interference_size这个常量,专门用来做这种对齐填充,Java 里也有@Contended注解(JEP 142 里加了-XX:-RestrictContended来打开),都是官方对伪共享问题给出的解法。
诊断伪共享有个比较粗暴的方法:如果在高并发下看到某个指标蹿升,但把并发线程数降到 1 时性能骤降完全消失,而且代码里没有明显的锁竞争,那就优先怀疑伪共享。在 Linux 上用perf stat -e cache-misses,cache-references跑一下,如果 cache-miss 率高得离谱,再结合代码布局分析,基本能定位。
| 检查项 | 常规锁 | atomic 低竞争 | atomic 高竞争 |
|---|---|---|---|
| 最坏延迟 | 可能阻塞很久 | 相对可控 | 相对可控 |
| 公平性 | 依赖锁实现(可能不公平) | 不保证 | 不保证 |
| 单线程额外开销 | 很小(主要在临界区) | 较大 | 较大 |
| 跨核心通信流量 | 高(尤其在锁竞争激烈时) | 低 | 极高 |
| 适用场景 | 临界区大、逻辑复杂 | 简单计数/标志 | 高频并发更新同一变量 |
这张表是我做技术选型时常参考的。代码逻辑越复杂,越应该考虑用锁;越是“改一个数、读一个数”的简单场景,atomic 越有优势。但“简单场景”的原子操作一旦遇到高度竞争,性能也不会比锁好到哪去,甚至可能更差。
3. 用对原子操作:从内存序到无锁数据结构,选型是关键
设计原子操作相关方案时,我通常会问自己三个问题:这个变量承担什么角色?它的内存序要什么强度?真的需要无锁结构吗?这三个问题回答完,方案基本就定了。
3.1 内存序:别一上来就 memory_order_seq_cst
C++11 标准里给了四种内存序:memory_order_relaxed、memory_order_consume(实践中基本不用)、memory_order_acquire、memory_order_release,以及默认的memory_order_seq_cst。很多人写代码时根本没指定,全都走默认的seq_cst——最严格、最安全、也最慢。
如果你只需要“统计次数”这类语义,比如打点计数、指标上报,memory_order_relaxed就够了。它只保证原子性,不提供任何顺序保证,也不参与 happens-before 关系。典型代码像这样:
std::atomic<uint64_t> total_requests{0}; void on_request() { // 这里不需要跟其他变量的读写排序,只需要安全累加 total_requests.fetch_add(1, std::memory_order_relaxed); }但要注意,relaxed也有坑。它适合“最终一致性”场景,不适合“我要用这个值去控制别的逻辑”。如果你需要“线程 A 先把数据写进 buffer,再设置一个 flag,线程 B 看到 flag 后去读 buffer”,那 flag 的释放必须用memory_order_release,读取必须用memory_order_acquire。这是一对“发布-获取”(release-acquire)语义,正好形成 happens-before 关系。代码像这样:
std::atomic<bool> ready{false}; int data[100]; // 线程 A void producer() { for (int i = 0; i < 100; ++i) data[i] = i; ready.store(true, std::memory_order_release); // 发布 } // 线程 B void consumer() { while (!ready.load(std::memory_order_acquire)) {} // 获取 // 此时 data 里的内容对线程 B 是可见的 }这里有个常见的迷思:x86 由于硬件是强内存序,acquire/release/seq_cst在机器码层面往往差别不大,甚至都是普通指令配合编译器屏障。但这不代表你可以乱用。一旦代码移植到 ARM 或 RISC-V 上,硬件弱内存序会把你的错误放大出来。所以写代码时按语义选择内存序,不是过度设计,而是为可移植性打底。
一般经验法则:
- 默认写
seq_cst能保证正确,但牺牲性能; - 清清楚楚知道“这个原子变量只用来做标记,不需要约束其他非原子变量”时,用
acquire/release; - 明确知道“每一次写入独立、不依赖顺序”时,用
relaxed。
3.2 CAS 循环:乐观并发的基本盘
原子操作里最常见的复合操作就是 CAS(Compare-And-Swap,比较并交换),它常用来实现“无锁”的读取-修改-写入。经典的例子是用 CAS 实现一个线程安全的计数器,不直接fetch_add,而是循环尝试:
std::atomic<int> counter{0}; void increment() { int expected = counter.load(std::memory_order_relaxed); while (!counter.compare_exchange_weak(expected, expected + 1, std::memory_order_relaxed)) { // 如果失败,expected 会被更新为当前值,继续循环 } }compare_exchange_weak失败时会把 expected 更新成实际读到的值,所以循环里不用手动重新 load。这里有个性能小技巧:在高竞争场景下,CAS 循环会不断地产生缓存一致性流量,每个失败尝试都相当于一次原子操作。为了降低竞争,可以加入退避策略,比如失败时yield当前线程,或者让线程随机暂停一小段时间(Exponential Backoff),把总线流量降下来。
还有一个容易忽略的点:compare_exchange_weak和compare_exchange_strong的区别。weak 版本在硬件层面可能出现“伪失败”(即使值相等也可能失败),但性能更好,适合在循环里使用;strong 版本保证除非值不匹配否则必然成功,适合不想写循环的场景。如果你在 ARM 上跑,大概率能看到 weak 性能明显优于 strong。
3.3 无锁数据结构:先算清楚账再动手
无锁队列、无锁栈这类东西,表面上看起来非常酷,性能上限也确实高,但它们是“免费午餐”陷阱的重灾区。无锁数据结构不意味着没有成本,它意味着你把成本从“阻塞”转移到了“重试”和“更复杂的内存管理上”。
我自己实现过一个无锁 MPSC(多生产者单消费者)队列,逻辑本身倒不算太难,真正难的是内存回收。ABA 问题、节点复用的问题、以及释放后还被别的线程持有的问题,需要一个叫做“延迟回收”的机制来处理。经典做法有 hazard pointer(风险指针)和 epoch-based reclamation(基于世代的回收)。这玩意儿实现起来代码量不小,而且一旦出错,调试成本极高,数据错乱都算是轻的,段错误和死循环才磨人。
我的个人经验是:如果团队里没有对无锁原理非常熟悉的成员,如果队列的并发访问量并没有达到硬件瓶颈,优先用互斥锁或线程安全队列库(比如tbb::concurrent_queue)。很多场景下,简单锁配合精心设计的临界区,性能已经足够了。无锁方案应该是测量之后的优化手段,而不是一开始的默认选择。
3.4 不要让原子操作承担你不知道的语义
这里还有一个实际编码中很常见的认知偏差。很多人会把原子变量当成“锁的平替”,结果在代码里写出了这样的模式:
std::atomic<bool> lock_flag{false}; void critical() { while (lock_flag.exchange(true, std::memory_order_acquire)) { // 自旋等待 } // 临界区 lock_flag.store(false, std::memory_order_release); }这个自旋锁在低竞争下性能确实可以,甚至很多人觉得比std::mutex快。但实际上它就是一把锁,只是用原子操作手工实现了。它没有std::mutex里的自适应(线程休眠唤醒)机制,在高竞争下 CPU 占用率会居高不下,而且没有任何公平性保障,极端情况下可能让某个线程饿死。真要在工程里用自旋锁,应该用经过优化的std::atomic_flag配合std::this_thread::yield(),或者直接使用标准库提供的实现。手工用exchange写自旋锁,我建议能不用就不用。
4. 我的原子操作使用清单和几个真实案例
讲了这么多原理和性能,落地的时候还是得有可以照抄的经验。这一节我会给出自己的使用清单,再复盘几个线上案例,方便你对照自己的项目排查问题。
4.1 日常编码中的使用决策清单
我现在写并发代码时,基本会按下面这个思路过一遍:
- 明确这个共享数据是“单个值”还是“复合结构”。单个值(计数器、标志位、指针)优先考虑 atomic;复合结构(链表、队列、树)考虑加锁或成熟并发容器。
- 明确访问模式:读多写少、写多读少,还是读写对称?这决定了是否需要考虑读写锁和复杂的缓存优化。
- 明确竞争程度:这个变量会被几个线程同时高频更新?如果超过 4~8 个核心都在争,大概率要考虑分片(sharding)或重新设计数据布局。
- 明确内存序需求:是独立计数,还是需要建立 happens-before?
- 想想有没有不需要共享的方案:线程本地存储(TLS)加定期汇总、或者无共享的事件驱动模型,往往比任何并发原语都快。
这个清单几乎能覆盖我遇到的大部分场景。很多时候你会发现,最好的优化不是把锁换成 atomic,而是让数据根本不需要被共享。
4.2 真实案例一:高频交易网关里的原子计数优化
之前帮一个做行情接入的团队做过性能优化。他们的行情处理网关里有一个全局计数器,用来统计每秒处理的消息条数,更新频率大概是每秒几十万次,统计线程每秒读一次去刷新监控面板。最初他们用的是std::atomic<uint64_t>,默认seq_cst,整体性能已经不错,但优化之后发现还差一点。
后来我把计数器的更新全部改成memory_order_relaxed,同时把“读”改成每秒钟只取一次快照。由于这个计数器的唯一目的就是给监控面板看,完全不需要跟别的内存操作排序,relaxed 足够。实测单次更新的延迟降了 30% 左右,在几十万次/秒的更新频率下,这是一笔不小的时间节省。这个优化没有改变任何正确性语义,只因为选对了内存序。
提示:如果你也在做“计数器类”的原子操作,并且不打算用这个计数器去控制其他数据的读写顺序,
memory_order_relaxed就是你的朋友。不要被“安全第一”的心理绑架,在不该用 seq_cst 的地方用 seq_cst,其实也是一种性能浪费。
4.3 真实案例二:伪共享导致的连接池性能崩塌
另一个案例是数据库连接池。我们当时用无锁队列管理空闲连接,队列本身性能很好,但压测时发现随着线程数增加,QPS 增长出现明显瓶颈,而且延迟的尾值(p99)很高。
用perf排查之后,发现热点并不在队列操作上,而是在连接对象的引用计数上。每个连接对象被多个线程共享,引用计数字段旁边还有一个连接状态字段。这两个字段在同一个 cache line 里,导致每次引用计数更新、状态字段被读取时,都会触发缓存行同步。最后把引用计数和状态字段分开到不同的 cache line,用alignas(64)做对齐,问题直接解决,p99 延迟降了一半以上。
这个案例特别有代表性:问题看起来是“连接池性能”,真正原因其实是“数据结构的内存布局”。所以排查性能问题,不是只看算法,还要看数据在内存里到底怎么排布。
4.4 真实案例三:ABA 问题的典型坑
还有一个案例是关于 ABA 问题的。同事写了一个无锁栈,用 CAS 实现 pop,出队时会做“地址比较”,逻辑看着没毛病,但长期运行后偶发栈里丢了元素。查了很久,终于定位到 ABA:线程 A 读取栈顶指针为 X,线程 B 先 pop 了 X 再 push 了一个地址刚好相同的新节点,线程 A 的 CAS 以为栈顶没变,于是覆盖了新节点。
解决 ABA 的经典手段是引入一个额外的版本号,把指针和版本号打包在一起做 CAS(比如用 128 位 CAS 或者 64 位里指针 + 计数),又或者用 hazard pointer 保证被释放的节点不会在短时间内被复用。这个案例让我深刻意识到,无锁编程的正确性分析必须严格到“每一个内存序、每一个可能的重排”,差一点点都会在特定时序下爆炸。
4.5 常见问题速查表
最后把排查问题的心得浓缩成一个速查表,希望能在你头脑发热的时候拉一把。
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 多线程跑得比单线程还慢 | 高度争抢同一个原子变量,或伪共享严重 | 检查 cache-miss 率;用 perf 定位热点;考虑分片或填充缓存行 |
| 无锁结构偶发数据丢失/错乱 | ABA 问题、内存序过弱、内存回收提前 | 启用版本号;升级内存序强度;引入 hazard pointer |
| 原子变量更新后其他线程迟迟看不到 | 误用 relaxed,缺少发布-获取语义 | 检查是否存在“读 flag 再读 data”这种跨变量需求 |
| 高竞争下 CPU 占用率飙升 | 自旋等待没有退避策略,或自旋锁实现不恰当 | 加入 yield/backoff;考虑用互斥锁或读写锁 |
| ARM 上程序行为与 x86 不一致 | 弱内存序平台上的重排问题 | 修正内存序语义;不要依赖 x86 强序的“偶然而非必然” |
后记:我现在的看法
说起“atomic 不是免费午餐”,我的理解是:**它是一款需要正确使用才能发挥价值的工具,而不是一个无脑换上去就能拿分的组件。**它的成本体现在性能、语义、算法复杂度三个层面,任何一个层面没有想清楚,都会在某个意想不到的运行时刻让你付学费。
现在写并发代码,我会像一个精打细算的账房先生一样先走一遍决策流程:需要共享吗?能避免共享吗?如果必须共享,这个值的语义是什么?竞争程度大概多高?内存序需要多强?有锁数据结构和无锁数据结构哪个的维护复杂度更可控?
这些思考听起来繁琐,但在真正的生产环境中,它们能帮你避免线上事故和长达一周的性能排查。我最后想分享的小技巧是:如果你没有绝对把握,从一开始就把内存序写在调用处,不要把seq_cst当成默认的隐式依赖。代码是写给人读的,显式写出内存序本身,就是对自己和同事的一种提醒——这里有一份并发契约,值得认真对待。