
如果你问我多核编程里最讨厌的 Bug 长什么样我第一个想到的一定是这种统计值会丢但代码看起来全对。单核跑稳如老狗一上四核就开始克扣你给共享变量上了锁它照丢你换成原子操作它还丢。最近我在一块 LA664 内核的开发板上排查一个网络报文字节统计丢失更新的问题从表面看像经典的并发读写竞态最后挖出来的却是两颗雷一颗埋在 CPU 原子指令的失败重试路径里另一颗是被编译器优化打包进寄存器里的死循环。这篇文章就把整个排查过程、背后的指令行为、以及最后三层修复方案完整写出来希望能帮到正在被丢失更新折磨的人。1. 现象四核压力下统计值开始克扣1.1 第一次复现不是偶尔丢是一跑满就丢事情要从一块采用 LA664 内核的四核板子说起上面跑的是一个定制 Linux业务是网络报文统计。每收一个包驱动里就要更新两个计数器一个是包数量pkt_cnt一个是字节数byte_cnt。这两个计数存在一个全局结构体里由一个独立的统计导出线程周期性把值写到共享内存供上层业务读取。问题很明确用打流工具跑满四核导出的统计值总是比实际值少跑得越久丢得越多。最典型的一次是连续打流 10 分钟实际发包 5800 万个统计导出值却只有 5798 万多个少了差不多 1500 个。更诡异的是单核压测 24 小时一条不少双核偶发丢几个四核跑 5 分钟以上必现核数越多丢得越狠。一开始团队里所有人都认为是经典的读-改-写竞态收包路径里某个地方做了tmp pkt_cnt; tmp 1; pkt_cnt tmp;这种三步操作两个核同时读到旧值各自加一其中一个更新被覆盖。这种丢更新在统计型代码里太常见了我当时也这么判断决定先加锁压住再说。1.2 加锁之后继续丢问题开始不正经了我在两个计数器的更新路径上都加了自旋锁把读-改-写整体包进临界区。按道理说同一时刻只有一个核能改计数器别的核必须等我写回去才能进来丢失更新应该彻底消失。结果跑了一轮测试统计值还是丢。丢得没有之前多但依然稳定复现。这就非常不对劲了。锁保证了互斥计数器就不会因为普通读改写互相覆盖。剩下的可能性只有几个更新点的代码路径里除了我看漏的地方还有别的写入点锁本身在多核下没起作用比如锁变量被编译器优化出问题计数器更新的真正实现用的是原子指令而原子指令在某些场景下看起来成功了、实际没落上。我把所有写入点翻出来逐个审计确认没有遗漏的赋值路径。锁也单独写了小实验验证在用户态起一个多线程程序对同一变量反复自增在锁保护下数量完全正确。那问题只能落在第三点上——原子指令本身。这个判断当时说服力不强因为原子指令在绝大多数情况下不会让你丢数据。但结合四核必现这个规律我开始怀疑是 LA664 的原子指令实现路径里有猫腻于是决定把更新函数拉出来看汇编。2. 单步走进汇编LA664 原子指令的语义与使用误区2.1 原子指令不是锁是一次有条件的写要聊清楚这个问题先得说一个容易被忽略的事实通用处理器上的原子操作并不是靠一道魔法锁把内存圈起来而是靠缓存一致性协议在背后打工。LA664 内核属于 LoongArch 架构它提供了两类典型的原子原语。一类是 AMOAtomic Memory Operation指令比如amadd.w、amswap.w、amcas.w这些指令在单条指令内部完成读-改-写处理器会保证这条指令在整个缓存一致性域内是原子的对程序员而言最省心。另一类是 LL/SC 对也就是ll.w和sc.w两条指令配合使用实现更灵活的复合原子操作。LL/SC 的逻辑可以通俗理解为ll.w去内存里取一个值同时处理器悄悄记下这个缓存行我盯上了之后代码对这个值做任意计算最后sc.w尝试把新值写回去此刻处理器会检查自己盯上的那个缓存行是否还是原样。如果期间有其他核碰过这个缓存行哪怕只是读了一下同一根缓存线sc.w就会失败返回值变成非零表示你写不进去需要重新来一遍。这套机制的本质是原子性不是锁出来的而是靠监听失效来保证的。它的优点是灵活可以实现任意复杂的读-改-写算法缺点是它天生就会失败而且失败的概率跟竞争程度正相关。如果你写了 LL/SC 的代码却完全不处理失败分支那原子更新丢失就不是玄学而是数学上必然会发生的事。2.2 问题宏的第一次暴露SC 失败后竟然直接跳过我在旧代码仓库里找到了统计更新的实现发现它既不是atomic_fetch_add也不是 AMO 指令而是一个从别的平台项目里直接复制过来的内联汇编宏核心内容长这样#define STAT_ADD(ptr, delta) \ do { \ unsigned long tmp; \ asm volatile( \ ll.w %0, %1, 0 \n /* 读取旧值到 tmp */ \ add.w %0, %0, %2 \n /* tmp 旧值 delta */ \ sc.w %0, %1, 0 \n /* 尝试把 tmp 写回 */ \ bnez %0, 1f \n /* 如果 SC 失败跳到 1 标签 */ \ b 2f \n /* 成功则跳到结束 */ \ 1: \n \ 2: \n \ : r(tmp), m(*(ptr)) \ : r(delta) \ : memory); \ } while (0)看明白问题了吗sc.w成功时会把tmp清成 0失败时置为非零。这个宏的逻辑是失败就跳到1:标签但1:标签后面没有任何重试代码紧接着就是2:结束。也就是说只要sc.w发生一次失败这次累加就被静默跳过了——计数器不增、报错没有、日志不打数据就凭空蒸发。这种失败即丢的写法在低竞争场景下很难暴露单核执行时 LL/SC 基本不会失败双核偶发四核竞争激烈时失败概率急剧上升于是呈现出核越多丢得越多的规律。这完美解释了最开始的现象。第一层根因找到了。我当时的第一反应是改掉这个宏把失败分支改成重试。但直觉告诉我事情没这么简单——因为四核场景下 LL/SC 的失败率虽然会上升但标称也就几十万分之一这个量级绝对不至于 10 分钟丢 1500 个包那么多。也就是说失败率被人为放大了系统里一定还藏着另一个因素在持续制造缓存行扰动。3. 藏得更深的打包死循环编译器优化把共享标志变成了寄存器常量3.1 又是 perf 救了我一个核趴在空转循环里修好宏的重试逻辑之后我又跑了一轮测试情况有所改善但依然偶发丢失。频率低了一些可现象没断根。这时候光靠看代码已经无法定位了我上了perf抓 CPU 采样。采集结果很扎眼core2的 CPU 占用率几乎 100%采样热点集中在一个叫wait_dma的函数上。这个函数是等硬件 DMA 完成的中断驱动等待逻辑逻辑本身只有一句话static int dma_done; void irq_handler(void) { dma_done 1; } void wait_dma(void) { while (dma_done 0) { /* 等中断把 dma_done 置 1 */ } }正常情况下这个函数应该在中断回调执行后立刻退出。但core2上的线程却在里面一动不动而且 CPU 时间被吃满。我一开始怀疑是中断没触发或者中断服务程序没执行。加了一堆调试进去才发现irq_handler确实执行了dma_done在内存里也确实变成了 1但wait_dma里的循环就是不退出。更诡异的是在循环外面打印dma_done的值为 1循环条件判断时它却像永远等于 0。3.2 编译器优化如何把变量打包进寄存器答案在编译器的优化行为里。dma_done被声明成普通int读写它的两处代码——中断服务程序的写和wait_dma的读——之间没有告诉编译器这个变量会被并发访问。我当时的编译参数是-O2编译器看到while (dma_done 0)这个循环体内不修改dma_done也没有任何内存屏障就聪明地做了一次优化把dma_done的当前值加载进一个寄存器循环条件判断反复使用寄存器里的这个旧值不再每次去访问内存。用我们团队的话说这个变量被打包进了寄存器。内存里的dma_done哪怕被中断服务程序写成了 1也影响不到寄存器里那个已经缓存的值循环永远判断它为 0于是这里就形成了一颗真正意义上的死循环。这就是标题里说的打包死循环——不是业务逻辑写错导致的死循环而是编译器在-O2优化下把共享状态打包成了寄存器常量循环跳出条件被彻底锁死。修复方式不止一种我后来用了内核推荐的写法static int dma_done; void irq_handler(void) { WRITE_ONCE(dma_done, 1); } void wait_dma(void) { while (!READ_ONCE(dma_done)) { cpu_relax(); } }WRITE_ONCE和READ_ONCE的作用就是每次访问都强制走内存破除编译器的寄存器缓存优化。如果你不在 Linux 内核里把dma_done声明成volatile int也行但要清楚 volatile 只解决别给我缓存到寄存器这个层面不解决多核之间的内存可见性问题严格场景下还是用原子 API 更稳。3.3 死循环如何通过缓存行颠簸把原子更新逼丢找到这条死循环之后最核心的问题还没解答它和丢失更新到底有什么关系关系是通过缓存行建立起来的。我检查wait_dma所在线程的调用栈发现它在进入死循环之前先拿了一把全局自旋锁stats_lock用来保护统计导出。死循环发生在持锁状态中这把锁永远不会释放。于是其他三个核上所有想要读取统计值、更新统计值的线程全都会在尝试获取stats_lock时自旋等待。自旋等待的实现通常是atomic_try_cmpxchg或者atomic_cmpxchg一类的原子指令循环。三个核同时在一个锁变量上反复执行原子读改写这个锁变量所在的缓存行就开始了剧烈的三核乒乓——缓存行在核间高速转移每个核拿到缓存行所有权后马上被另一个核失效掉再次拿回来再次失效循环往复。问题在于我前面统计计数器pkt_cnt和byte_cnt的定义恰好跟stats_lock挨在一起struct { unsigned long pkt_cnt; unsigned long byte_cnt; spinlock_t stats_lock; } stats;这段结构体一共不到 32 字节百分之百落在同一个缓存行里。三个核疯狂抢stats_lock导致整个缓存行像烫手山芋一样弹来弹去。统计更新路径用手写的 LL/SC 宏去更新pkt_cnt它的ll.w刚建立监听缓存行就被抢锁的原子操作抢走了sc.w立刻失败。而且缓存行颠簸越猛烈失败概率越高。正常的 LL/SC 失败率可能是几十万分之一在自旋锁疯狂冲击下能飙到几千分之一甚至几百分之一。虽然我已经修了宏的重试逻辑但每次失败都要重新执行一遍这会拉长热路径还会反过来加剧缓存行竞争于是偶尔丢变成了还是丢。这是一场连锁反应编译器把一个共享标志打包成寄存器常量 → 死循环死循环持锁不放 → 其他核在锁上自旋自旋抢锁剧烈访问同一个缓存行 → 统计变量所在缓存行被持续颠簸统计更新的 LL/SC 高概率失败 → 加上原本就不严谨的失败处理 → 丢失更新。如果最开始只修宏的重试分支最多只能缓解症状如果只修死循环表面空转消失、但统计宏的失败分支依然是雷如果只拆缓存行死循环还是会占满一个核、拖垮性能。这三层必须联动处理才能把问题真正按死。4. 修复落地三层问题三个补丁4.1 补丁一把宏重写成失败必重试或用单条 AMO第一件事把统计宏的失败分支彻底重写。正确版本应该是这样#define STAT_ADD(ptr, delta) \ do { \ unsigned long tmp; \ asm volatile( \ 1: \n \ ll.w %0, %1, 0 \n /* 重新读取旧值 */ \ add.w %0, %0, %2 \n /* 累加 delta */ \ sc.w %0, %1, 0 \n /* 尝试写回成功则 tmp 0 */ \ bnez %0, 1b \n /* 失败时跳回 1 重新来 */ \ : r(tmp), m(*(ptr)) \ : r(delta) \ : memory); \ } while (0)这版的语义就对了SC 失败的唯一出路是回到ll.w重新读取最新值再尝试写入。除此之外热路径上的更新操作能换单条 AMO 指令尽量换单条 AMO比如amadd.w一条指令完成整个读-改-写没有失败重试的概念也不存在SC 失败后怎么办的问题。对于纯累加这种场景AMO 比 LL/SC 干净得多方案指令数失败重试建议场景AMOamadd.w1无天然原子纯累加、交换、CAS 等固定模式LL/SCll.w sc.w2可能失败必须重试自定义复合原子操作手写宏且失败不重试2失败即丢弃永远不要这么写修完这个宏热路径的原子语义才算真正正确。4.2 补丁二给共享标志补上原子/易变语义第二件事处理dma_done的编译器优化问题。最省事的做法是改成atomic_t类型但在这种纯标志位场景我更推荐代码里明确使用READ_ONCE和WRITE_ONCE因为意图更直白告诉编译器和读代码的人这行代码访问的是一个会被并发修改的共享变量。static int dma_done; void irq_handler(void) { WRITE_ONCE(dma_done, 1); } void wait_dma(void) { while (!READ_ONCE(dma_done)) { cpu_relax(); } }这里再强调一句volatile不等于原子也不等于多核安全。它只能防止编译器把变量打包进寄存器解决不了 CPU 缓存一致性和内存序问题。不过对于中断里写、线程里读这种标志位场景配合内存屏障后基本就够用了。更保险的做法是全部改用atomic_set/atomic_read养成好习惯。4.3 补丁三拆掉统计变量和锁之间的伪共享第三件事解决缓存行颠簸的放大效应。把统计计数器和锁拆分到不同的缓存行甚至直接把热更新数据改成 per-CPU 结构避免核间共享。我在实际修复中采用了分层方案每个 CPU 维护一份per_cpu_stats收包时只更新自己那份导出线程再按 CPU 逐项累加求和。这样热路径上各核完全独立连缓存行共享都消除了。struct per_cpu_stats { unsigned long pkt_cnt; unsigned long byte_cnt; /* 填充到 64 字节对齐避免与相邻字段伪共享 */ unsigned long pad[8]; }; static struct per_cpu_stats __percpu *cpu_stats; void acc_pkt(unsigned len) { struct per_cpu_stats *s this_cpu_ptr(cpu_stats); s-pkt_cnt; s-byte_cnt len; }这个补丁的效果立竿见影统计更新的 LL/SC 再也不用跟锁变量抢同一个缓存行SC 失败率立刻降到极低。而且性能还涨了一截——之前三核在锁上疯狂乒乓导致的总线流量消失了缓存局部性大幅改善。5. 压测验证与数据对比修复全部落地后我把板子拉起来又跑了一轮完整的对比测试。打流工具同样跑四路满带宽每次持续 2 小时记录统计导出值和实际发包数的误差。结果很清楚场景统计误差打流速率core2 CPU 占用修复前10 分钟丢约 150092 万 pps100%只修宏重试2 小时丢约 395 万 pps100%完整三层修复098 万 pps约 12%只修宏重试那一列的对比特别有意思说明我当时对第二层、第三层病因的判断是对的——宏的重试逻辑修复后丢失频率下降了三个数量级但依然没有归零因为死循环造成的缓存行颠簸还在持续制造 SC 失败。直到把死循环和伪共享一起解决统计误差才真正变成 0。CPU 占用的变化也印证了死循环的存在修复前core2一直在空转25% 的算力白白烧掉修复后整个系统四核都恢复常态导出线程正常睡眠、唤醒再也不会有一个核趴在wait_dma里不出来。打流速率从 92 万 pps 提到 98 万 pps也说明缓存行颠簸消除后收包路径的整体效率上来了。6. 复盘丢失更新类 Bug 的排查路径和避坑清单6.1 我会记住的排查顺序这次排障给我最大的体会是表面上的丢失更新不一定是单一原因造成的而是一条因果链的最终表现。我的排查顺序也值得分享一下适合大多数并发更新丢数据的情况先看写入路径是否全部收口。确认没有遗漏的赋值点排除最简单的读-改-写竞态。上锁验证。如果加互斥锁之后依然丢基本可以把普通并发竞态排除把注意力转向原子操作层面。反汇编确认原子操作的真实实现。不要想当然认为原子操作不会丢要看它用的是 AMO 还是 LL/SC代码里是否处理了失败重试。查编译器优化导致的死循环。重点查看perf热点和 CPU 占用凡是有核一直 100% 趴在某个函数上优先怀疑共享变量被优化进寄存器。检查缓存行布局。统计变量和自旋锁挤在同一个结构体里、落在同一根缓存行是典型的放大因子。如果能早一点把这几步走完至少能省三天折腾。我当时就是太迷信原子指令不会丢这个直觉在第一层根因上反复打转忽略了它背后的失败路径。6.2 一条一条的避坑清单把这次踩过的坑整理成清单方便后续排查时直接对照手写 LL/SC 宏必须写失败重试而且重试标签必须放在ll.w之前不能放在计算指令之后否则要么丢失更新要么重复累加纯累加、自增、自减这类固定模式优先用 AMO 单指令让硬件保证原子性不给程序员留犯错空间被中断或其他执行流修改的共享标志必须用READ_ONCE/WRITE_ONCE或者原子类型避免编译器优化成寄存器常量死循环自旋锁、统计变量、频繁更新的原子变量不要放在同一个缓存行里必要时显式填充对齐切断伪共享在弱内存序处理器上跨核共享数据的可见性还需要显式内存屏障别只靠原子指令本身。回到这次事件的本质一颗 CPU 的原子指令本身没有骗我它老老实实地在缓存一致性协议的支持下执行了读-改-写真正骗我的是那段失败即跳过的手写宏和那个被编译器打包成常量、把整个核困住的死循环。两者单拎出来都足够隐蔽合在一起又通过缓存行颠簸互相放大才酿成了这场丢掉几千个数据包还让人抓狂的诡异事故。排查并发问题就是这样别急着相信结论顺着代码、汇编、缓存三个层面一层层往下挖总会撞见真凶。