
先说结论这次事故的根子不在 LA664 这颗 CPU 的原子指令本身而在于把一个非可重入的自旋锁用进了一条会二次进入的嵌套调用路径。原子指令保证了“读改写”一气呵成可它管不了同一个线程第二次进来时自己锁自己。死循环就是这样被打包出来的一个ll.w、一个sc.w、一个跳转三段式抢锁逻辑在错误场景中被再次触发锁值永远等于 1循环体永不退出。最后外围线程在更新计数器时因为等不到锁走了补偿逻辑直接覆盖丢失更新就这么落地了。整个事件看起来像一个经典的 lost update但加锁后依然偶现。查到最后CPU 占用、线程栈、原子指令、内存序全都相关。这篇文章就完整还原排查过程顺便把 LA664 上自旋锁设计、原子指令的边界、重入死循环的成因和排查清单都理一遍。1. 一次看起来像“丢失更新”的生产事故1.1 统计服务为什么会丢数当时跑的是一个多线程统计服务作用是从消息队列里拉请求记录每处理一条就把内存里的全局计数器加一到点后定时落库。因为是多线程并发计数器更新用的是“读、加、写”三步没有锁保护时丢失更新很容易发生线程 A 读到 100线程 B 也读到 100A 写回 101B 写回 101最后计数少了 1。这个原理大家都懂所以第一版代码也是规规矩矩地在更新路径上加了自旋锁。但事故发生当天统计值和网关日志对不上每小时少了几千条。我们第一反应又是 lost update于是怀疑是不是锁没有生效或者某个更新路径漏加了锁。检查代码发现所有更新入口确实都调了同一个spin_lock包起来的函数理论上不该丢。更让人困惑的是top里始终有一个线程 CPU 稳定在 100%而且趴了一个下午都没下来。一开始以为只是某台机器负载高但把线程栈抓出来之后发现它停在了一个非常微妙的位置自旋锁的抢锁循环里。这个位置明明是一个几纳秒就应该跳出的路径怎么会成为 CPU 热点直觉告诉我问题不只是“少加锁”这么简单。1.2 线程 100% CPU 的第一个线索top输出里那一个线程几乎占满了一个核但整个系统负载并不算夸张。我当时以为是哪个业务代码里写了死循环于是gdb attach上去看到栈帧反复出现在一个自定义的内联函数里函数名翻译过来就是一个低层自旋锁的加锁操作。在 LA664 平台上这个内联函数最终被编译成一组基于 LL/SC 的原子指令序列。用perf top看热点最热的指令是自旋锁里等待分支的ll.w指令或者更准确地说是 LL/SC 重试循环中反复执行的“判断锁值是否为零”的读操作。这说明线程一直在重读锁变量但读到的始终是 1。一个锁变量在长时间内保持 1 只有两种可能要么确实有人持锁没释放要么持有者就是当前线程自己但当前线程在逻辑上跑到了一个不该重复加锁的地方。当时我还不确定是哪种但已经意识到这个线程不是在“等待”一个合理的短临界区而是在等一把永远不可能被释放的锁。1.3 加锁之后问题依旧的迷惑时刻最让人难受的是事故当天凌晨发过一次版本目的就是专门修复丢失更新在所有共享变量更新路径上补了自旋锁。发布后第二天统计值还是丢了而且丢得更规律只要那个 100% CPU 的线程一出现之后的统计值就开始偏少。这就排除了“漏加锁”这个第一怀疑对象。锁加了但仍然丢说明问题转移到锁自身的正确性或者锁被错误地用在了一个会自我阻塞的场景里。我们当时做了三件事抓完整的线程栈、看锁变量在内核态还是用户态、检查有没有别的线程绕过锁直接写共享变量。线程栈让我直接看到了一个不该出现的现象同一个线程在调用栈里连续出现了两层spin_lock一层是某一模块的入口另一层是它调用的内部函数入口。两层中间没有一个释放锁的节点。看到这一幕我基本知道凶手是谁了自旋锁不可重入而代码在持锁状态下又调用了需要同一把锁的函数自己把自己锁死。这比“原子指令用错”更隐蔽因为表面业务逻辑是完整的只是锁被当成了一种可重入的普通临界区保护工具。2. LA664 上的原子指令与自旋锁实现2.1 原子指令的“原力”读-改-写不再断开LA664 是龙芯 3A6000 系列使用的处理器核指令集是 LoongArch。LoongArch 提供了两类原子指令一类是 LL/SC也就是 load-linked / store-conditional这是从 MIPS 时代就广泛使用的经典方案另一类是 AMO 指令比如amswap.w、amadd.w在硬件上直接完成某个内存地址的原子读改写。原子指令解决的核心问题是让“读旧值、计算、写回”这一串操作在并发视角上不可分割。拿计数器累加来举例如果直接用普通 load 和 store即使每条单指令在 CPU 内部是按顺序执行的在多核视角下两个核可以同时读到同一个旧值然后再同时写回最终只增加一次。而用原子指令比如amadd.w硬件会保证“把目标内存单元加上某个值”这个动作整体生效其他核心要么看到操作前的值要么看到操作后的值不可能看到中间态。这就像酒店前台发房卡系统里本来显示 0 表示空房你插卡瞬间就把状态改成 1其他客人同时查房看到的要么是“还没入住”要么是“已入住”没有“正在写入”这种状态。原子指令提供的原力就是这一步“同时读和改”的真实性。但要注意原子指令只是保证单点操作的原子性它不保证多个原子操作之间按照你希望的顺序被感知也不解决“同一个线程重复进入同一把锁”的逻辑问题。这次事故恰恰栽在后一项。2.2 用 LL/SC 写一个自旋锁的常规姿势自旋锁的经典实现是用原子操作把锁变量从 0 换成 1成功则进入临界区失败则继续重读重试。在 LA664 上常见做法是用 LL/SC 指令自己拼static inline void spin_lock(int *lock) { while (1) { int old; asm volatile ( ll.w %0, %1, 0\n // 读取锁值并建立监视 bnez %0, 2f\n // 如果已经有人持锁跳去等待 ori %0, $zero, 1\n sc.w %0, %1, 0\n // 尝试写入 1 beqz %0, 1f\n // 如果写入失败重新抢锁 j 3f\n // 成功进入临界区 1: j 4f\n 2: nop\n 4: : r(old) : r(lock) : memory); // 若失败短暂停顿后继续 } }这段汇编里有一个关键点ll.w会在当前 CPU 核上建立一个对目标地址的监视当且仅当该地址的缓存行在sc.w执行前没有被其他核写入时sc.w才会成功并返回 1如果期间发生了任何冲突写sc.w会返回 0需要重新来一轮。这样可以避免传统 test-and-set 带来的总线风暴在锁竞争不太激烈时效率非常高。但在编写自旋锁时还有个容易被忽略的细节内存序。LL/SC 只保证锁变量本身的读取和写入是原子的不会自动帮你把临界区里的普通读写也“锁住”。获取锁之后临界区内对共享数据的读写必须在这个锁的“临界点”之后对其他核可见释放锁之前临界区的写操作必须先于“把锁置 0”这一操作被其他核感知。2.3 内存序原子指令没帮你解决的另一个问题LoongArch 的内存模型属于弱内存序这意味着普通 load/store 指令之间可以被 CPU 乱序执行只要单线程语义一致。多核环境下一个核写了变量 A另一个核如果不加屏障过多久能看到 A 的新值并没有严格保证。具体到自旋锁上即使sc.w把锁从 1 写成 0被释放的内存操作如果没有配合dbar这样的屏障另一个核可能看到锁已经是 0但临界区内的数据还是旧值这就可能造成新的丢失更新。更典型的是获取锁的一方如果不在进入临界区后加读屏障临界区内的 load 可能被 CPU 提前执行读到临界区之外的脏数据。所以完整的自旋锁实现获取锁后要加dbar 0保证后续读写不会越过加锁点释放锁之前也要加dbar 0保证之前临界区里的写操作对别人可见再执行把锁置 0 的操作。否则即使原子指令完全正确内存序这一关也会让你在弱内存模型上翻车。这次事故虽然根因不是内存序但在排查过程中我们也把这块检查了个遍顺手补上了所有缺失的屏障。3. 那个被“打包”出来的死循环3.1 gdb 调用栈里出现了两个 lock当时用gdb挂到那个 100% CPU 的线程上bt打出来的栈如下简化版#0 spin_lock (lock0x7f...) at spinlock.h:68 #1 StatsUpdater::Update (this0x..., delta1) at stats_updater.cpp:120 #2 StatsUpdater::FlushToDB (this0x...) at stats_updater.cpp:301 #3 WorkerThread::Run (this0x...) at worker.cpp:88这里最扎眼的是spin_lock在栈里出现了一次而它的调用者StatsUpdater::Update本身也声明为“调用前必须持锁”。结果代码里FlushToDB又调用了StatsUpdater::Update于是同一个线程在已经持有锁的情况下又进入了一次spin_lock。这在普通互斥锁比如 pthread mutex上一不小心也会发生但 pthread mutex 至少会立刻报出死锁错误或允许你设置递归属性。自旋锁则完全是另一个物种它没有所有者记录只有一个整型变量 0 和 1。线程 A 第一次获取锁后锁值为 1当它第二次执行spin_lock时无论怎么自旋LL 读到的都是 1SC 永远不会成功于是就在这个循环里原地打转。用大白话说这就好比一个人已经走进了房间锁上了门转身又刷卡想再进一次房间而他手里的卡永远刷不开一扇已经被自己锁住的门。门没有“当前持有者是谁”的记忆它只知道自己现在是锁着的。3.2 重入同一个非重入锁自旋锁最经典的死法自旋锁死循环不稀奇稀奇的是它在“业务上看起来一切正常”的路径里发生。我们的StatsUpdater::Update函数开头有一行spin_lock(global_stats_lock);这个函数内部又会调用一个辅助函数AddDelta而AddDelta里也带了一把同样的锁。第一个版本里AddDelta是一段独立的公共函数被很多地方调用所以设计成自己加锁后来重构时Update为了多做一些批量判断直接调用了AddDelta却没意识到锁已经在Update这层加过了。两层锁用的都是同一个全局锁变量。外层把锁置 1 后内层再进来时就死锁。这个场景放在自旋锁上尤其致命普通线程互斥锁在内层尝试加锁时因为得不到锁线程进入睡眠操作系统还能让出 CPU系统只是被卡住但不会 100% 爆核。自旋锁不会睡眠它会疯狂重试 LL/SC把一个核完整地烧在空转上。这也就是标题里“打包死循环”的来历抢锁逻辑本身是一个小循环被错误地“打包”进另一个持锁函数后小循环就变成了一个永远不加检出的死循环。死循环里的原子指令ll.w每执行一次都会去访存CPU 的取指、访存、比较、分支全在这几十字节的圈里打转。perf能看到这一个函数占了几十亿个周期。3.3 死循环如何把丢失更新变成现实死循环和丢失更新之间还差一步因为死循环线程霸占了一个 CPU 核并长时间持有锁其他需要使用统计锁的线程全部阻塞。生产环境的统计服务是 8 线程模型死循环线程占住锁另外 5 个业务线程全部卡在抢锁上只剩 2 个能继续跑。但业务要求统计值每 N 条记录落一次库落库线程发现心跳线程已经超过阈值没更新上下文就触发了降级补偿逻辑直接从消息队列重新拉最近一段时间的条目强制刷新内存中的全局计数器。这个补偿逻辑因为担心“锁被卡死导致永远等不到”故意没有走加锁路径直接普通写计数。于是问题出现了在死循环线程占锁期间有几个业务线程本来已经成功读到了旧计数并算了新值但它们还没来得及写回就被卡住补偿线程直接用“从队列重放的更准确值”覆盖了计数器。可重放值不完整覆盖之后就把原本几个线程准备提交的新值全部冲掉。从指标看就是计数少了且每次死循环出现时必丢一段数据。如果没有死循环这个补偿逻辑永远不会触发丢失更新也不会发生。所以根因还是自旋锁重入导致的死循环但表象却是原子指令、锁、覆盖更新三者搅在一起。4. 修复、预防与排查清单4.1 最小改动修复可重入锁与递归深化第一版修复很简单去掉Update函数里多余的spin_lock只让最外层的调用方加锁内部函数不再重复加锁。这种方案适合锁层次固定、调用关系简单的小项目。但如果像我们这个服务一样同一套更新函数会被多个公开接口调用无法保证每个入口都统一先加锁那就老老实实把自旋锁换成可重入锁。可重入锁需要在锁结构里多记录两个字段持有者线程 ID 和获得次数。每次加锁时先判断holder 当前线程如果相等直接把次数加一不做 LL/SC否则才去执行真正的原子抢锁。释放时减次数减到零才把锁变量置 0。需要注意可重入锁的实现必须保证“记录持有者”这一步本身是线程安全的。LA664 上可以用 LL/SC 或者 AMO 指令把holder 和 count放在同一个结构体里做成无锁更新。我们后来用的是一个 64 位结构体高 32 位存线程 ID低 32 位存重入计数一次cas搞定避免在锁结构内部再引入另一把锁。用这种方式修复后同样的嵌套调用不会再自锁线程 100% CPU 的现象消失统计值也不再丢失。但我在复盘时更在意的是为什么加了锁之后还会丢数据因为补偿逻辑也是一种“绕过锁写共享数据”的行为这种保护性代码在系统异常时虽然可以救急但必须保证它和加锁路径之间也遵循同一个同步协议否则丢失更新的锅最后还是落在锁头上。4.2 排查原子锁问题的四个工具法这次排查踩了不少坑总结四个排查方法以后遇到类似问题可以少走弯路。第一招用gdb看线程栈重点看同一个自旋锁函数是否重复出现。gdb attach后用thread apply all bt noalias把所有线程栈拉出来如果一个锁函数出现在连续两个栈帧里基本可以断定是重入自死锁。这个特征比什么perf分析都直接。第二招用perf top或perf record看热点指令。LA664 上如果热点停在ll.w对应的等待跳转说明线程在自旋要是热点停在一个普通ld指令上可能只是读循环还没进入原子更新。可以用perf annotate放大单指令周期数判断是不是锁重试。第三招检查锁变量的值分布。写一个小工具每隔 100ms 采样打印锁变量的值和当前持锁线程 PID再看采样期间锁变量是否一直是 1 且 PID 始终相同。如果一直相同就是一个持锁线程在里面空转说明不是外部竞争而是内部死循环。第四招临时打开编译器内置检测。在 LA664 的工具链里用-fsanitizethread虽然不一定能直接报重入但可以通过lockdep或者 AdTSan 之类的辅助工具追踪锁序也能识别出重复加锁点。我们当时手动加了一个spin_lock_owner的调试字段打印获得锁的 PC 值定位到具体函数之后一把就修完了。4.3 经验清单LA664 并发编程避坑指南复盘之后我给自己列了几条规则建议在 LA664 或其他弱内存序 CPU 上做并发编程时放在心里。尽量少自己写汇编自旋锁优先用工具链提供的__atomic内置函数。GCC 针对 LoongArch 提供了一套和平台无关的原子操作抽象性能虽然不一定能达到手写汇编的上限但语义有保障不容易出现边界遗漏。比如释放锁时用一个带__ATOMIC_RELEASE的 store获取锁时用__ATOMIC_ACQUIRE的 load比裸写sc.w稳妥得多。不要在设计公共函数时内嵌“自动加锁”逻辑然后在另一个同样加锁的函数里去调用它。这在任何自旋锁平台都会埋雷只是 LA664 的弱序让踩雷后的表现更极端。公共函数应该遵循“加锁由调用者决定”或者“函数内部加锁、函数命名直接标注”的单一约定。原子指令不是万能的它只解决“单点更新不可分割”不解决“临界区嵌套”和“可见性顺序”。使用原子指令时要同时把内存屏障放进设计里。例如写一个无锁队列时入队侧必须在追加数据后加 release 屏障这与锁变量释放后的dbar是一个道理。再啰嗦一句关于 LA664 的 AMO 指令。如果你用amadd.w做计数器自增它天然就是原子的不需要锁但如果你要基于“加完后的值”做某种判断就得考虑返回旧值和新值之间的语义。AMO 内部保证单个原子操作的一致性但多个操作之间的顺序还是要靠你的代码逻辑去约束。这次事故让我重新认识了一个简单道理并发 bug 从来不是“某个地方用错了指令”而是多个层次上的默认假设错了。原子指令解决不了重入问题弱内存序需要屏障锁设计需要可重入或者明确调用边界补偿机制也不能绕过同步。任何一个假设和实现不匹配最后都会发生在计数少了几千条这种看似微小的指标异常上。排查时把手里的工具用熟先把栈看清楚再谈优化指令往往比对着汇编猜半天效率高得多。