尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

LA664 CAS重试死循环:陈旧快照导致计数丢失与修复

发布时间:2026/9/26 22:14:23

资讯中心
01
ARTICLE

LA664 CAS重试死循环:陈旧快照导致计数丢失与修复

LA664 CAS重试死循环:陈旧快照导致计数丢失与修复
先说结论那天线上丢的每一笔计数都不是业务层的竞态而是底层一个看似没毛病的 CAS 重试循环在 LA664 这颗核上把“重试”活活打成了死循环而死循环腾出的时间窗口里另一条非原子路径又补了一刀于是我们同时看到 CPU 打死机和计数回退。这个 case 前后折腾了两天最后定位到的根因说穿了就一句话在 LA664 上用原子指令做“读-改-写”时失败重试的快照必须重新加载否则一切都是空中楼阁。这篇文章我把完整的排查过程、涉及的 LA664 原子操作语义、最小复现程序、修复模板以及移植 LoongArch 时最容易踩的坑都整理出来。适合做高性能并发组件、嵌入式基础库或者刚把 x86 代码往 LoongArch 上迁移的兄弟参考。内容不短但每一段都值得看完。1. 现象复盘同一套代码两个看起来无关的故障1.1 64 位打包字段为什么要在一条指令里塞两个状态我们的热点统计模块是个内存 KV 服务单机每秒要处理几十万次热点查询。统计项非常多为了省内存也为了省缓存行我们把两个强关联状态量压进了同一个uint64_t高 32 位epoch可以理解为“代次”每次滚动清零计数时 1低 32 位count当前代次内的累计计数。这种做法在高性能基础库里非常常见类似 seqlock 里的“版本号 实际数据”打包成一个字。好处很直观如果不用打包字段更新count和epoch需要两步原子操作或者一把锁而打包成 64 位后一次 CAS 就能保证“调整 epoch 的同时重置 count”在并发视角下是不可分割的。热点路径上省掉一把全局锁延迟能低一个数量级。打包字段本身的思路没问题问题出在“怎么打包”和“重试循环怎么写”上。我们当时为了追求极致性能没有直接用 C11 原子库而是自己封装了一层“更轻”的原子访问宏。这个决定后来被证明是整场事故的导火索。1.2 压测现场的两类症状压测用的是 16 并发线程 每线程 100 万次累加目标很明确验证打包计数器在高并发下单调递增。跑起来之后现象非常分裂现象 A计数回退。控制面周期性地读取统计快照发现某些时刻count比上一轮读到的值还小。计数只能增不能减出现回退就意味着——有更新丢了而且是实实在在的“丢失更新”。现象 BCPU 打死机。压测跑到几分钟后某个核的用户态 CPU 时间冲到接近 100%top里那个进程的 CPU 占用一条直线拉满。用gdb attach上去线程的 PC 在一个小循环里来回跳用perf top看热点全部集中在一个自旋等待点。一开始大家以为这是两个独立故障一个是并发写错了一个是死循环 bug。最诡异的是同一套代码在 x86 服务器上跑了无数遍从没出过问题。这就让人非常怀疑是架构相关的行为差异排查方向一下从业务层拉到了指令层。1.3 初步排查排掉的嫌疑我们按常规套路先做排除法代码里没有裸的所有计数器操作都走了原子封装锁的使用没有发现问题热点路径根本没有锁单核、低并发下完全复现不出来x86 上多轮压测全绿。排除到这一步基本可以确定不是普通的数据竞争也不是互斥逻辑写错而是原子操作本身在 LA664 上的行为和我们预期不一致。这年头写并发代码大家默认“原子 安全”但原子性和正确性是两回事。接下来的问题变成了这颗核的原子指令到底在什么情况下会参与制造一个死循环又怎么和丢失更新扯上关系2. 原子指令在 LA664 上的“三种性格”2.1 从 x86 的 CAS 到 LoongArch 的 LL/SCx86 的LOCK CMPXCHG是一条指令完成“比较 交换”硬件层面把读、比较、写打包成不可分割的原子序列。写过 x86 并发代码的人都很习惯这种模型CAS 失败说明值不相等重新读、重新试就行。重点在于失败后只要重新加载一切都能恢复。LoongArch 的原子指令分两类am*系列amswap、amadd、amand、amor、amxor、ammax、ammin等是真正意义上的“读改写”原子指令x86 老玩家比较容易理解。ll.d/sc.d以及 32 位版本的ll.w/sc.w这是典型的LL/SCLoad-Linked / Store-Conditional模型MIPS 基因的延续。LL/SC 的玩法完全不同。“LL 指令负责把一个地址读进寄存器同时硬件事先在缓存行上做一个‘监视标记’SC 指令尝试写回只有监视窗口没有被破坏时 SC 才成功否则返回 0 表示失败。” 这个“监视窗口”会被什么破坏最常见的是这个缓存行被其他核写过、被中断和上下文切换打断、或者缓存行被替换出去。也就是说SC 失败的次数可能远比你想象的多而且失败并不代表“值不相等”——哪怕你计算用的快照和别人写回的值一模一样只要中间有人动过缓存行SC 照样失败。x86 的 CAS 失败可以甩锅给“值变了”LA664 的 SC 失败却不能这么解释。很多从 x86 迁过来的代码死就死在把 SC 失败完全当成“CAS 比较失败”来处理。2.2 一个原子 RMW 隐含的三个前提无论用am*还是ll/sc一个完整的“读-改-写”原子操作在逻辑上包含三步读快照、算新值、尝试交换。很多人只关注“交换”那一步是原子的却忽略了下面三个前提读快照必须是原子读。快照读取本身如果用了普通load编译器有权把它当成普通内存访问来优化可能在循环外只读一次也可能乱序提前执行。失败重试必须刷新快照。CAS/SC 失败后唯一正确的动作是重新读内存哪怕上一个快照看起来“还新鲜”。只要快照是旧的重试多少次都是拿同一个预期值去撞一个已经变化的世界。内存序是语意的一部分。原子操作只管“这一个点的原子性”不管“它和前后操作之间如何排序”。弱内存序架构上排序问题尤其明显。这三个前提任何一个被破坏结果都不是“操作变慢”而是“更新静默丢失”或“循环永不退出”。我们这次的 bug就是第二条和第三条一起踩了。2.3 强内存序掩盖了多少问题x86 是强内存序TSOstore 的写入顺序、load 的读取顺序在绝大多数场景下表现得非常“老实”。你写old *p再cas(p, old, new)几乎不会遇到读到的值比内存旧这种问题。可 LA664 是弱内存序乱序执行的窗口很大再加上编译器优化很多在 x86 上“碰巧能工作”的坏代码在这里就会露出原形。一个很形象的比喻x86 像一间规矩的教室后排同学也基本按顺序举手发言LA664 更像一个自由讨论的会场不主动约束顺序大家可能同时说话你必须自己用“举手规则屏障”来保证谁先谁后。我们的 bug 场景里编译器虽然没有把真实的 LL 读提出循环LL 在语义上必须每次读内存但它确实可以把“旧值是否等于预期值”的判断做成基于一个不更新的寄存器快照。结果就是LL 每次读到的都是最新值但程序拿最新值和寄存器里“祖传”的旧预期值比较永远不相等于是永远重试。3. 根因链陈旧快照把重试循环“打包”成死循环3.1 问题代码全景简化后的问题代码长这样static inline uint64_t fetch_and_bump(uint64_t *p) { // 第一次读快照 uint64_t old *p; for (;;) { // 基于同一个 old 打包计算新值 uint32_t ep (uint32_t)(old 32); uint32_t ct (uint32_t)old; ct; if (ct 0) ep; uint64_t desired ((uint64_t)ep 32) | ct; // 关键问题CAS 失败后old 不会被刷新 if (__sync_bool_compare_and_swap(p, old, desired)) return desired; } }这段代码的错误非常隐蔽。__sync_bool_compare_and_swap的 expected 参数是按值传递的它只告诉函数“你用这个值去比”失败后不会把当前内存值回填给 old。C11 的atomic_compare_exchange系列则不同它把 expected 当指针传入失败时会自动把内存里的真实值写回 expected。就这一个 API 语义的差异决定了循环是否真正“自愈”。于是这里变成两个线程同时调用线程 A 成功把counter从 0 变成 1线程 B 的old还是开天辟地时的 0它每次 CAS 都会拿着 0 去和内存里的 1 比永远失败永远不更新 old形成了教科书式的死循环。3.2 从反汇编看“新鲜失败”的真相我们当时把这段代码反汇编之后看到了接近下面这样的序列示意非精确逐行翻译# a0 保存 p 地址a1 保存 old(初始值 0) .Lloop: # 计算 desired结果放到 a2 ... # LL 读内存真实值到 t0 ll.d t0, (a0) # 用真实值(1) 和 旧快照(0) 比较 bne t0, a1, .Lfail # 如果相等才执行 SC sc.d a2, (a0) beqz t0, .Lloop ; sc 失败则跳回循环 ... .Lfail: # 返回 0外层 for 循环再次进入 j .Lloop注意.Lfail分支它什么都没做直接跳回循环。循环再进来时a1寄存器里还是那个 0。LL 每次都忠实地读到内存里的 1可程序比较的标准始终是 0。这不是“读被优化到循环外”而是“期望值没有随失败刷新”。效果比外提更隐蔽读、写、比较都是原子的整条链路却是死锁的。再加上 LA664 是弱内存序__sync_bool_compare_and_swap在 x86 上默认会给足排序保证我们在封装时为了性能又加了宽松语义的倾向导致失败路径上连一点点重新加载的“自觉”都没有。两个因素一叠加死循环几乎成了必然。3.3 死循环如何演变成丢失更新完整时间线死循环只是表象真正让人冷汗的是丢失更新。两者不是两个 bug而是同一个 bug 的两个阶段。完整时间线是这样的压测开始线程 A、B 同时进入fetch_and_bump。A 成功 CAScounter 1B 拿着旧快照 0 进入死循环把一个核彻底占满。B 的空转导致请求处理能力下降压测 TPS 开始抖管理面每隔几秒读取一次统计快照。管理面发现counter状态“卡死”了很久按照预案触发了一次状态重置直接对counter执行了普通赋值清零。这个重置代码为了图快用的是普通 store没有走原子封装更糟的是它用的是 32 位写只把低 32 位的count清零高 32 位的epoch保持原样。就在重置把counter低 32 位写回 0 的瞬间B 的 LL 读到这个 0和自己的旧快照 0 匹配成功SC 通过把desired旧的 epoch 计数 1整体写回。结果重置过的状态被 B 用陈旧快照覆盖部分位被改回旧值计数出现“回退”更别提 32 位部分写本身已经制造了撕裂值。死循环把系统推进了急诊室非原子重置又给了致命一刀。表面上丢失更新是“另一条路径”惹的祸但真正让旧快照有机会存活这么久的是那个不会自愈的 CAS 循环。4. 最小复现、修复与回归验证4.1 最小复现程序剥离掉所有业务逻辑最小复现可以浓缩成下面这个程序。在 LA664 机器上跑死循环几乎必现加一段“周期性清零并观察”的辅助代码就能看到丢失更新。#include pthread.h #include stdint.h #include stdio.h static uint64_t counter 0; void *bad_worker(void *arg) { // 第一次读快照 uint64_t old counter; for (;;) { uint64_t desired old 1; // 失败后 old 不刷新线程会卡死在这里 if (__sync_bool_compare_and_swap(counter, old, desired)) break; } return NULL; } int main(void) { pthread_t t1, t2; pthread_create(t1, NULL, bad_worker, NULL); pthread_create(t2, NULL, bad_worker, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); printf(final%llu\n, (unsigned long long)counter); return 0; }两个线程同时进入时只有一个能完成递增另一个陷入死循环。如果换用 C11 的atomic_compare_exchange_weak并传入指针形式的 expected失败时 expected 会被刷新程序就能正常结束。这就是最小复现里最扎心的地方API 选错一个重载结果天差地别。4.2 正确的原子重试模板修复后的核心写法是所有并发库都该有的标准模板#include stdatomic.h static inline uint64_t atomic_fetch_and_bump(_Atomic uint64_t *p) { uint64_t expected atomic_load_explicit(p, memory_order_relaxed); uint64_t desired; do { uint32_t ep (uint32_t)(expected 32); uint32_t ct (uint32_t)expected; ct; if (ct 0) ep; desired ((uint64_t)ep 32) | ct; // 失败时expected 会被刷新为当前内存值 } while (!atomic_compare_exchange_weak_explicit( p, expected, desired, memory_order_relaxed, memory_order_relaxed)); return desired; }这里有两个关键点用weak版本而不是strong。LA664 是 LL/SC 架构SC 失败有很多理由weak 版本天然适配这种“失败后重试”的语义strong 版本在 LL/SC 上通常也是循环重试但会给编译器少一些自由。让原子库维护 expected。失败时库函数自动把expected更新为内存里的真实值循环里顺理成章地拿新快照重新计算。不需要自己手动重新读也不会有陈旧快照存活的可能。如果因为特殊原因必须手写 LL/SC模板必须是下面这样注意 SC 失败的跳转目标必须回到 LL1: ll.d t0, (a0) # 读快照 # 基于 t0 计算新值到 t1 sc.d t1, (a0) # 条件写回 beqz t1, 1b # 注意失败必须跳回 LL而不是跳回比较处“失败跳回哪个标号”决定了这个循环是会自愈还是一头撞死。很多从 x86 平移过来的手写汇编SC 失败后回到的是比较分支逻辑上等于拿旧快照再撞一次和我们的 bug 一模一样。4.3 修复后压测对比修复后同一台机器、同样的 16 线程压测结果如下观测项修复前修复后计数单调性偶发回退严格单调递增是否出现打死机复现单核 100%未复现合并 100 万次累加的耗时因死循环无法完成约 24 msP99 延迟尖峰偶发几十毫秒级基本消失回归测试又跑了 48 小时没有再出现过一次计数回退。到这里才敢确认LA664 的原子指令本身没有毛病是我们的重试循环没有尊重它的语义。5. 在 LA664 上做并发更新的实操清单5.1 牢记 LL/SC 的失败语义别把它当 CAS 用在 LA664 上写并发代码第一原则是SC 失败 ≠ 值不等。中断、上下文切换、缓存行被其他核碰过、缓存行被替换都可能导致 SC 失败。所以在 LL/SC 的循环里失败是常态不是异常常态的应对方式就是无条件重新 LL。任何“失败后继续用旧值试”的写法都是埋雷。5.2 用标准原子函数别裸手写能用 C11stdatomic.h或 Cstd::atomic就别自己拼指令。LoongArch 工具链已经能正确生成ll/sc序列和失败重试逻辑。我们这次两个故障全部是从“手写轻量封装”开始的。手写内联汇编看起来比库函数少几条指令但失去的是语义保证和编译器协同这个代价远大于那几条指令的性能收益。性能分析时觉得库函数慢先做 profiling 再决定要不要手写不要凭感觉优化。5.3 内存序按需给足不要一律 relaxed很多计数器场景用memory_order_relaxed确实够因为计数本身的正确性由 CAS 保证。但我们这次错误把“打包状态更新”也设计成了纯 relaxed失败路径上完全没有任何排序约束等于告诉编译器“怎么快怎么来”。如果你的更新存在“先改数据再发布标志”这类关系release/acquire 是底线不要为了省几个周期把所有内存序都设成 relaxed等丢失更新来了十个周期都修不回来。5.4 向 LoongArch 移植 review 时盯住这几处如果团队正把 x86 代码迁到 LA664建议把所有涉及并发的地方过一遍这个清单有没有用__sync_bool_compare_and_swap这类不刷新 expected 的接口换成__atomic_compare_exchange_n或 C11 原子库。手写汇编里有没有 SC 失败后跳回比较处的错误循环结构有没有“原子读改写路径”和“普通赋值路径”混用同一个变量统一走原子封装。有没有依赖 x86 强内存序“碰巧成立”的锁序/标志位逻辑补上 acquire/release。打包字段有没有被 32 位指令部分写64 位字段必须用 64 位原子操作整体读写。这个 checklist 我们后来直接写进了 CI review 模板只要是 LoongArch 相关的并发改动必须逐条过。事后复盘时我一直想如果当时有人提醒一句“失败重试后 expected 要重新加载”这场事故可能根本不会发生。但这种事就是这样没踩过坑永远不会对“陈旧快照”这四个字有感觉。后来我们团队立了一条规矩任何涉及 CAS 重试的代码PR 时必须审查“失败后是否重读”并且尽量用标准原子库函数而不是手写内联汇编。这个小习惯比什么防火墙都好使。最后再补一个细节那个 32 位部分写导致撕裂值的问题修完 CAS 后依然值得单独自查一遍。打包字段在弱内存序架构上最怕的就是“不同宽度的访问混用”——哪怕你是往低 32 位写 0高 32 位也可能在那一瞬间被读到随机值。能用 64 位原子整体读写就永远不要为了“省事”拆成两个 32 位操作。这是我在这次事故里学到的另一条血泪教训。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。