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 在写同一行?最坏情况下的竞争窗口被谁放大?把这些想清楚,很多类似的问题能在设计阶段就绕开。
最后说点个人体会。这种事发生一次,比看一百遍《计算机组成与设计》都能加深对"原子"两个字含义的理解。原子不是魔法,不是凭空造出来的不可分割,而是由缓存一致性协议、总线仲裁逻辑、存储队列深度这些硬件机制共同撑起来的一个契约。契约在绝大多数场景下守约,但实现契约的每个环节都可能存在不如人意的角落。这次排查最大的价值不是我修好了一个计数器回退,而是让我学会了在写并发代码时多问一句:如果这份契约在最坏时序下失效了,我的软件扛得住吗?