news 2026/10/4 22:51:38

多核编程中统计值无故丢失?从原子指令失败到缓存行颠簸的排查实录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多核编程中统计值无故丢失?从原子指令失败到缓存行颠簸的排查实录

如果你问我多核编程里最讨厌的 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 架构,它提供了两类典型的原子原语。一类是 AMO(Atomic 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 失败率可能是几十万分之一,在自旋锁疯狂冲击下能飙到几千分之一甚至几百分之一。虽然我已经修了宏的重试逻辑,但每次失败都要重新执行一遍,这会拉长热路径,还会反过来加剧缓存行竞争,于是"偶尔丢"变成了"还是丢"。

这是一场连锁反应:

  1. 编译器把一个共享标志"打包"成寄存器常量 → 死循环;
  2. 死循环持锁不放 → 其他核在锁上自旋;
  3. 自旋抢锁剧烈访问同一个缓存行 → 统计变量所在缓存行被持续颠簸;
  4. 统计更新的 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 干净得多:

方案指令数失败重试建议场景
AMO(amadd.w)1无,天然原子纯累加、交换、CAS 等固定模式
LL/SC(ll.w + sc.w)2+可能失败,必须重试自定义复合原子操作
手写宏且失败不重试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 我会记住的排查顺序

这次排障给我最大的体会是:表面上的"丢失更新"不一定是单一原因造成的,而是一条因果链的最终表现。我的排查顺序也值得分享一下,适合大多数并发更新丢数据的情况:

  1. 先看写入路径是否全部收口。确认没有遗漏的赋值点,排除最简单的读-改-写竞态。
  2. 上锁验证。如果加互斥锁之后依然丢,基本可以把普通并发竞态排除,把注意力转向原子操作层面。
  3. 反汇编,确认原子操作的真实实现。不要想当然认为"原子操作不会丢",要看它用的是 AMO 还是 LL/SC,代码里是否处理了失败重试。
  4. 查编译器优化导致的死循环。重点查看perf热点和 CPU 占用,凡是有核一直 100% 趴在某个函数上,优先怀疑共享变量被优化进寄存器。
  5. 检查缓存行布局。统计变量和自旋锁挤在同一个结构体里、落在同一根缓存行,是典型的放大因子。

如果能早一点把这几步走完,至少能省三天折腾。我当时就是太迷信"原子指令不会丢"这个直觉,在第一层根因上反复打转,忽略了它背后的失败路径。

6.2 一条一条的避坑清单

把这次踩过的坑整理成清单,方便后续排查时直接对照:

  • 手写 LL/SC 宏必须写失败重试,而且重试标签必须放在ll.w之前,不能放在计算指令之后,否则要么丢失更新,要么重复累加;
  • 纯累加、自增、自减这类固定模式优先用 AMO 单指令,让硬件保证原子性,不给程序员留犯错空间;
  • 被中断或其他执行流修改的共享标志,必须用READ_ONCE/WRITE_ONCE或者原子类型,避免编译器优化成寄存器常量死循环;
  • 自旋锁、统计变量、频繁更新的原子变量不要放在同一个缓存行里,必要时显式填充对齐,切断伪共享;
  • 在弱内存序处理器上,跨核共享数据的可见性还需要显式内存屏障,别只靠原子指令本身。

回到这次事件的本质:一颗 CPU 的原子指令本身没有骗我,它老老实实地在缓存一致性协议的支持下执行了读-改-写;真正骗我的是那段"失败即跳过"的手写宏,和那个被编译器"打包"成常量、把整个核困住的死循环。两者单拎出来都足够隐蔽,合在一起又通过缓存行颠簸互相放大,才酿成了这场丢掉几千个数据包还让人抓狂的诡异事故。排查并发问题就是这样,别急着相信结论,顺着代码、汇编、缓存三个层面一层层往下挖,总会撞见真凶。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 22:51:18

VSCode搭建C/C++环境全指南:编译器配置、JSON文件与多文件构建

简介:面向需要在 Visual Studio Code 中搭建 C/C 开发环境的初学者与进阶开发者,这份资源包针对编译器路径配置、调试器接入、插件设置等常见痛点,提供可直接参考的配置方案与示例代码。压缩包共 25 个文件,主要包含 9 个 json 配…

作者头像 李华
网站建设 2026/10/4 22:24:45

CodeX+DeepSeekFlash:自然语言生成硬件原理图与PCB

1. 项目概述:当代码生成模型真正“看懂”硬件设计最近在电子工程师圈子里,一个标题被反复截图转发:“CodeXDeepSeekFlash也能画原理图与PCB了(非GPT-6)ESP32 S3 最小系统板”。我第一次看到时也愣了一下——不是因为技…

作者头像 李华
网站建设 2026/10/4 22:08:35

FPGA千兆以太网UDP通信实现:从RGMII时序到Wireshark抓包实战

1. 为什么大家都卡在“千兆以太网”这一步FPGA 开发做到一定阶段,你会发现十个项目里有七八个绕不开网络通信。无论是做高速 ADC 采样后的数据上传、图像采集与实时处理,还是搭建一个自定义协议的数据通路,最终都要靠以太网把数据从板卡上弄出…

作者头像 李华
网站建设 2026/10/4 22:03:16

C# Winform Socket通信实战:TcpListener多客户端接入与心跳断线重连

简介:一个基于C# Winform的套接字通信完整项目,面向初学网络编程与多线程开发的读者,重点演示服务端与多个客户端同时连接的处理方式。压缩包内共59个文件,主要由C#源码、窗体界面文件、工程配置文件以及可执行程序构成&#xff0…

作者头像 李华