ARTICLE DETAIL

资讯详情

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

原子RMW与普通store写竞争:计数器回退的根因剖析与修复

原子RMW与普通store写竞争:计数器回退的根因剖析与修复 1. 现象确认一个不该回退的计数器偏偏回退了事情要从一款跑在自研服务器平台上的存储引擎说起。那阵子我们刚做完一轮固件升级把底层内核固件、BSP 驱动和板级管理逻辑整体换了一版正在做长时间稳定性回归。压测脚本里有一条核心断言统计环形缓冲区读写落后的计数器只增不减。这个计数器在两个核之间是共享的用自旋锁加原子操作维护逻辑上极其简单——谁消费完一批数据谁就对共享变量做一次原子自增另一个核读它来判断队列水位。压测跑了一天一夜值机同事凌晨打电话过来计数器回退了。回退不是零星的抖动。监控图上能明显看到一条锯齿线总体趋势往上爬但每隔几分钟就往下跳一截最多一次掉了四十多万。按代码逻辑这是不可能的——所有写入点都加了锁所有累加都走原子指令自增在单核视角下无论如何不会把值变小。更诡异的是回退幅度和某个线程的忙等行为强相关。那个线程不是正常的消费者而是一个固件热升级期间负责搬运队列元数据的临时线程它在等待另一个核表态时会原地自旋。我一开始怀疑是监控端读撕裂。64 位计数器在 32 位系统上读取时可能读到半个新值半个旧值撕裂之后拼接出一个看起来回退的数值。但排查之后发现撕裂解释不了这个现象撕裂只会造成单次读值异常下一拍就会恢复而我们的回退是持续性的回退后的值真的维持了一段时间才重新涨回去。这意味着内存里的真实值被改小了不是观测端的问题。那个周末我搭了一个最小复现环境两个线程一个负责不停地对共享变量做fetch_add(1)另一个每隔 100 微秒读一次并记录最小值让它们在两个物理核上绑核运行。复现概率相当惊人跑不到二十分钟就出现了一次回退。代码逻辑如下// cpu0: writer while (!stop) { atomic_fetch_add_explicit(shared_counter, 1, memory_order_relaxed); } // cpu1: monitor while (!stop) { uint64_t v atomic_load_explicit(shared_counter, memory_order_relaxed); if (v min_seen) { printf(counter went BACKWARD: %lu - %lu\n, min_seen, v); exit(1); } if (v min_seen) min_seen v; }这套代码放到 x86 机器上连跑一周没有任何问题放到 LA664 平台上二十分钟内必然复现。到这里基本可以断定不是业务逻辑写错了而是平台行为在我们没预期到的层面出了问题。2. 常规排查全部失效锁、缓存、编译器都清白第一轮排查走的是教科书路线检查内存序、检查锁保护、检查编译器优化。原子自增使用的是 C11 标准里的atomic_fetch_add底层会生成 LDXR/STXR 这样的 LL/SC 指令序列语义上是读-改-写整体原子执行。反复审视环形缓冲区的操作流程临界区入口和出口的锁配对没有问题没有发现任何路径会绕过锁去写共享变量。为了排除编译器重排的干扰我甚至把相关变量都加了volatile又把原子操作的内存序改成seq_cst最强顺序结果复现概率一点没变。缓存一致性层面更查不出问题。LA664 是 ARMv8.2 架构的多核处理器硬件上实现了 MESI 风格的缓存一致性协议两个核对同一个 cache line 的访问会被硬件串行化。按文档描述原子读改写指令在执行期间该 cache line 处于独占状态外部任何读写都无法插入。如果严格按照这个模型推导丢更新在理论上就不该发生。可现实是它确实发生了而且发生在原子指令内部不是在临界区外围。后来我把怀疑对象转到 store buffer 和写合并逻辑上。ARM 架构允许普通 store 在 store buffer 里暂存不立刻进入缓存层级而原子指令为了保证读改写的原子性往往需要绕开或冲刷 store buffer。问题在于如果这个平台上原子读改写指令的读和写回在微架构层面存在中间步骤而写回路径和普通 store 的合并路径共享同一个队列就可能出现一个极其狭窄的执行窗口窗口里另一个核的普通 store 抢先落到了内存随后本核的原子指令写回阶段用旧值覆盖了新值。这个方向没有文档可查。我翻遍了厂商的 BSP 代码、芯片勘误表、内核邮件列表都没有直接讨论类似问题的记录。勘误表里只有一条相关条目在特定低功耗状态下CPU 的原子指令延迟会明显增大建议固件避免在原子指令周围使用 WFI/WFE。看起来八竿子打不着但后来证明这条勘误恰恰给了重要提示——原子指令的执行路径存在某些不受软件直接控制的总线交互阶段。常规手段全部失效之后我反而松了口气这说明问题不在我们写的业务逻辑里而在更底层值得把观测手段往下探一层。3. 从观测端下功夫用指令流和 PMU 锁死现场跑软件逻辑找不出证据就只能盯着指令流和总线行为看。LA664 的调试体系里有几个现成的钩子PMU 性能事件、Kprobe 插桩、以及 ARM 架构里的一等公民——同步异常。我三管齐下把现场锁死。先上 PMU。ARMv8 的 PMU 支持按 PC 采样PMU 事件INST_RETIRED配合PC_SAMPLING也能统计缓存访问和总线访问的特定事件。我把采样周期调到最小让 PMU 在计数器溢出时中断记录当前的 PC 值。压测跑一分钟抓到的样本里两个核的 PC 高度集中在同一个地址附近——writer 核在一段以ldxr/stxr为核心的循环里反复自旋而这段循环正是atomic_fetch_add被内联后的指令序列。这证实了一个关键事实丢更新发生时writer 核正处在原子指令的循环重试路径上。接着用 Kprobe 在原子指令入口处插桩打印每次执行时的锁变量值和 cache line 状态。插桩本身会引入中断延迟和额外的内存访问可能扰动时序所以我只在一万个样本里采一个。即便如此还是捕获到了几次异常前兆在计数器回退发生之前writer 核读到的是旧值随后 stxr 返回成功但下一次循环里再读到同一个地址时值反而比 stxr 写入的值小。也就是说stxr 认为自己写成功了数据也确实进了存储子系统但最终落到 L2 缓存时被另一个来源覆盖了。最后一步是锁定 cache line。我通过/proc/kallsyms找到共享变量的虚拟地址再用DC CVAC指令强制刷 cache配合页表查询把物理地址对齐到 64 字节边界。这样一来我可以在回退发生的前后各做一次 cache line dump对比哪一部分被外部修改过。多次实验之后得到一个稳定的结论回退事件发生时总有一个核刚刚对这个 cache line 做过普通 store写入的位置和原子指令的目标地址在同一个 cache line 上。普通 store 和原子 RMW 在该行上发生了写竞争。为了把竞争窗口复现得更密集我把压测脚本改成writer 核执行原子自增另一个核轮询同一个 cache line 并高频执行普通写的混合负载。复现概率从二十分钟一次提升到了几乎每分钟必现。到这一步我基本确定问题不在编译器、不在锁逻辑而在原子读改写指令的写回路径与普通 store 的提交路径之间存在某种竞争。4. 死循环放大了问题原子 RMW 的写回窗口与旧值覆盖根因分析要回答一个核心问题原子指令在缓存一致性协议的保护下为什么还能允许旧值覆盖新值先讲清楚原子读改写RMW在 LA664 这类乱序执行核心上的实际执行路径。软件视角里atomic_fetch_add是一个不可分割的整体硬件视角里它至少分成三个阶段读取目标值、计算新值、写回。LL/SC 指令对的语义靠独占监视器exclusive monitor来保证ldxr建立独占监视stxr只有在监视未被破坏时才允许写回成功。如果中间有外部写者介入stxr 会返回失败软件重试。这套机制理论上无懈可击——只要 stxr 返回成功就说明读和写之间没有其他写者。问题出在返回成功的时序上。ARM 架构对 stxr 成功语义的规定是写操作已经提交到存储层次并且独占监视未被破坏。注意这里说的是提交到存储层次不是最终落到内存。在 LA664 上普通 store 和原子指令的写回都经过一个共享的存储队列队列出口连接到 L1/L2 缓存和总线接口。当一个原子 RMW 在存储队列里排队时它的写回动作可以拆成两步先把新值写入本地缓存再把缓存行标记为脏、等待总线仲裁回写内存。而另一个核的普通 store 如果经过缓存一致性协议获取了该行的写权限它写入的值会先进入自己的缓存随后通过总线把更新广播到所有核。竞争窗口出现在这里假设 CPU0 执行原子 RMW 读到了旧值 100计算得到 101准备写回在它写回还没落定之前CPU1 对同一个 cache line 执行普通 store把共享变量改成了 102。一致性协议会先处理 CPU1 的请求——因为它持有该行的独占权CPU0 的写回必须让路。但 CPU0 的存储队列里那个写回 101的动作已经处于待提交状态在总线上表现为一个悬挂的写请求。如果 CPU0 在 CPU1 的 store 完成之后才真正把 101 写入缓存那么 101 就会覆盖掉 102。此时独占监视器并不会拦截这次覆盖因为它监控的是外部写者有没有法律上的写权限而不是总线上的写请求会不会乱序回来。结果就是stxr 返回了成功但值被旧值覆盖了。为什么死循环会成为放大器因为这个平台上的原子 RMW 会以极高的频率反复执行每个循环都在制造一个完整的读-改-写回窗口。普通业务代码里原子操作之间隔了成百上千条指令竞争窗口几百纳秒一闪而过根本撞不上。而忙等自旋循环里没有间隔窗口被背靠背地反复打开相当于每秒向总线投递成百上千次可能碰撞的写请求。再加上另一个核对同一 cache line 的普通写也在高频进行碰撞概率被拉高了几个数量级。死循环真正的危害不是忙等浪费 CPU而是制造了持续不断的存储子系统竞争流量。用大白话打个比方两个人交替在一张纸上写数字A 每次都先低头看一眼纸上的旧数字再抬头把自己的新数字写上去。B 偏要在 A 看完旧数字、还没动笔的那个间隙快速把自己写的数字盖上去。A 写完收笔纸上留下的是 A 的数字B 的更新就凭空消失了。原子指令的语义保证了看和写之间没人能进来篡改纸面——但前提是 A 写完之后纸上只能有他这次写的内容。如果 B 的那一写晚到一步恰好落在 A 已经写完但还没合上本子的状态下B 的修改就会被子系统的写回顺序吞掉。5. 修复方案栅栏、退避和失效重读定位到根因之后修复反而不难。我最终在固件和驱动两层各做了一套改动双管齐下把问题堵死。第一层修复是加内存栅栏。在原子读改写指令前后分别插入DMB SY数据内存屏障全系统范围强制存储队列里的悬挂写全部排空之后原子指令才允许进入写回阶段。这样做的本质是把读-改-写回窗口里的所有历史 store 请求清空避免旧的悬挂写与新的原子写回碰撞。实测效果立竿见影压测一小时回退从每分钟一次降为零。但代价是DMB SY是全系统屏障性能损耗比较明显压测数据显示整体吞吐下降了大概 5%在音频处理这种对延迟敏感的场景下不可接受。第二层修复是改掉无界自旋。最初的忙等循环是典型的while (1)现在改成带退避的有限循环每轮自旋前先重新读取目标值的当前状态一旦发现目标值在本轮 RMW 期间被外部修改过就放弃本轮写回退避若干纳秒后再重新发起。具体来说把ldxr读到的旧值和 stxr 成功后重新读到的值做比较不一致就说明发生过碰撞本轮数据已经被外部更新应当以外部值为准重算增量而不是机械地把自己的旧值写回去。这个思路不算新颖多线程编程老手一看就懂但之前没人想到要在原子指令层面做这个检查——大家都默认原子指令本身是绝对可靠的。第三层修复是针对临界区特别短的场景临时关中断 普通读写。在固件里有些统计计数逻辑只执行两三条指令不值得动用全套原子指令。做法是本地关中断irq_local_disable用普通 load/store 更新变量最后再开中断。关中断能阻止本核被抢占却不能阻止另一个核对共享变量的访问所以这个方法只适用于另一个核不会同时写同一个变量的场合。我把这类用法严格限制在核私有计数上跨核共享的变量一律走原子指令加栅栏。修复完成后我跑了三天三夜的强化压测writer 核高频原子自增observer 核高频普通写同一个 cache line监控线程每秒记录一次计数器单调性。结果是一次回退都没有性能损耗控制在 4% 左右——比单独加全屏障的方案好得多。这个结果也验证了根因判断问题出在原子指令的写回路径与普通 store 提交路径的竞争只要在软件侧把竞争流量错开硬件缺陷就不会被触发。6. 这件事教给我的原子指令不是无条件整体的银弹这次排查前后折腾了将近两周最后得到的结论在代码层面只有几行改动但思维层面的收获远比改动量大。写出来给做底层开发的同行们参考。第一原子指令的整体性在硬件视角里从来不是无条件的。软件规范定义的是可观测语义微架构实现为了性能一定会做各种折衷。atomic_fetch_add在乱序执行核心上可能被拆成独立的读阶段和写回阶段两个阶段之间的一切由存储子系统排队。绝大多数时候这个拆分对软件不可见但一旦出现高频原子 RMW 同行普通写 无界自旋三个条件叠加折衷就会以丢更新的形式暴露出来。别以为文档没写就不会发生。第二无限自旋是底层并发问题的放大镜。很多人写自旋循环时只考虑 CPU 占用率忽略了它制造的存储子系统竞争流量。原子指令在自旋循环里被背靠背执行等于持续向总线投递竞争请求任何与时序相关的硬件边界都会被快速命中。我的建议是哪怕不指望退避能解决什么问题也一定要给自旋加上限——要么有最大重试次数要么有超时退出路径。这不仅是健壮性问题也是给硬件保留喘息空间。第三遇到偶发性问题观测的重点要从软件逻辑转向总线行为。PMU 的 PC 采样、cache line 状态 dump、stxr 返回值与内存实际值的交叉比对这些手段定位问题比单纯审查代码有效得多。我一开始在锁保护和内存序正确性上花了太多时间因为那些地方最容易用逻辑推理直接否定真正有价值的是把观测焦点放到存储子系统的写回顺序上去抓那个stxr 成功但值没对的证据链。第四这类问题也提醒我重新审视固件里的每个原子操作。不是所有原子指令都需要改成栅栏退避这么重的方案但每个使用原子指令的地方都应该在心里过一遍这个变量的 cache line 会被多少核同时触碰周围有没有普通 store 在写同一行最坏情况下的竞争窗口被谁放大把这些想清楚很多类似的问题能在设计阶段就绕开。最后说点个人体会。这种事发生一次比看一百遍《计算机组成与设计》都能加深对原子两个字含义的理解。原子不是魔法不是凭空造出来的不可分割而是由缓存一致性协议、总线仲裁逻辑、存储队列深度这些硬件机制共同撑起来的一个契约。契约在绝大多数场景下守约但实现契约的每个环节都可能存在不如人意的角落。这次排查最大的价值不是我修好了一个计数器回退而是让我学会了在写并发代码时多问一句如果这份契约在最坏时序下失效了我的软件扛得住吗
返回列表