前阵子组里的小伙子拿着一份压测报告找我,说他写的一个并发计数模块,8个线程跑下来吞吐不但没上去,反而比单线程还低。我扫了一眼代码,满屏幕都是 std::atomic,UAF、ABA、内存序这些词在脑子里转了一圈,心里大概有数了。atomic 在并发编程里已经快被当成“免锁万能药”了,好像只要把变量包上一层,线程安全就自动到手。但现实远没有这么美好——atomic 只是在帮你把问题从“锁竞争”挪到了“缓存行竞争、内存序语义、CAS 重试”这些更隐蔽的坑里。它从来不是免费的午餐,而且很多时候,这顿午餐的账单比你以为的贵得多。
这篇文章我不打算把 C++ 或者 Java 的 atomic 类文档再念一遍,而是想结合我实际写并发代码踩过的坑,聊聊 atomic 真正帮你买了什么、背后哪些成本是隐性的、什么场景下用它才划算。写过多线程、被锁和原子变量折磨过的朋友应该能产生共鸣,刚接触并发的读者也能从这篇文章里摸到一些门道,少走点弯路。
1. atomic到底解决了什么问题
1.1 从令人头秃的并发计数开始
先说一个最经典的场景:多个线程需要对同一个计数器执行 value++。这个操作在 C 或者 C++ 里就是一行代码,但在汇编层面,它被拆成了三条指令:把变量从内存加载到寄存器、寄存器加一、再把结果写回内存。问题就出在这里。线程 A 和线程 B 可能同时完成了“加载”这一步,都看到 value 是 5,然后各自加一后写回,结果 final value 是 6 而不是 7,你丢了两次更新。
传统解决方案是加锁。mutex 可以保证这三条指令作为一个完整临界区执行,代价是每次操作都需要加锁、解锁,涉及系统调用和线程调度,哪怕没有竞争也要几十纳秒的开销。atomic 提供了另一条路:直接使用 CPU 的原子指令,比如 x86 上的 LOCK XADD,硬件保证“读-改-写”这整个操作对其他核心来说要么完全发生了,要么完全没发生,中间状态不可见。这就让你不用写锁也能安全自增。
但请注意,atomic 解决的是“单个变量的并发修改”问题。它并没有神奇到能把所有线程安全问题都取消掉。很多人把 atomic 当作普通的变量类型,以为加上它就高枕无忧,这正是后面所有麻烦的起点。
1.2 原子性的本质与内存可见性的边界
原子性这个词在日常生活中也好理解,就像银行转账:扣款和入账必须同时完成,不能出现扣了钱但对方没收到钱的中间状态。CPU 层面的原子性,靠的是锁定总线、锁定缓存行,或者依靠缓存一致性协议(比如 MESI)来实现独占访问。简单说,某个核心在执行原子操作时,会通知其他核心“这条数据我正在改,你们先别动”,操作完成后再释放。
不过,原子性不等于“立即可见”。即使操作是原子的,不同的 CPU 核还有自己的缓存、写缓冲区,指令也可能是乱序执行的。一个线程执行了 atomic 的 store,另一个线程紧接着去 load,理论上不一定能马上看到这个新值,除非你指定了合适的内存序(memory order)。默认的 memory_order_seq_cst 提供全局一致性顺序,但代价更大;如果你手动放松到 acquire/release 甚至 relaxed,就需要自己保证可见性和顺序关系了。
我见过太多的并发 bug,都源于把 atomic 当作“内存屏障替身”,觉得我用 atomic 变量存了一个标志位,另一个线程读取这个标志位就能安全地访问共享数据。但实际上,如果其他共享数据的读写不是普通的、缺乏屏障保护的读写,标志位本身是原子的也没用,数据竞争依然在那里。
1.3 它承诺了你什么,又不承诺什么
atomic 的承诺,单独看其实非常小:
- 单个变量的原子读、原子写、原子读改写(比如 fetch_add、compare_exchange)是被保证的;
- 默认内存序下,所有线程对同一个 atomic 变量的操作存在一个全序;
- 可以防止对这一个变量本身发生数据竞争。
但它不承诺的东西就多了:
- 多个 atomic 变量之间的组合操作不是原子的。你不能说“我先检查 A,再修改 B,这两步加在一起不会有人打断”——它不会保护你;
- 不提供临界区和阻塞能力。锁可以让等待线程睡眠,atomic 忙等就是 CPU 空转;
- 不能自动解决 ABA 问题;
- 也不能消除你对复杂并发数据结构的思考义务。
我见过有人试图用四个 atomic 变量实现一个“无锁双缓冲”,最后在线上出现了离奇的数据错乱。原因很简单:他想当然地认为 atomic 变量之间可以像事务一样协调,却忘了原子性作用域只是单个变量。
2. 账单明细:atomic背后的真实开销
2.1 硬件那一层贵在哪里
如果你只写过用户态代码,可能会觉得 atomic 和普通变量差不多,就是多几个指令前缀。但实际上,每一次原子操作背后都在和整机所有核心打交道。
以 x86 为例,普通 load 可能就是一个 MOV 指令,访存延迟大约几个纳秒。原子操作往往需要加 LOCK 前缀,这会让 CPU 在操作期间锁住一段缓存行甚至整个内存总线,何况还得处理缓存一致性的消息协议。当多个核心同时修改同一个缓存行时,为了保持一致性,缓存行会在多个核之间“乒乓”传递,效率极低。
我做过一个简单测试:8 个线程同时对同一个 atomic<uint64_t> 执行 100 万次 fetch_add。不受竞争影响的理想吞吐应该是接近 8 倍单核,但实际结果连单核的一半都不到。原因就是所有线程在同时抢同一个缓存行,每次 fetch_add 都要等待前面那个核把缓存行所有权交出来。这其实和一把大锁没什么本质区别,只是锁粒度变成了“缓存行”。
2.2 内存序的隐形税
std::atomic 默认的 memory_order_seq_cst 会要求所有原子操作在所有线程眼中呈现同一个全局顺序,这意味着编译器不能跨越原子操作做很多重排,CPU 也要用较为保守的内存屏障来满足这种语义。这个“顺序一致性”是有实打实成本的。
release/acquire 语义只约束单向顺序,relaxed 则几乎不提供任何顺序保证。理论上,越放松的顺序,越容易获得性能提升。但是,代价是正确性的负担全部转移到了程序员身上。我见过有人为了优化,把默认顺序改成 memory_order_relaxed,代码在 x86 上跑得飞快且结果正常,一换到 ARM 架构就开始随机抽风。原因就是 x86 的强内存序掩盖了问题,而 ARM 的弱内存序立刻暴露了缺失的同步。
这里有个很反直觉的事实:在 x86 上,std::atomic 用 acquire/release 和 seq_cst 的性能差距往往很小,但代码的复杂度和出错概率却差距很大。除非你确实测量出巨大差异,否则我不建议花大力气去“手工打磨”内存序。省下的时间,远没有找 bug 花掉的时间值钱。
2.3 编译器优化被绑手绑脚
atomic 变量还有一个普通人不容易察觉的负面影响:它严重限制了编译器的优化能力。
普通变量如果在一个循环里只读,编译器完全可能直接把它加载到寄存器里,循环体里根本不产生内存访问。但 atomic 变量不行,因为编译器必须保证每次 load 都能看到其他线程可能写入的新值,所以循环里每次都要真实地访问内存。这在高频读取场景下,性能差异非常明显。
类似的,普通变量的一段连续自增可能被编译器优化成乘法或者合并成一个加法指令,原子变量则基本不会做这种合并,因为合并后就不满足“逐步可见”的原子语义了。你为了线程安全付出的代价,不只是那条 LOCK 指令,还有整个周边代码失去的优化机会。所以,在一个很热的循环里用 atomic 变量做无意义的读取,性能损耗可能远超你的直觉。
2.4 忙等和自我竞争并不便宜
atomic 常用的 compare_exchange(CAS)往往是写成循环重试,比如:
int expected = value.load(); while (!value.compare_exchange_weak(expected, expected + 1)) {}这种代码在低竞争下很高效:一次 CAS 就成功。但一旦竞争上升,失败的线程会立即重试,形成忙等。CPU 只能一直空转,功耗高、发热大,缓存系统还要不停处理无效化消息,把整体吞吐拉垮。
如果你是一个网络服务里被调用很频繁的“计数接口”,碰到高并发时,这种忙等会把 CPU 烧到 80% 以上,而如果用 mutex 加锁让等待线程睡眠,同一压测环境下 CPU 占用可能只有 20%。atomic 不是比锁更优,它只是在“低竞争”的场景下更优。竞争上去了,它照样会排队,而且队的队形比锁还要难看。
3. 最容易被坑的三个场景
3.1 组合操作根本不原子
Atomic 变量本身的自增、读改写都是原子的,但这绝不意味着“先检查再修改”这种组合动作是原子的。看一个非常典型的限流误用:
// 错误示例:先检查再自增,整体不是原子的 std::atomic<int> used{0}; if (used.load() < max) { used.fetch_add(1); // do something }两个线程可能同时 load 到 used == max - 1,都判断成立,然后各自 fetch_add,结果 used 变成 max + 1,超限了。正确的写法是用 CAS 把“检查+修改”放在同一个原子序列里:
int expected = used.load(); while (expected < max) { if (used.compare_exchange_weak(expected, expected + 1)) { break; } }这里 compare_exchange_weak 会反复尝试:只有当前值仍然等于 expected 时才会把它改成 expected + 1,否则会更新 expected 为当前值,然后循环重新判断。这样才真正保证了“不超过 max”这个条件的原子性。
CAS 循环本身也不是没有代价,它一定会引入你刚才看到的忙等和重试。此外还要警惕 ABA 问题:如果某个线程把值从 A 改成 B 后又改回 A,CAS 无法区分“没变过”和“变回原样”。这在无锁链表、无锁栈的实现里是经典大坑。说实话,普通业务场景根本没必要自己造这种轮子,直接用现成的无锁容器或者干脆用锁,反而更稳。
3.2 内存序选错:flag明明设置了,为什么读不到
另一个常见坑是内存序乱用或者不匹配。举个例子,线程 A 负责初始化数据,然后通过 atomic 标志位通知线程 B 可以读数据了。
// 线程A data = {...}; ready.store(true, std::memory_order_release); // 线程B if (ready.load(std::memory_order_relaxed)) { // 读取 data,可能有风险 }在 x86 上,release store 就是一个普通 store,relaxed load 也是一个普通 load,因为 x86 天然带有相对强的顺序保证,所以这个代码在 Intel 平台上可能一直都能正常工作。但换到 ARM、RISC-V 这类弱内存序平台,B 线程可能先看到 ready 变为 true,然后才看到 data 的更新,甚至看到 data 还是旧的、半更新状态。
正确的做法是让 load 使用 acquire,与 A 线程的 release 成对出现:
if (ready.load(std::memory_order_acquire)) { // 此时能保证看到 release 之前写入的 data }这段经验来自我早期做跨平台开发时的教训:在 x86 上测了一周都没问题,一上线 ARM 机型就复现随机宕机。后来用 ThreadSanitizer 和内存序终于定位到问题。所以,如果你要跨架构,内存序不是一个“优化技巧”,而是一个正确性要求。
3.3 把atomic当成“免锁万能药”
第三种情况,也是最让我头疼的情况:有人为了追求“无锁”,用 atomic 配合 CAS 去实现复杂的共享数据结构,比如队列、栈、链表。结果就是几周之后,代码里出现了各种玄学:某线程读到了一个已经释放的节点、顺序颠倒、死循环重试……
无锁编程并不是“不用锁”,而是“用一堆更细粒度的原子指令自己管理同步”。这让正确性变得极其微妙,因为你需要同时考虑内存序、ABA、生命周期回收、故障窗口等等。绝大多数业务系统真的不需要走到这一步。
我个人的建议是:能用简单原子变量,就用简单原子变量;一个原子变量搞不定,就用锁。锁没有那么丢人。std::mutex 在低竞争时的快路径已经优化得很好了,很多场景下性能和 atomic 忙等相差并不大。等你真的用性能分析工具证明锁是瓶颈,再考虑更复杂的手段,层层递进,而不是一开始就梭哈无锁。
4. 实操复盘:一个并发计数模块的优化过程
4.1 第一版:一把大锁锁住全表
我接手过一个内部 API 网关,需要统计每个上游接口的调用次数和错误数。最开始的实现特别简单:一个 unordered_map 保存接口名到计数结构,外面套一个 mutex,每次请求进来都要查表、计数、解锁。
这个方案在低并发(比如 1000 QPS)下还能跑。但网关接的流量慢慢涨上来之后,锁竞争成了瓶颈,压测显示系统吞吐只有几万 QPS。用 perf 抓了一下,锁等待占了 CPU 时间的 30% 以上。逻辑非常简单,就是锁太“重”了,因为每次请求都锊一遍全局锁,临界区虽短,但大家还是在排队。
4.2 第二版:atomic计数器替换map
第一版优化顺理成章:把 map 里的计数结构改成 atomic<uint64_t>,去掉 mutex。理论上查表可以并发,计数也不用锁。
改动上线后,吞吐确实涨了,但没过多久就发现高频接口的自增操作成了新的焦点。多个线程同时 fetch_add 到同一个 atomic 变量,本质上就是抢同一个缓存行。而且这个版本还有一个之前隐藏的伪共享问题:接口 A 的计数和接口 B 的计数可能恰好落在同一个缓存行里,线程 X 只修改 A 的计数器,线程 Y 只修改 B 的计数器,但两者为了保持缓存一致,每次写都会把对方的数据一起“踢”掉,性能直线下降。
伪共享的经典示例如下:
struct Counter { std::atomic<uint64_t> value; }; // 两个不同的接口 counters[i] 可能在同一个缓存行解决方式之一是让每个 atomic 变量独占一个缓存行,在 C++17 里可以这样声明:
struct alignas(64) Counter { std::atomic<uint64_t> value; };但这样内存开销很大,而且并没有解决“同一个变量被多个线程同时修改”这个本质问题。高频接口的原子变量依旧是单点瓶颈。
4.3 第三版:per-thread累加再合并
既然多个线程不能同时高频写同一个变量,那就不写同一个变量。最终采用的方案是 per-thread 累加:每个线程维护自己的一组普通变量计数,只写自己线程的数据,完全不与其他线程共享;需要对外读取时,操作一个全局的 atomic 汇总表,把每个线程的计数合并进去。
大致结构如下:
thread_local std::unordered_map<std::string, uint64_t> local_counts; std::unordered_map<std::string, std::atomic<uint64_t>> global_counts; void addRequest(const std::string& api) { local_counts[api]++; // 只操作本线程数据 } void flushToGlobal() { for (auto& [api, cnt] : local_counts) { global_counts[api].fetch_add(cnt, std::memory_order_relaxed); cnt = 0; } }flush 操作不需要每次请求都做,可以定时或者按阈值触发。因为 flush 频率远低于请求频率,全局 atomic 计数器上的竞争压力骤减。
这个版本的吞吐,最终从最初的几万 QPS上升到近百万 QPS。关键点不是“用了原子操作”,而是“让共享写操作尽量不发生”。atomic 在这里只负责低频的汇总,而不是高频的每次请求热点。
4.4 线上真实收益与反思
这次优化让我对 atomic 有了更清醒的认识:它是很好的工具,但不是性能银弹。所谓“无锁”并不是没有等待,而是把等待分散到硬件缓存一致性协议里去了。高竞争下,原子变量的等待一样在,而且更隐蔽。
后来我还做过一个实验:单独把第二版的原子计数器放在一个高并发接口上,线上观察 cache-miss 率高达 20% 多,而改成 per-thread 后的版本几乎降到 1% 以下。这个数据再次验证:减少共享写,比优化某一条原子指令本身要有效得多。
5. 我踩过坑后总结的选择指南
5.1 哪些场景可以放心用atomic
atomic 最适合的场景,其实是那些“单变量、低冲突、简单读改写”的需求。
- 计数器:请求总数、错误总数、生成递增 ID,用 fetch_add 非常合适;
- 开关标志:是否初始化完毕、是否收到退出信号,atomic 足够;
- 无锁发布:拿一个指针,先准备好数据,再用 release store 发布指针,另一个线程用 acquire load 读取后再安全访问数据;
- 无锁数据结构的底层原语,但前提是你真的已经完整读过相关论文并且做过压力测试。
这些场景的共同特点是:共享状态小、单个变量能表达、竞争不频繁。在这种场景下,atomic 比锁更轻,性能优势明显。
5.2 哪些场景最好绕道走
反过来,遇到下面的情况,我劝你不要硬上 atomic:
- 需要多个变量一起变化的“事务式”操作。比如账户余额和交易日志要一起更新,这类需求必须依赖锁或者数据库事务;
- 需要阻塞等待条件。比如生产者消费者,消费者没数据时应该睡眠,而不是用原子变量自旋;
- 多个线程对同一个变量高频写。就算原子操作不崩溃,性能也会因缓存行争用而惨不忍睹;
- 复杂链式结构、树结构。CAS 循环加上 ABA、生命周期问题,会让你陷入泥潭;
- 所有“我觉得用锁不太好,所以改成 atomic 拼一个复杂逻辑”的情况,基本都别碰。
记住,atomic 只是一个“更便宜的锁”,不是“免费的安全”。真正复杂的状态机,老老实实用 mutex + condition_variable,可读性、正确性和可维护性都高出几个量级。
5.3 性能优化的几个实用建议
如果你确实需要追求并发性能,我建议按下面的顺序一步步来:
- 先用默认内存序(seq_cst)写出正确的代码,不要一开始就到处放松 memory order;
- 用性能分析工具确认瓶颈真的是 atomic 操作,而不是整个架构或 IO;
- 观察 cache-miss、bus-cycles、stalled-cycles 等指标,判断是否存在缓存行竞争;
- 如果存在竞争,尝试让每个线程只写自己的数据,定期合并;
- 如果必须共享大量状态,才考虑用设计良好的无锁结构或第三方库;
- 内存序的放松,放在最后作为最后的性能优化手段,而且要加清晰注释说明为什么这里可以放松。
另外伪共享是很容易被忽略的点。在 C++17 里,可以通过 alignas(std::hardware_destructive_interference_size) 避免结构性伪共享。但别把每一个变量都对齐,否则内存膨胀带来的 TLB 压力又会变成新问题。
5.4 检测 atomic 竞争的小技巧
最后分享一个我常用的检测手段:写一个很小的压测程序,让 N 个线程同时对同一个 atomic 变量做相同次数的 fetch_add,然后看耗时和单线程执行 N 次耗时的对比。如果多线程耗时接近 N 倍,说明竞争非常严重;如果几乎线性增长,继续写下去就要出问题。这个压测程序十几分钟就能写完,能帮你提前预判瓶颈,比上线后再冒烟定位要快得多。
atomic 不是不能碰,而是要碰得明白。我现在的习惯是,每次写 std::atomic 都会先问自己一句:“这个变量会发生多线程高频写吗?如果用锁会怎样?”如果答案是“会”,我大概率会重新设计,而不是继续堆原子指令。毕竟,它真的不是免费午餐,只是把账单寄到了你可能看不见的地方。