ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入理解C++原子操作:从CPU缓存一致性到无锁编程实战

深入理解C++原子操作:从CPU缓存一致性到无锁编程实战 1. 一个简单的自增为什么能引发线上事故1.1 一次8核压测的诡异结果先讲一个真实的排查经历这个场景几乎每个写服务端并发程序的人都遇到过。某次我给一个内部服务做压测8核机器上起了8个worker线程每个线程对同一个计数器执行100万次自增。理论上结果应该是800万。实测结果只有七百四十几万每次跑数值还不一样偏差稳定在几个百分点。单线程跑就完全正常编译开了O3优化结果也不变。问题出在哪我先跟同事解释了结论count这句话在执行时被分成了三步多线程同时执行三步就会互相踩脚。一开始他还不信说C语言里的不是一条汇编吗等我把汇编代码拉出来看了之后他才明白所谓“一条语句”只是给程序员看的假象。1.2 “读—改—写”三步带来的缺失更新看C、C里最普通的自增在CPU眼里长什么样把内存里count的值加载到寄存器在寄存器里加1把寄存器的新值写回内存这三条指令在时间上是有先后顺序的中间任何一个时刻被其他线程打断就会出乱子。一个线程读取count50另一个线程也读取count50两个都加1再写回最终count还是51而不是52。教科书管这叫“缺失更新”lost update实际排查时就是个很隐蔽的脏计数。像这种“读—修改—写”跨越多个步骤的操作光靠语法层面的修饰是没用的。这也是为什么所有语言都引入了原子操作它的意义不是让指令更“快”而是让一段原本会被拆散的读写过程对外表现为一个不可再分割的整体。多核CPU上这个“不可分割”是靠硬件机制保证的这在下一节展开。你只要先记住这句话原子操作解决的是“中间态”问题。所谓中间态就是这个变量在完全写入之前被别的线程看到了一半。2. 多核CPU里的两道锁总线锁与缓存锁2.1 总线锁最原始也最粗暴的保证x86 CPU从很早就提供了一条硬件支持LOCK前缀。程序员在指令前加上lockCPU在执行该指令时会向系统总线发送一个LOCK#信号锁住总线通信。这个机制很好理解既然多核共享内存总线那我在关键指令执行期间把公交车道直接封了谁也别想发车自然不会有车抢道。这是最简单粗暴的“原子性”实现也是当年单核到多核过渡期最容易想到的方案。但它有个致命缺点锁的颗粒度太大了。锁总线期间封锁的是整个CPU与内存、外设之间的通路不光是你在写入的那个变量不能动其他线程读另外一个完全无关的地址也被堵住了。并发程度高的时候一组原子自增就能让整个内存系统的吞吐掉一大截。2.2 缓存锁与MESI协议把锁定粒度从总线缩小到缓存行后来CPU架构演进出了缓存系统原子操作的实现方式也跟着进化从“锁总线”变成了“锁缓存行”。现代CPU内部每个核心都有自己的L1/L2 Cache多个核心之间通过L3或总线互联。为了保证同一份数据在不同核的缓存里不会互相矛盾大家默认遵守一套缓存一致性协议x86上用的大多是MESI及其变体。MESI协议把缓存行状态归纳为四种ModifiedM本核独占该缓存行且已修改还没写回内存其他核肯定没有同一份有效副本ExclusiveE本核独占该缓存行且数据与内存一致SharedS多个核各自缓存里都有一份相同数据都还没改InvalidI缓存行失效必须去别的地方找新数据原子操作在缓存锁场景下的流程大致是CPU发现目标变量所在的缓存行当前处于Shared或Invalid状态就会发起一个“独占请求”让其他核心的缓存行失效把状态变成Exclusive或Modified这样后续整个“读—改—写”过程就在本核心自己的缓存里完成期间其他核心即使发起访问也会因缓存行失效而被迫等待。这不难联想它像教室里老师要求“这个黑板暂时只有你能写”其他同学虽然不能用这块黑板但可以在自己桌上活动不必整个教室静止。和总线锁的“整个教室不许动”相比开销小了一个量级。这也是为什么现代CPU上原子操作并没有传说中那么离谱的慢锁的粒度被大大细化是主要功臣。2.3 x86指令一览哪些自带原子属性具体到指令层面x86一般分三种情况xchg指令和内存交换数据时硬件默认加锁不需要写lock前缀add、sub、inc、dec等普通读写改指令必须加lock前缀才能保证原子性cmpxchg系列CASCompare-And-Swap的核心指令可以配合lock前缀使用用于实现“比较并交换”需要注意的是带锁前缀的指令自带完全内存屏障语义也就是说CPU在执行这条指令前后不会对内存访问乱序跨越它。这一点后面讲内存序时还会用到。这里也顺便提一句ARMARM的原子指令叫LDXR/STXR是“加载独占存储独占”的配对用loop实现原子更新不支持x86那种粗暴的总线锁。底层理念不同但对上层程序员来说原子API的行为一致性是由语言标准保证的代码层面基本可以做到无感切换。3. C与C语言层的原子API3.1 C11的stdatomic.h到底给了什么如果你写的是纯CC11标准引入了一套原子操作头文件是stdatomic.h。它定义了atomic_int、atomic_uint、atomic_bool等类型配套函数包括atomic_load(v)原子读取atomic_store(v, val)原子写入atomic_fetch_add(v, n)原子自增返回旧值atomic_compare_exchange_strong(v, expected, desired)CASatomic_flag_test_and_set(flag)和atomic_flag_clear(flag)基于atomic_flag的自旋锁原语atomic_is_lock_free(v)检查这个原子类型在当前平台上是否真的不需要加锁就能实现这里我觉得最有价值的是atomic_flag。标准明文规定它是“可能无锁”的最小原语很多时候实现自旋锁用它比用atomic_int顺手因为它保证不会偷偷给你塞个mutex。一段很典型的C代码长这样#include stdatomic.h atomic_int counter 0; void worker(void) { for (int i 0; i 1000000; i) { atomic_fetch_add(counter, 1); } }老项目里经常看到ATOMIC_VAR_INIT(0)的写法那是早期标准推荐的初始化宏现在基本可以直接atomic_int counter 0;替代。遇到老代码认识它就行不用纠结。3.2 C11的std::atomic完整家族C的atomic头文件把这一切封装成了类模板。基本用法#include atomic std::atomicint counter{0}; void worker() { for (int i 0; i 1000000; i) { counter.fetch_add(1); // 原子自增 // counter; // 等价写法 } } int main() { int value counter.load(); // 原子读取 counter.store(42); // 原子写入 int old counter.exchange(100); // swap返回旧值 }std::atomic最强大的地方在于它有全套的CAS接口std::atomicint flag{0}; int expected 0; if (flag.compare_exchange_strong(expected, 1)) { // 成功flag之前确实是0现在变成1 }compare_exchange_strong的语义是如果当前值等于expected就改成desired并返回true否则把expected更新成实际值返回false。这一个操作就是一个完整的原子RMW多线程下不需要额外加锁。一个非常实用的经验在写循环重试逻辑时优先使用compare_exchange_weak而不是compare_exchange_strong。这两者的区别在于weak在极少数情况下会无辜失败spurious failure但代价是编译器能生成更高效的代码。如果CAS失败后你本来就要重新加载再尝试那weak这种“偶尔多试一次”的行为完全无伤大雅。3.3 锁与原子操作的选型心得很多新手一学原子操作就问那以后是不是可以不用锁了我个人的判断标准很粗暴如果业务逻辑里面一次操作只涉及一个变量优先用原子操作如果一个临界区需要同时维护多个变量的不变量关系必须老老实实上锁。原子操作不是万能药它只保护“单个内存对象的读改写”保护不了“两个变量必须同时更新”的复合关系。还有一层经验先量后测。一台机器上如果只是十来个线程mutex的竞争开销并不大盲目改成无锁可能收益只有几个百分点却把代码可读性全毁了。无锁代码的调试难度是普通锁代码的好几倍第六节我会专门展开说这里的坑。4. 内存序原子操作里最容易翻车的地方4.1 为什么CPU和编译器会乱序执行原子操作保证的是“原子性”但它不自动保证“顺序性”。现代编译器做指令重排优化CPU有乱序执行能力硬件缓冲区也会造成写入顺序和观察顺序不一致。简单说线程A按顺序写了a再写b线程B可能先观察到b再观察到a。如果你以为用了std::atomic就万事大吉一定会在这上面踩坑。举个经典例子。用全局flag控制数据发布std::atomicbool loaded{false}; std::atomicint data{0}; // 写入线程 data.store(42); loaded.store(true); // 读取线程 while (!loaded.load()) {} use(data.load());这里如果都用默认的seq_cst结果没问题。但如果你一开始图性能写成了memory_order_relaxed就可能读到data还是0。因为relaxed只保证“data.store本身是原子的”对顺序没有任何约束。4.2 四种常用内存序怎么选C里std::atomic的操作都可以指定std::memory_order枚举常用的有这么几个memory_order_relaxed只保证原子性用于计数器这类不需要和其他变量建立顺序关系的场景性能最好memory_order_acquire用在load上它之后的内存读写不允许被重排到这次load之前memory_order_release用在store上它之前的内存读写不允许被重排到这次store之后memory_order_acq_relRMW操作专用同时具备acquire和release语义memory_order_seq_cst默认值所有线程只能看到一个全局一致的顺序性能最贵但最容易理解组合规律release和acquire必须配对使用。一个线程用release写“放行信号”另一个线程用acquire读“放行信号”这样release之前的所有写操作对acquire之后的所有读操作都是可见的。这种可见性关系在标准里叫happens-before。刚才那个例子正式写法应该是std::atomicbool loaded{false}; std::atomicint data{0}; // 写入线程 data.store(42, std::memory_order_relaxed); loaded.store(true, std::memory_order_release); // 读取线程 while (!loaded.load(std::memory_order_acquire)) {} use(data.load(std::memory_order_relaxed));这里data的store/load用relaxed就够了因为顺序关系由loaded的release/acquire带出来了。这种模式叫“发布—订阅”是我在无锁队列里用到最多的内存序搭配。4.3 一个收过学费的锁顺序测试有一次我因为图省事把流水号检查用的原子变量全部写成了relaxed结果压测时偶发出现“生产者拿到了一个陈旧的下一条位置”。定位过程很痛苦因为复现概率极低用调试器挂上去又完全重现不了。后来我打印出两个线程的指令执行顺序才发现一个核心上store先执行另一个核心上load却看到了旧值。问题的根源不是原子操作失效而是默认写relaxed导致release/acquire链路断裂。那次之后我给自己定了两条约定凡是“用一个flag去保护另一批数据”的flag的写必须release读必须acquire凡是目标仅是计数、打点这类不和其他数据建立关系的才可以用relaxed这两条约定在实际项目里帮我挡掉了大部分内存序bug。内存序不是性能炫技它是正确性的基础设施。5. 实战拆解无锁队列到底怎么实现5.1 什么时候值得上无锁无锁队列听起来很高级但你先得回答一个问题现有的condvar/mutex队列真的不够用吗就我的经验在锁竞争比例低于5%时你大概率感受不到无锁的收益在锁竞争激烈到把吞吐打成锯齿形时无锁的收益才值得你付出调试成本。常见场景是高并发日志、交易撮合、网络收包后的内部传递。选型上我建议循序渐进先确认能不能做单生产者单消费者SPSC这是最容易实现且效果最好的模型再考虑多生产者单消费者MPSC复杂度会明显上一个台阶最后才是多生产者多消费者MPMC需要CAS和ABA处理一般不推荐自己造5.2 手写一个SPSC环形队列SPSC模型下不需要CAS只需要两个索引生产者只写tail消费者只读tail消费者只写head生产者只读head。因为读写方向天然分离可以用原子变量安全协作。一个精简实现长这样#include atomic #include vector template typename T class SPSCQueue { std::vectorT data_; std::atomicsize_t tail_{0}; // 生产者写 std::atomicsize_t head_{0}; // 消费者写 public: explicit SPSCQueue(size_t capacity) : data_(capacity 1) {} bool try_push(const T item) { size_t t tail_.load(std::memory_order_relaxed); size_t h head_.load(std::memory_order_acquire); if ((t 1) % data_.size() h) return false; // 满 data_[t] item; tail_.store(t 1, std::memory_order_release); return true; } bool try_pop(T out) { size_t t tail_.load(std::memory_order_acquire); size_t h head_.load(std::memory_order_relaxed); if (t h) return false; // 空 out data_[h]; head_.store(h 1, std::memory_order_release); return true; } };这里有三个细节值得讲透环形缓冲区刻意留一个空位用(tail1) % size head判断满避免“满”和“空”两种状态共用同一个索引值产生歧义生产者读head时用acquire是确保能观察到消费者的release写消费者读tail时用acquire是为了看到生产者的release写带出的数据生产者和消费者各自只操作一个原子变量天然规避了CAS这也是SPSC可以跑到接近内存极限速度的原因如果只是要在单线程内部用队列做解耦SPSC已经覆盖了绝大多数场景。很多性能敏感框架的核心传递通道就是SPSC。5.3 MPMC的复杂性与ABA问题到了多生产者多消费者场景单靠索引就不行了因为多个生产者同时抢同一个尾巴位置必须用CAS去竞争size_t t tail_.load(std::memory_order_relaxed); while (true) { // 先判断队列是否满 // ... if (tail_.compare_exchange_weak(t, t 1, std::memory_order_acq_rel)) { break; // 抢到位置 } // 失败时 t 已被更新为新值重新判断 }这个模式看着简单实际布满坑最经典的坑就是ABA问题。什么是ABA问题线程一读取位置指针为A然后被调度走。线程二把指针从A改成B干完活后又把指针改回A。线程一回来CAS发现“旧值还是A”以为没人动过于是成功写入。但此时队列里可能已经混入了线程二的数据数据结构被彻底破坏。ABA问题的标准解法是给指针打上版本号把“指针值”和“版本号”打包成一个复合对象struct TaggedPtr { void* ptr; uint64_t tag; };每次CAS比较时不仅比指针还要比版本号指针每改动一次版本号跟着递增。这样即使指针值回到原来的地址版本号也无法回退ABA就被识别出来了。无锁队列的完整实现还涉及内存回收、hazard pointer、epoch等一堆高级话题不是一篇博文能讲完。我的真实建议是能用SPSC就绝不碰MPMC能用成熟社区库就绝不自己造轮子。有个说法是“99%的开发者不应该写无锁代码”它的前提是——你应该懂原理但别轻易在生产环境亲自踩雷。6. 常见问题与排查技巧实录6.1 伪共享看起来和锁无关性能却像加了锁两个线程各写各的原子变量理论上应该互不干扰实际上可能被某个看不见的“邻居”拖慢几十倍。这就是伪共享false sharing。缓存是以缓存行x86上通常64字节为单位加载的。假如变量a和b正好落在同一个缓存行里线程A改a会让整个缓存行在核心A和核心B之间反复失效重载b也随之跟着遭殃。明明两个变量毫无关联表现却像在抢一把锁。我实测过一个案例8个线程各自维护一个独立计数器不处理伪共享时8个线程并发跑总吞吐只有单线程的1.2倍左右。把每个计数器对齐到单独的缓存行后吞吐立刻接近线性扩展。解决方式很直接给每个变量加缓存行隔离struct alignas(64) AlignedCounter { std::atomiclong long value{0}; };或者用传统的pad数组struct CounterPtr { std::atomiclong long value; char padding[64]; };对齐填充的本质是让每个计数器独占一个缓存行。现代编译器通常提供了hardware_destructive_interference_size这个常量可以直接查当前平台的缓存行尺寸不用自己硬编码64。6.2 CAS自旋的坑与ticket lock如果拿std::atomic_flag实现自旋锁写起来很简单class SpinLock { std::atomic_flag flag ATOMIC_FLAG_INIT; public: void lock() { while (flag.test_and_set(std::memory_order_acquire)) { // 自旋 } } void unlock() { flag.clear(std::memory_order_release); } };这个版本在高并发下有几点问题cache line bouncing严重所有等待线程都在同一缓存行上自旋每次锁释放都要给所有核心发失效通知冲突特别大无公平性运气差的线程可能饿死CPU空转不执行任何指令停顿功耗和延迟都不理想改进方向有两个。其一是自旋时插入pause指令让CPU缓解内存顺序冲突降低功耗并提高另一个核心获取锁的机会其二是改用ticket lock排队叫号。ticket lock是给每个申请者发一个排队号释放后按号进入临界区公平且性能稳定class TicketLock { std::atomicunsigned next_ticket{0}; std::atomicunsigned now_serving{0}; public: void lock() { unsigned ticket next_ticket.fetch_add(1); while (now_serving.load(std::memory_order_acquire) ! ticket) { // 等号轮到自己 } } void unlock() { now_serving.fetch_add(1, std::memory_order_release); } };自旋锁在临界区很小的时候确实比mutex快但临界区稍大就完蛋。实战铁律自旋锁临界区必须只有几行操作且不能在里面调用任何可能阻塞的系统调用。6.3 现场排查的思考路径原子操作的问题通常不是“坏得明显”而是“偶尔坏一下复现不出来”。我排查这类问题有一个固定路径先确认确实是并发问题单线程压测正常多线程异常差不多可以先排除编译优化和寄存器泄漏翻开所有共享变量的读写点逐个检查原子性普通int的读改写一律替代为原子RMW再检查内存序凡是承担“信号”职责的变量看有没有release/acquire配对最后用压测工具放大线程数配合CPU硬件计数器看缓存竞争和指令重排情况工具层面Linux下的perf c2c能直接列出伪共享嫌疑缓存行valgrind --toolhelgrind可以检测很多数据竞争。我在一次排查中正是靠perf c2c发现两个看似属于不同结构体的字段被编译器排到了同一个64字节区域。整个排查过程的痛苦指数是普通逻辑bug的几倍因为问题既不稳定出现又不在你能看到的行为里留痕迹。所以更重要的其实是预防共享变量一律先声明成atomic内存序按配对原则写清楚CAS循环考虑ABA结构体布局主动做缓存行对齐。这是我这几年在并发编程上最深的体会——多数时候我们并不是缺乏精妙的排查工具而是缺少一开始就把边界条件想清楚的习惯。原子操作、内存序、无锁队列这些概念单独看每个都不难组合在一起却能在不经意间埋下一颗延迟爆炸的雷。把基础原理搞清楚再用“先预防、后排查”的思路去写代码远比事后对着复现不出来的bug苦熬要划算得多。
返回列表