1. 一个深夜告警背后的“死循环”那天凌晨两点四十三分监控大屏先红了一片紧接着值班手机就炸了。报警信息很简单某国产化服务器 CPU 使用率 100%持续超过五分钟业务接口大面积超时。这个节点跑的是我们一个新上线没多久的批量归档服务底层处理器就是 LA664 核心的芯片操作系统是 LoongArch 架构下的 Linux。一开始我以为是数据量太大导致任务积压直到把 top 刷出来才发现CPU 跑满的不是业务线程而是一堆线程全卡在同一个地址附近反复执行、反复重试像一群蚂蚁绕着同一粒米打转。真正让人后背发凉的是客户的反馈凌晨那段时间部分订单的“归档状态”更新消失了。明明上游系统推送过更新请求底层数据库里也有临时记录可最终落到正式表里的状态竟然还是旧值。这不是普通的接口慢而是典型的“丢失更新”。一边是 CPU 原子指令相关的指令流像死循环一样空转一边是业务数据被吞掉更新这两件事放在一起答案几乎就写在脸上了——有人在多线程更新里用原子指令踩了坑而且这个坑在 LA664 这颗 CPU 上被放大了。这个问题适合所有写并发代码、做中间件或者维护高并发服务的同学参考。尤其是那些习惯了 x86 平台、最近开始接触国产平台的朋友这篇复盘会把从现象到根因再到修复的完整链路讲清楚看完至少能避开一个同类事故。2. 现场定位三个命令锁定嫌疑犯2.1 top、perf、gdb 的顺序操作排障第一步永远是先看现象。我登录节点后先执行了top -H -p pid按线程 CPU 占用排序发现十几个线程的 CPU 时间都在 99% 左右而且线程名高度相似都是归档任务的工作线程。再往下看这些线程的状态列不是 D不可中断睡眠也不是 R运行而是 S睡眠和 R 交错但 CPU 占用却拉满说明它们不是单纯等待而是处于一种“假睡眠真空转”的状态。随后我用了perf top看热点函数输出里出现了一个非常扎眼的符号atomic_cmpxchg_loop。这个名字一看就是我们自己封装的一个 CAS 更新函数。热点集中在这个函数内部而且调用栈层层叠叠最底层是 glibc 的sched_yield和自旋重试。到这里我心里已经有数了这是典型的自旋锁重试死循环。最后用gdb -p pidattach 上去对卡住的线程执行thread apply all bt所有线程的调用栈高度一致#0 atomic_cmpxchg_loop (...) #1 pack_update_state (...) #2 process_batch (...) #3 worker_thread (...)栈底是 worker 线程入口栈顶是自旋 CAS。每一个线程都卡在同一行代码一个while循环里不断调用compare_exchange_weak但循环体里没有修改“期望值”。这就是死循环的直接证据。2.2 事后的“丢失更新”数据复现确认了死循环之后我们再回头研究那些丢失的更新。从数据库 binlog 和中间件的操作日志里找到了这么一条链路上游服务先发送了一个“订单状态修改”请求我们的服务收到后开启了一个数据库事务先在事务里更新了订单表的临时状态位然后调用批量归档模块的 CAS 逻辑去更新版本号。关键在于这个事务在等待 CAS 返回时一直持有数据库行锁。而 CAS 陷入死循环后事务迟迟不提交也不回滚数据库连接池被占满其他依赖同一批数据的更新请求要么等待超时要么被连接池拒绝。更恶心的是数据库在超时后强制回滚了这个事务这就把“临时状态位”的修改也一起回滚了。上游服务以为自己的请求已经送达因为从接口层面看请求没有抛异常只是响应慢等超时后自动重试时那个批次的任务线程已经全部卡死新的请求根本排不进去。最终正式表里没有任何一条更新落盘但上游已经记了一笔“更新成功”数据就这样凭空丢了。3. 原子指令的正确姿势与错误姿势3.1 从 x86 的 cmpxchg 到 LoongArch 的 LL/SC要理解这个事故得先聊聊为什么需要原子指令。多线程更新同一个内存变量时你不能直接“读-改-写”因为两个线程可能同时读到旧值然后各自写回后写的覆盖先写的更新就丢了。CPU 为此提供了原子指令x86 上是lock cmpxchg而 LoongArch 架构也就是 LA664 所采用的指令集沿用了类似 MIPS 的ll.w/sc.w组合即 Load-Linked 和 Store-Conditional。两者的思路有本质差别。cmpxchg是一条真正的“比较并交换”指令硬件保证整个比较和写入过程不被其他核打断ll.w则是先读取一个内存地址并打上“链接”标记后续执行sc.w时CPU 检查这个标记是否仍然有效如果期间有其他核写入了该地址或者发生了中断、异常、缓存行失效sc.w就会失败表示“写入不成功你需要重试”。这带来的一个直接后果是在 LoongArch 上任何基于 LL/SC 的原子操作都必须自带重试循环。x86 上可能一句lock cmpxchg就结束的事情在 LA664 上需要写成static int atomic_cas_word(void *addr, unsigned long expected, unsigned long desired) { unsigned long old; int ok; asm volatile( 1: ll.w %0, %1 \n // load linked 读取原值 bne %0, %2, 2f\n // 如果原值不等于期望值跳出去返回失败 move %3, %4 \n // 把新值放入寄存器 sc.w %3, %1 \n // store conditional尝试写回 beqz %3, 1b \n // 如果写失败跳回去重新来 2: \n : r(old), (*addr), r(ok) : r(expected), r(desired) : memory); return (old expected) ok; }这个封装本身是没问题的。sc.w失败后跳回1:重新执行ll.w这样链接标记会被刷新后面的sc.w才有机会成功。很多从 x86 移植过来的代码会忽略这一点直接把cmpxchg的语义想成“一次指令一定能完成”结果在 LA664 上频繁失败却不自知。3.2 真正的死循环藏在业务代码里问题恰恰不在底层封装而在于上层调用者。我们的归档模块里有一段非常像“教科书”的乐观锁更新代码unsigned long expected atomic_load(ent-state); int success 0; do { unsigned long desired expected | STATE_PACKING; success atomic_cas_word(ent-state, expected, desired); } while (!success);乍一看这不是标准 CAS 重试吗expect 值在循环前读了一次循环里构造 desiredCAS 失败就重试。但请注意expected在循环体里从来没有被重新赋值为ent-state的最新值。如果第一次 CAS 失败说明有其他线程已经把我们读到的旧状态改掉了那么第二次、第三次……第无数次 CAS 拿到的依然是一个旧的 expected内存里真实值永远不等于这个旧快照于是循环永远退不出去。这就是标题里说的“一个打包死循环”——外层是批量归档的包处理循环内层是 CAS 重试循环两个循环叠在一起内层一旦出不来整个包的处理线程就彻底卡死。CPU 在一遍遍执行ll.w/sc.w指令流水线被灌满系统负载飙升但什么也没推进。3.3 为什么原子指令会掩盖问题原子指令本身只是保证“比较并交换”这个动作的原子性它不负责“比较失败之后该怎么办”。很多人有一个错觉既然 CAS 是原子的那它就应该像锁一样帮我搞定一切。实际上 CAS 只回答一个问题“如果内存里的值还是我期望的那个我就把它换成新值如果不是我告诉你失败。”失败之后是重新读最新值再试还是放弃还是睡一会儿再试这完全是业务代码的责任。更隐蔽的是这种错误在 x86 上极难复现。因为lock cmpxchg是一条指令完成比较和写入执行窗口极短如果并发压力不够大第一次 CAS 往往就成功了expected 没有被更新也无所谓。但在 LA664 上ll.w到sc.w之间是一个“链接窗口”期间有任何缓存一致性事件比如其他核读改写同一个缓存行都可能导致sc.w失败。并发一高SC 失败概率显著上升死循环立刻被放大成 100% CPU 事故。这也是为什么问题在低压力测试时完全发现不了一上生产就爆。4. 丢失更新的完整链条锁、事务与回滚4.1 从 CAS 死循环到事务回滚的四步传递要解释清楚“丢失更新”不能只盯着 CAS 那一行。整个事故的发生是一个链条第一步线程 T1 成功地把订单 A 的状态从 0 改成了 1并且准备继续处理归档内容。第二步线程 T2 在 T1 提交之前读取了订单 A 的状态读到的是旧值 0。第三步T2 尝试 CAS(0, 1)失败因为真实值已经变成 1。按照上面的错误代码T2 陷入死循环并且在这个过程中T2 持有一个处理订单 A 所在分片的数据库事务和行锁。第四步T1 的后续操作需要访问同一个分片的另一行数据时被 T2 的锁阻塞T1 的事务在超时后被强制回滚——包括它已经成功的0 - 1更新。最终 T1 和 T2 的修改全部丢失上游却收到过“成功”的响应这个响应其实只是代表请求已经被服务端接收并没有等待最终事务提交成功。这个场景里最反直觉的一点是表面上看T1 的 CAS 是成功的它甚至拿到了“更新成功”的结果但因为它依赖的后续操作被卡死的 T2 阻塞整个事务被回滚已经写入的更新也跟着被撤销。这不是经典的“后写覆盖先写”而是“死循环线程用锁拖垮了正常事务让已提交的变更跟着陪葬”。在业务日志里看到的自然就是明明有更新操作最终数据却是旧值。4.2 “重试机制”为什么没能救回来大部分系统面对偶发的更新失败都会靠接口幂等重试来兜底。但这次事故里重试机制完全失效了原因有三点。第一连接池被占满。每个卡死的线程都持有数据库连接等待 CAS 返回值连接池几十个连接很快耗尽新的重试请求根本拿不到连接。第二线程死循环导致的 CPU 风暴。调度器上全是空转的归档线程重试请求即使进了应用也分不到足够的 CPU 时间片。第三数据分片锁互斥。归档任务按照“包”来组织一个包对应一个互斥锁现在这个包的所有工作线程全部锁在死循环里锁永远不会释放重试请求只能排队等锁等到超时。所以不要以为有了幂等和重试就能高枕无忧。当错误发生在底层资源层——比如 CAS 死循环把 CPU 打满、把连接池拖垮——重试只会加重系统负担而不是挽回数据。4.3 正确写法怎么避免这个问题正确代码其实只需要改一句话每次 CAS 失败后重新读取当前真实值再计算新的 desired。unsigned long expected; unsigned long desired; int success; do { expected atomic_load(ent-state); // 每次都读最新值 desired expected | STATE_PACKING; success atomic_cas_word(ent-state, expected, desired); } while (!success);这样做有一个小小的性能代价多了一次普通内存读。但这个读操作是廉价的比起死循环烧掉的 CPU 时间这点开销完全可以忽略。如果你读到的 expected 和上一次的 expected 相同说明没人改过如果不同说明有竞争重新用新值去比较。本质上就是“跟随最新状态”而不是“拿着旧照片找现在的人”。还有一个更稳健的替代方案如果这个更新操作不追求无锁直接用pthread_mutex或者数据库行锁包住整个“读-改-写”过程也能避免 CAS 滥用。但考虑到我们当时追求的是高并发下减少锁竞争改成“每次都重新读”的 CAS 重试逻辑是性价比最高的修法。5. 修复、验证以及我踩过坑后的三点体会5.1 一行代码的修复与完整的验证过程那次修复的 diff 真的很短核心就是上面说的把expected的读取从循环外挪到循环内。但短 diff 不代表可以随便发我们做了一套完整的验证。先是在测试环境复现。写了一个简单的压测程序开 32 个线程同时更新共享结构体里的状态字段每次更新之间加一点随机 sleep 模拟业务处理。压力开起来之后旧代码立刻让 CPU 冲到 100%线程栈停在atomic_cas_word的循环上新代码在同样的压力下CPU 占用保持在 30% 以下所有线程都能正常退出循环。然后我们在 staging 环境上了生产流量同时监控四个指标CPU 使用率、线程阻塞数、数据库连接池活跃连接数、事务超时率。运行四个小时后CPU 峰值从 100% 降到 45%连接池没有告警事务超时率归零。最后才发到生产并且加了日志在 CAS 重试次数超过 10 次时打印 WARN方便以后再遇到类似问题能第一时间看到。5.2 快速排查同类问题的速查表这次事故之后我把排查思路整理成了一张表分享给团队也希望能帮到同行症状可能原因第一步排查动作CPU 跑满但线程状态多为 S/R 交替自旋锁/CAS 重试死循环perf top看热点函数是否在 atomic 重试调用栈反复出现同一封装的 CAS 函数期望值未更新检查循环里有没有重新读取目标内存数据库事务大量超时回滚死循环线程持有行锁查information_schema.innodb_trx找长时间未提交事务接口超时但上游认为已成功连接池被死循环占满看连接池监控确认活跃连接是否打满高并发才出现低压力不出现LL/SC 窗口期竞争导致的 SC 失败被错误重试用线程压测工具复现对比 CPU 与线程栈这张表的核心逻辑是一切“莫名其妙的高 CPU 数据丢失”问题先看线程栈再看事务和锁最后才怀疑业务逻辑。很多时候bug 本身藏在你对底层指令的误解里。5.3 那些文档里不会告诉你的 LA664 细节最后说说我在这次事故里学到的、跟 LA664 平台相关的几个细节。第一不要低估 LL/SC 的失败概率。很多文章说 SC 失败是因为“缓存行被其他核写过了”这没错但在实际并发场景里ll.w之后哪怕只是读取同一个缓存行上的其他变量也可能导致缓存行状态从 Modified 变为 Shared从而让 SC 失败。所以如果 LL/SC 封装内部混入了其他内存操作成功率会更低。第二编译器不背锅但也要防一手。atomic_cas_word里的 asm 我加了memoryclobber这是必须的。如果不加编译器可能把循环外的读操作缓存到寄存器导致重试逻辑被优化得更离谱。用 GCC 的__atomic_compare_exchange_n内置函数会更安全它能保证合理的编译器屏障。第三线上一定要有 watchdog。死循环这种事哪怕概率再低一旦发生就是爆炸性的。我们给归档任务加了一个“心跳标记”工作线程每处理一个包就更新一次标记监控线程如果发现标记超过 5 分钟没动就自动 dump 线程栈并告警。这次事故如果能提前 dump定位时间至少可以缩短一半。原子指令是并发世界里最锋利的刀但它只管“比较并交换”那一下管不了你拿着结果干什么。一个忘记更新的期望值就能让一颗强大的 CPU 变成一个永动机式的电暖器顺带把业务数据烧成灰。这个教训我至少能记十年。