LoongArch64弱内存模型踩坑:relaxed原子操作导致打包死循环
发布时间:2026/10/1 20:17:35 锦皓数字建站

1. 从一次诡异的打包卡死说起打包机房里那台 LA664 跑构建任务平时十几分钟就能出包那天下午突然卡在链接阶段不动了。top 一看某个编译进程 CPU 占用 100%但进度条纹丝不动日志停在最后一行再也不刷新。第一反应是死锁gdb attach 上去看栈发现线程卡在一个自旋等待的循环里循环体里读一个共享标志位读到的值永远是旧值——明明另一个线程已经把它改了。这个现象在 LoongArch64 平台上并不算特别罕见但每次遇到都让人头皮发麻因为它不像普通的逻辑 bug 那样能靠加日志定位。你加日志日志本身可能改变时序问题就消失了你不加日志它又稳定复现。更麻烦的是这类问题往往只在多核高并发场景下出现单核跑或者降低并发度就没事很容易被误判成偶发的环境问题而放过。LA664 是龙芯 3A6000 系列采用的处理器核心基于 LoongArch64 架构支持 LASX 向量扩展。它的内存模型、原子指令语义、缓存一致性协议和 x86、ARM 都有差异。很多从 x86 迁移过来的代码在 x86 上跑得好好的到了 LoongArch64 上就暴露出内存序问题。这次的事件根子就在一条原子指令的语义理解上——代码里用了一条看起来应该没问题的原子操作但在 LA664 上它并不提供代码作者以为的那种同步保证。我把这次排查的完整过程整理出来包括怎么从现象定位到原子指令、怎么理解 LoongArch64 的内存模型、怎么用正确的屏障和原子原语重写那段代码。如果你正在做 LoongArch64 平台的移植、调优或者单纯对多核同步感兴趣这篇应该能帮你少踩几个坑。2. 死循环现场还原与初步定位2.1 卡死进程的现场快照先还原一下当时的现场。出问题的进程是一个多线程的打包工具主线程负责调度若干工作线程并行处理文件。卡死的是其中一个工作线程它的任务循环大致是这样的逻辑从任务队列取任务处理处理完后设置一个完成标志然后等待主线程回收。用 gdb attach 上去之后thread apply all bt看到的情况是主线程阻塞在pthread_cond_wait工作线程卡在一个while循环里。反汇编那段循环核心就三条指令load、compare、branch。load 读的是一个全局变量compare 判断它是否等于期望值不等就跳回去继续 load。问题在于那个全局变量在另一个线程里已经被写成了期望值但工作线程读到的始终是旧值。这不是缓存没刷新那么简单——LoongArch64 的缓存是一致的不存在 x86 早期那种需要手动 flush 的情况。真正的问题是写操作对读线程不可见或者说读线程看到的写顺序和实际发生的顺序不一致。2.2 为什么单核测试永远复现不了这类问题有个很讨厌的特性单核跑、低并发跑、加 sleep 跑都正常。因为单核情况下线程切换本身就隐含了完整的内存屏障效果所有写操作在切换前都已经对后续代码可见。低并发情况下两个线程碰巧落在同一个核上执行的概率高同样被线程切换的屏障效应掩盖了。只有当两个线程真正并行跑在不同物理核上且读写时序恰好落在某个窗口内问题才会暴露。这也是为什么很多团队在 x86 开发机上测了几百遍都没事一上 LoongArch64 多路服务器就翻车。LA664 的乱序执行窗口、store buffer 深度、缓存一致性协议的实现细节和 x86 不一样同样的代码在两种架构上的可见性行为可能完全不同。提示遇到单核正常、多核偶发的同步问题第一优先级不是加日志而是审查代码里所有跨线程共享变量的访问是否用了正确的原子原语和内存屏障。日志会改变时序往往让问题更难定位。2.3 从现象反推丢失更新还是可见性问题丢失更新这个词容易让人误解。严格来说丢失更新lost update指的是两个写操作互相覆盖最终只保留了一个。但这次的现象是写操作确实发生了内存里最终的值也是对的只是读线程在某个时间窗口内读到了旧值然后基于旧值做了错误判断进入了死循环。这更准确地说是可见性问题或者叫内存序问题。写线程执行了 store但 store 还在 store buffer 里没提交到缓存一致性域读线程执行 load从自己的缓存里读到了旧值。两个操作在时间上重叠但读线程没有等待写线程的 store 变得可见。要解决这个问题要么让写线程在 store 之后插入释放屏障要么让读线程在 load 之前插入获取屏障要么两者都加。具体加哪个、加什么强度的屏障取决于 LoongArch64 的内存模型和那条原子指令的实际语义。3. LoongArch64 内存模型与原子指令语义3.1 LoongArch64 是弱内存模型LoongArch64 采用的是弱内存模型weak memory model这一点和 ARM、RISC-V 类似和 x86 的强内存模型TSO不同。在 x86 上普通的 load 和 store 基本上就带有 acquire 和 release 语义你写flag 1然后另一个线程读flag大概率能读到新值。但在弱内存模型下硬件允许对普通 load 和 store 进行重排序只要不违反单线程内的数据依赖。具体来说LoongArch64 允许以下重排序store 之后的 load 可以提前到 store 之前执行不同地址的 store 之间可以重排序不同地址的 load 之间可以重排序。这些重排序在单线程视角下看不出来但在多线程共享内存的场景下就会导致一个线程看到的操作顺序和另一个线程看到的顺序不一致。这就是为什么在 LoongArch64 上写并发代码不能照搬 x86 的直觉。你必须显式地使用原子指令和内存屏障来建立 happens-before 关系告诉硬件和编译器这两个操作之间不能重排序。3.2 LASX 与原子指令的关系LASX 是 LoongArch64 的 256 位向量扩展指令集主要用于 SIMD 计算加速。它本身不直接提供原子操作但 LASX 指令的执行可能影响内存序——向量 load/store 和标量 load/store 之间的顺序在弱内存模型下同样需要屏障来约束。这次的问题不涉及 LASX但排查过程中我确认了一点LA664 的 LASX 单元和标量单元共享同一套缓存一致性协议向量操作不会绕过内存屏障。如果你的代码里混用了 LASX 向量访问和标量原子操作屏障的放置需要同时覆盖两类访问。3.3 LoongArch64 的原子指令家族LoongArch64 提供了一组原子内存操作指令主要包括指令语义内存序ll.w/sc.wload-linked / store-conditional需配合屏障使用amswap.w原子交换默认 relaxedamadd.w原子加默认 relaxedamand.w/amor.w/amxor.w原子位操作默认 relaxedamswap_db.w带 DB 屏障的原子交换acquire-releaseamadd_db.w带 DB 屏障的原子加acquire-release关键点在于不带_db后缀的原子指令默认是 relaxed 语义。它保证操作本身的原子性但不保证操作前后的内存访问顺序。很多从 x86 迁移过来的代码看到原子两个字就以为自带完整屏障实际上不是。_db后缀代表带屏障barrier具体是 acquire、release 还是 full barrier取决于指令编码中的aq和rl位。amswap_db.w默认同时设置 acquire 和 release相当于 full barrier。如果你的场景只需要单向屏障可以用amswap.w配合显式的dbar指令。3.4 那条出问题的原子指令到底做了什么回到出问题的代码。工作线程设置完成标志用的是类似这样的逻辑// 简化后的伪代码 void worker_done(volatile int *flag, int *data) { *data compute_result(); __atomic_store_n(flag, 1, __ATOMIC_RELAXED); // 问题在这里 }主线程等待while (__atomic_load_n(flag, __ATOMIC_RELAXED) 0) { // spin } int result *data;在 x86 上这段代码大概率能工作因为 x86 的 store 不会和之前的 store 重排序load 也不会和之后的 load 重排序。但在 LoongArch64 上__ATOMIC_RELAXED的 store 可能被重排序到*data compute_result()之前导致主线程看到flag 1时*data还是旧值。更糟的是主线程的 relaxed load 也可能被重排序读到flag 1后提前读*data。正确的写法应该是// 写线程 *data compute_result(); __atomic_store_n(flag, 1, __ATOMIC_RELEASE); // 读线程 while (__atomic_load_n(flag, __ATOMIC_ACQUIRE) 0) { // spin } int result *data;__ATOMIC_RELEASE保证 store 之前的所有内存操作对获取该 store 的线程可见__ATOMIC_ACQUIRE保证 load 之后的所有内存操作不会提前到 load 之前。这一对语义在 LoongArch64 上会编译成带_db的原子指令或显式的dbar指令。4. 从原子指令到打包死循环的完整链路4.1 打包工具的任务队列设计出问题的打包工具任务队列用的是经典的生产者-消费者模型。主线程把待打包的文件列表拆成任务放入队列工作线程从队列取任务处理完后把结果放到一个共享的结果数组里然后设置一个完成计数器。主线程的回收逻辑是轮询完成计数器当计数器等于任务总数时认为所有任务完成开始汇总结果。问题就出在这个轮询上——计数器用的是 relaxed 原子操作结果数组的写入也是普通 store两者之间没有屏障。在 x86 上store 到结果数组和 store 到计数器之间的顺序是保持的主线程看到计数器增加时结果数组的对应位置一定已经写好了。但在 LoongArch64 上这两个 store 可能重排序主线程看到计数器增加去读结果数组读到的是未初始化的内存或者旧数据。4.2 为什么表现为死循环而不是数据错误如果只是读到旧数据程序可能会输出错误结果但不一定死循环。这次之所以死循环是因为工作线程在设置完成标志之前还会检查一个取消标志。如果取消标志被设置工作线程会跳过结果写入直接设置完成标志。主线程在某个时刻设置了取消标志但工作线程因为内存序问题先看到了完成标志的更新实际上是自己之前设置的然后才看到取消标志于是进入了等待取消确认的循环。而主线程那边因为完成计数器没有达到预期值一直在等两边互相等形成死锁式的死循环。这个链路比较绕但核心就一句话relaxed 原子操作不提供跨线程的同步保证多个共享变量的更新顺序在弱内存模型下是不确定的。当你的代码依赖多个变量的更新顺序时必须用 acquire-release 或 full barrier 来建立顺序。4.3 用 litmus test 验证内存序假设定位到原子指令之后我写了一个小的 litmus test 来验证假设。litmus test 是内存序排查的标准工具基本思路是两个线程各自执行一组 load/store跑很多次统计各种结果组合出现的次数。针对这次的问题测试逻辑是// 线程 A X 1; // store X __atomic_store_n(Y, 1, __ATOMIC_RELAXED); // store Y // 线程 B while (__atomic_load_n(Y, __ATOMIC_RELAXED) 0) {} int r X; // load X在 x86 上r永远是 1。在 LoongArch64 上跑几百万次会出现r 0的情况。把 store Y 改成__ATOMIC_RELEASEload Y 改成__ATOMIC_ACQUIREr 0就再也不出现了。这个测试跑下来基本就确认了问题根因。接下来就是改代码、验证、回归测试。5. 修复方案与代码改造实操5.1 原子操作语义升级对照表修复的核心是把所有跨线程共享变量的访问从 relaxed 升级到正确的语义。下面是我整理的对照表覆盖了这次改造涉及的所有场景场景错误写法正确写法说明标志位设置__ATOMIC_RELAXEDstore__ATOMIC_RELEASEstore保证之前的数据写入可见标志位读取__ATOMIC_RELAXEDload__ATOMIC_ACQUIREload保证之后的数据读取不提前计数器递增__ATOMIC_RELAXEDadd__ATOMIC_ACQ_RELadd兼顾前后顺序简单计数统计__ATOMIC_RELAXEDadd__ATOMIC_RELAXEDadd不依赖顺序保持 relaxed指针发布普通 store__ATOMIC_RELEASEstore防止对象初始化被重排序关键原则只要一个变量的值被用来判断另一个变量的可见性这两个变量的访问之间就需要 acquire-release 配对。如果只是单纯的统计计数不依赖它做同步判断relaxed 就够了性能也更好。5.2 用 C11 原子操作重写关键路径改造后的工作线程完成逻辑#include stdatomic.h typedef struct { atomic_int completed; atomic_int cancelled; int *results; int total; } task_ctx_t; void worker_finish(task_ctx_t *ctx, int index, int result) { if (atomic_load_explicit(ctx-cancelled, memory_order_acquire)) { atomic_fetch_add_explicit(ctx-completed, 1, memory_order_release); return; } ctx-results[index] result; atomic_fetch_add_explicit(ctx-completed, 1, memory_order_release); } void main_wait(task_ctx_t *ctx) { while (atomic_load_explicit(ctx-completed, memory_order_acquire) ctx-total) { // 可以加 pause 指令降低功耗 __builtin_loongarch_dbar(0); // 轻量屏障减少总线压力 } // 此时所有 results 都可见 }这里有几个细节值得说。atomic_fetch_add_explicit用memory_order_release保证results[index]的写入在计数器递增之前对其他线程可见。主线程用memory_order_acquire读计数器保证读到计数器达到 total 之后所有 results 的写入都可见。__builtin_loongarch_dbar(0)是一个轻量屏障用于自旋循环中降低内存总线压力。它不是必须的但在高并发自旋场景下能明显减少缓存一致性流量。LoongArch64 的dbar指令有不同级别的参数0 是最轻的只保证 load/store 的顺序不等待 store buffer 清空。5.3 编译期屏障与运行期屏障的配合C11 原子操作同时提供编译期屏障和运行期屏障。编译期屏障防止编译器重排序运行期屏障防止 CPU 重排序。两者缺一不可。有些老代码用volatile来试图解决这个问题但volatile只保证编译器不优化掉访问不提供任何运行期内存序保证。在 LoongArch64 上volatile变量的 load/store 仍然可能被 CPU 重排序。所以volatile不能替代原子操作和屏障。如果你用的是 GCC 的__atomic内建函数__ATOMIC_RELEASE和__ATOMIC_ACQUIRE会同时生成编译期屏障和运行期屏障。如果你用的是__sync系列老接口它们默认是 full barrier性能较差但语义正确。新代码建议统一用__atomic或 C11stdatomic.h。5.4 改造后的验证与回归改完之后我用几个手段验证第一litmus test 重跑确认r 0不再出现。第二打包工具在 LA664 多路服务器上跑压力测试并发度拉满连续跑 24 小时没有再出现卡死。第三用perf对比改造前后的性能确认屏障的引入没有带来明显的性能退化。实测下来改造后打包任务的整体耗时增加了不到 2%主要来自自旋循环里的dbar指令。把dbar去掉之后性能几乎无差别但考虑到高并发下的总线压力我还是保留了它。这个开销换来的正确性是值得的。6. 排查过程中的常见问题与避坑指南6.1 为什么 gdb 看到的栈和实际不符弱内存模型下的死循环gdb attach 上去看到的栈可能具有误导性。因为 CPU 乱序执行程序计数器指向的指令不一定是真正导致卡死的指令。你看到的while循环可能只是表象真正的问题在循环之前的某个 store 没有正确同步。我的经验是不要完全信任 gdb 的栈要结合反汇编和内存序分析。具体做法是把卡死线程的寄存器状态 dump 出来看它正在等待的地址和值然后反推这个值应该由谁写入、写入时用了什么内存序。6.2 常见问题速查表现象可能原因排查手段修复方向多核偶发死循环relaxed 原子操作缺少屏障litmus test 验证升级 acquire-release单核正常多核异常弱内存模型重排序对比 x86 行为插入显式屏障加日志后不复现日志引入隐式屏障用 ftrace 替代 printf审查原子操作语义性能下降明显屏障过强perf 对比降级到单向屏障只在 LA664 复现架构特定内存序跨架构 litmus test统一用 C11 原子6.3 避坑心得不要用 volatile 做同步这是我踩过的最大的坑。早期代码里大量用volatile修饰共享标志位在 x86 上跑了很多年没出问题一到 LoongArch64 就翻车。volatile的语义是每次访问都从内存读/写但它不阻止 CPU 重排序也不建立 happens-before 关系。正确的做法是共享标志位用atomic_int或__atomic内建函数需要顺序保证的地方用 acquire-release不需要的地方用 relaxed。volatile只应该用于内存映射 I/O 或者信号处理程序中的sig_atomic_t不应该用于多线程同步。6.4 避坑心得自旋循环里加 pause 指令LoongArch64 没有 x86 的pause指令但有类似的dbar 0可以用。在自旋等待循环里加一条轻量屏障能减少缓存一致性流量降低功耗还能让超线程兄弟核获得更多执行资源。不过要注意dbar 0不是必须的加不加取决于自旋的频率和竞争程度。如果自旋很快就能退出加屏障反而增加开销。我的做法是先不加测性能如果发现自旋导致总线流量异常高再加。6.5 避坑心得跨架构代码统一用 C11 原子如果你的代码需要在 x86、ARM、LoongArch64 多个架构上跑最省心的方案是统一用 C11stdatomic.h。C11 原子操作的语义是架构无关的编译器会针对不同架构生成正确的指令序列。你不需要为每个架构写不同的屏障代码。唯一需要注意的是C11 原子操作在某些老编译器上支持不完整。GCC 4.9 以上、Clang 3.6 以上都支持得不错。如果必须用老编译器可以用 GCC 的__atomic内建函数作为替代语义和 C11 基本一致。7. 从这次事件延伸出的几点经验LA664 上的这次丢失更新事件表面上看是一条原子指令用错了语义深层看是跨架构移植时对内存模型差异的忽视。x86 的强内存模型养成了很多坏习惯这些习惯在弱内存模型架构上会变成定时炸弹。我现在做 LoongArch64 移植时会先做一件事把所有跨线程共享变量的访问列出来逐个审查内存序。不需要同步的用 relaxed需要同步的用 acquire-release需要全序的用 seq_cst。这个审查过程花不了多少时间但能避免后面大量的调试成本。另外litmus test 应该成为弱内存模型平台开发的标配工具。不要等到出了问题才去写在代码 review 阶段就用 litmus test 验证关键同步路径能把问题挡在上线之前。我现在的做法是每个涉及跨线程同步的模块都配一个对应的 litmus test跑在 CI 里每次提交都验证一遍。最后再分享一个小技巧如果你怀疑某段代码有内存序问题但又不想大改可以先把所有 relaxed 原子操作临时改成 seq_cst跑一遍看问题是否消失。如果消失了说明确实是内存序问题然后再逐步降级到最小必要的屏障强度。这个方法能快速确认方向避免在错误的方向上浪费时间。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。