尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

atomic不是免费午餐:并发编程中原子操作的真实开销与陷阱

atomic不是免费午餐:并发编程中原子操作的真实开销与陷阱 前阵子组里的小伙子拿着一份压测报告找我说他写的一个并发计数模块8个线程跑下来吞吐不但没上去反而比单线程还低。我扫了一眼代码满屏幕都是 std::atomicUAF、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 个线程同时对同一个 atomicuint64_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_exchangeCAS往往是写成循环重试比如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::atomicint 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 后又改回 ACAS 无法区分“没变过”和“变回原样”。这在无锁链表、无锁栈的实现里是经典大坑。说实话普通业务场景根本没必要自己造这种轮子直接用现成的无锁容器或者干脆用锁反而更稳。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 就是一个普通 storerelaxed 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 里的计数结构改成 atomicuint64_t去掉 mutex。理论上查表可以并发计数也不用锁。改动上线后吞吐确实涨了但没过多久就发现高频接口的自增操作成了新的焦点。多个线程同时 fetch_add 到同一个 atomic 变量本质上就是抢同一个缓存行。而且这个版本还有一个之前隐藏的伪共享问题接口 A 的计数和接口 B 的计数可能恰好落在同一个缓存行里线程 X 只修改 A 的计数器线程 Y 只修改 B 的计数器但两者为了保持缓存一致每次写都会把对方的数据一起“踢”掉性能直线下降。伪共享的经典示例如下struct Counter { std::atomicuint64_t value; }; // 两个不同的接口 counters[i] 可能在同一个缓存行解决方式之一是让每个 atomic 变量独占一个缓存行在 C17 里可以这样声明struct alignas(64) Counter { std::atomicuint64_t value; };但这样内存开销很大而且并没有解决“同一个变量被多个线程同时修改”这个本质问题。高频接口的原子变量依旧是单点瓶颈。4.3 第三版per-thread累加再合并既然多个线程不能同时高频写同一个变量那就不写同一个变量。最终采用的方案是 per-thread 累加每个线程维护自己的一组普通变量计数只写自己线程的数据完全不与其他线程共享需要对外读取时操作一个全局的 atomic 汇总表把每个线程的计数合并进去。大致结构如下thread_local std::unordered_mapstd::string, uint64_t local_counts; std::unordered_mapstd::string, std::atomicuint64_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 哪些场景可以放心用atomicatomic 最适合的场景其实是那些“单变量、低冲突、简单读改写”的需求。计数器请求总数、错误总数、生成递增 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 等指标判断是否存在缓存行竞争如果存在竞争尝试让每个线程只写自己的数据定期合并如果必须共享大量状态才考虑用设计良好的无锁结构或第三方库内存序的放松放在最后作为最后的性能优化手段而且要加清晰注释说明为什么这里可以放松。另外伪共享是很容易被忽略的点。在 C17 里可以通过 alignas(std::hardware_destructive_interference_size) 避免结构性伪共享。但别把每一个变量都对齐否则内存膨胀带来的 TLB 压力又会变成新问题。5.4 检测 atomic 竞争的小技巧最后分享一个我常用的检测手段写一个很小的压测程序让 N 个线程同时对同一个 atomic 变量做相同次数的 fetch_add然后看耗时和单线程执行 N 次耗时的对比。如果多线程耗时接近 N 倍说明竞争非常严重如果几乎线性增长继续写下去就要出问题。这个压测程序十几分钟就能写完能帮你提前预判瓶颈比上线后再冒烟定位要快得多。atomic 不是不能碰而是要碰得明白。我现在的习惯是每次写 std::atomic 都会先问自己一句“这个变量会发生多线程高频写吗如果用锁会怎样”如果答案是“会”我大概率会重新设计而不是继续堆原子指令。毕竟它真的不是免费午餐只是把账单寄到了你可能看不见的地方。
返回列表