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 <pid>attach 上去,对卡住的线程执行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 值在循环前读了一次,循环里构造 desired,CAS 失败就重试。但请注意: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 变成一个永动机式的电暖器,顺带把业务数据烧成灰。这个教训,我至少能记十年。