Linux内核并发编程:READ_ONCE与WRITE_ONCE宏的原理与实践

发布时间:2026/8/5 5:35:33
Linux内核并发编程:READ_ONCE与WRITE_ONCE宏的原理与实践 1. 项目概述为什么内核需要“一次性”读写如果你写过Linux内核驱动或者研究过内核源码肯定见过READ_ONCE()和WRITE_ONCE()这两个宏。它们看起来平平无奇就像一层简单的变量访问包装以至于很多开发者会忽略它们直接使用原始的变量读写。但内核开发者们不厌其烦地在成千上万个地方使用它们绝不是为了代码美观。简单来说这两个宏是内核应对现代处理器和编译器“过于聪明”而设立的“秩序维护者”。在没有它们的情况下你的代码逻辑可能会被编译器和CPU的执行优化彻底打乱导致一些极其诡异、难以复现的并发Bug。这不仅仅是理论问题我亲身经历过一次驱动调试因为漏写了一个READ_ONCE()设备在某个多核ARM平台上运行时会以极低的概率读取到陈旧的寄存器状态导致系统误判设备离线。这个问题折磨了团队近两周最终通过代码审查和查阅内存屏障文档才定位到。所以理解READ_ONCE和WRITE_ONCE不是去背诵它们的宏定义而是要明白它们所防御的三种“混乱之源”编译器优化、CPU乱序执行以及非原子访问的撕裂Tearing。它们共同的目标是确保对单个内存位置的访问是“一次性”完成的并且从其他CPU的视角来看这个访问是“原子的”和“顺序一致的”。这里的“一次性”是关键意味着读或写操作不会被拆分成多个部分也不会被观察到一个“半完成”的中间状态。2. 混乱之源编译器、CPU与我们的认知偏差在深入宏的实现之前我们必须先搞清楚敌人是谁。为什么在单线程应用程序中完全正确的代码到了内核并发环境下就可能出错2.1 编译器的“善意”优化编译器如GCC的目标是生成高效的机器码。它会基于“单线程程序顺序执行”的假设进行大量优化。其中一个常见的优化是冗余加载/存储消除。假设我们有如下代码while (tmp a) { do_something(tmp); if (tmp 10) break; }编译器可能会想“既然循环里a的值没被修改我何必每次循环都去内存里读它呢读一次放到寄存器里后面一直用这个寄存器值就好了。”于是它生成的汇编代码可能只会在循环开始前读取一次a。这在单线程下完全正确。但在内核中a可能是一个由其他CPU或中断处理程序异步更新的共享变量。编译器的优化导致我们的循环永远看不到a的更新陷入了死循环。READ_ONCE()的作用就是告诉编译器“这个变量可能会被别处修改不要做这种缓存优化每次都必须从内存重新读取。”另一个例子是变量合并。编译器可能把对同一个变量的多次写操作合并为一次或者把多次读操作重新排序如果这些操作之间没有明显的依赖关系的话。WRITE_ONCE()就是告诉编译器“这次写操作必须精确地发生在这里并且要立刻让其他线程可见不要给我合并或延迟。”实操心得在查看复杂并发代码的汇编输出时如果发现对某个共享变量的访问指令“消失”了或者数量不对第一个要怀疑的就是是否缺少了READ_ONCE/WRITE_ONCE。可以用gcc -S -O2生成汇编代码来验证。2.2 CPU的“随心所欲”执行即使编译器老老实实地生成了每次访问内存的指令现代CPU为了充分利用流水线和多级缓存也会对指令进行乱序执行Out-of-Order Execution。只要在单线程视角下不影响最终结果CPU可以打乱指令的执行顺序。考虑以下代码// CPU 0 执行 a 1; b 2; // CPU 1 执行 while (b ! 2) { /* spin */ } printk(“%d\n”, a);我们的直觉是如果CPU1看到了b 2那么它一定能看到a 1。因为代码顺序是a1在b2之前。但在缺乏同步的弱内存模型架构如ARM、PowerPC上这不一定成立CPU0可能为了效率先将b写入缓存后写入a。那么从CPU1的视角就可能出现b2但a0这种违反直觉的情况。WRITE_ONCE()本身并不直接提供跨CPU的全局顺序保证那是内存屏障smp_wmb()等的工作但它为使用内存屏障奠定了基础。更重要的是它确保了单个变量的写入本身是原子的不会被撕裂。2.3 非原子访问的撕裂这是最直接的危险。在C语言中对一个int的赋值通常是原子的。但对一个结构体或一个大于CPU字长例如在32位系统上访问64位变量的数据进行读写可能对应多条机器指令。struct foo { int a; int b; }; struct foo shared_struct; // 线程1 shared_struct.a 1; shared_struct.b 2; // 线程2 int local_a shared_struct.a; int local_b shared_struct.b;如果shared_struct的赋值不是原子的线程2可能读到a已更新但b还是旧值或者更糟的、混合了新旧字节的撕裂值。WRITE_ONCE()和READ_ONCE()通过将操作对象转换为与CPU字长匹配的简单类型并利用编译器内建函数保证了即使对于无法单指令完成的操作也能以“一次性”的方式被观察。3. 核心细节解析宏的实现与演变理解了“为什么需要”我们再来看“怎么实现”。READ_ONCE和WRITE_ONCE的定义在include/linux/compiler.h中它们的实现随着内核版本和编译器支持在演进但核心思想一致。3.1 基础实现volatile类型转换我们来看一个简化版本的核心思路#define WRITE_ONCE(x, val) \ (*(volatile typeof(x) *)(x) (val)) #define READ_ONCE(x) \ (*(volatile typeof(x) *)(x))typeof(x)获取变量x的类型保证宏适用于任何类型。(x)取变量x的地址。(volatile typeof(x) *)将地址强制转换为指向volatile类型的指针。volatile关键字是关键它告诉编译器禁止优化不要缓存这个位置的值到寄存器每次访问都必须从内存读取或写入内存。保持顺序编译器不能将对volatile变量的访问与其他访问随意重排。*解引用指针完成实际的读或写操作。这个版本已经能解决大部分编译器优化问题。但它有局限对于非标量类型如结构体volatile访问可能仍然不是原子的会生成多条指令存在撕裂风险。3.2 现代实现__native_word与编译器内建现代内核的实现更加精细和强大。以当前较新的内核版本为例其逻辑大致如下首先内核会判断访问的类型是否是“原生字”__native_word即其大小是否等于或小于CPU能原子访问的字长如32位系统上的4字节。// 简化逻辑 #define __READ_ONCE(x) \ ({ \ union { typeof(x) __val; char __c[1]; } __u; \ __read_once_size((x), __u.__c, sizeof(x)); \ __u.__val; \ }) static __always_inline void __read_once_size(const volatile void *p, void *res, int size) { if (size sizeof(int)) { *(int *)res *(volatile int *)p; } else if (size sizeof(short)) { *(short *)res *(volatile short *)p; } else if (size sizeof(char)) { *(char *)res *(volatile char *)p; } else { // 对于非原生字大小调用编译器内建函数 __builtin_memcpy // 并标记为 volatile 访问防止编译器优化掉或拆散这次拷贝 __builtin_memcpy((void *)res, (const void *)p, size); // 注意这里仍然可能不是硬件原子操作但保证了“一次性”拷贝语义 } }WRITE_ONCE的实现类似使用__builtin_memcpy进行“一次性”写入。为什么用__builtin_memcpy因为它给了编译器明确的指令“将这块内存作为一个整体来拷贝”。编译器会尽力生成高效的拷贝指令如rep movsb或ldm/stm并且由于上下文是volatile它不会将这个拷贝操作拆散或与其他操作重排。这就在软件层面保证了访问的“原子性”语义尽管在硬件层面对于大于字长的数据中间状态可能仍然会被观察到但至少不会读到由部分旧字节和部分新字节混合而成的“撕裂值”。注意事项READ_ONCE/WRITE_ONCE不保证硬件级的原子性比如一个64位写操作在32位系统上可能还是两条指令。它们保证的是“访问的原子性语义”和“编译器优化屏障”。如果你需要真正的、不会被任何中间状态打断的硬件原子操作应该使用内核提供的原子操作API如atomic64_t或使用锁。3.3 与内存屏障的配合这两个宏常与内存屏障一起使用以控制跨CPU的内存访问顺序。例如// 生产者 data ...; // 准备数据 smp_wmb(); // 写内存屏障确保data的写入先于flag的写入 WRITE_ONCE(flag, 1); // 发布数据可用标志 // 消费者 while (READ_ONCE(flag) ! 1) { // 等待标志 cpu_relax(); } smp_rmb(); // 读内存屏障确保先读到flag1再读取data ... data; // 消费数据这里WRITE_ONCE确保了flag的写入是原子的、编译器不会乱序。smp_wmb()确保了在它之前的所有写入data在全局内存顺序上先于它之后的所有写入flag。READ_ONCE确保每次循环都重新从内存读取flag。smp_rmb()确保了读flag在读data之前。4. 实操指南何时用、怎么用、何时不用理论说了很多到底怎么写代码这里有一些非常具体的指导原则。4.1 必须使用 READ_ONCE/WRITE_ONCE 的场景访问可能被异步修改的共享变量这是最核心的场景。变量可能被其他CPU、中断处理程序、下半部softirq/tasklet、内核线程等修改。// 错误示例 if (shared_flag) { // 编译器可能只读一次shared_flag while (shared_flag) { // 这里可能死循环 do_work(); } } // 正确示例 if (READ_ONCE(shared_flag)) { while (READ_ONCE(shared_flag)) { // 每次循环都重新读取 cpu_relax(); } }实现无锁lock-free数据结构例如自旋锁、引用计数、简单的标志位通信。在这些结构中对共享状态的读写必须使用这些宏来保证可见性和顺序。// 一个简单的自旋锁实现简化版实际内核实现更复杂 void spin_lock(spinlock_t *lock) { while (unlikely(atomic_cmpxchg(lock-val, 0, 1) ! 0)) { while (READ_ONCE(lock-val) ! 0) // 必须用READ_ONCE cpu_relax(); } }访问通过volatile指针指向的内存即使指针本身是volatile解引用它也不一定保证每次访问都从内存读。使用宏是更明确和可靠的做法。volatile int *reg (volatile int *)MMIO_ADDR; int val READ_ONCE(*reg); // 明确的单次读取防止编译器对变量进行常量传播等优化当变量的初始值在编译时已知但运行时会被修改时。int debug_enabled 0; // 默认关闭可通过sysfs修改 void log_debug(char *msg) { if (READ_ONCE(debug_enabled)) { // 必须用READ_ONCE否则编译器可能优化掉整个if块 printk(KERN_DEBUG %s\n, msg); } }4.2 不需要使用 READ_ONCE/WRITE_ONCE 的场景已经被锁保护的变量如果变量访问被 spinlock、mutex、rcu_read_lock 等同步原语保护那么锁本身就提供了必要的内存顺序和可见性保证通常不需要额外使用这两个宏。spin_lock(mylock); my_shared_data new_value; // 安全在锁内 spin_unlock(mylock);注意有一个例外就是锁变量本身的状态例如spinlock_t里的lock字段它的访问必须是无锁的因此内核在实现锁原语时内部会使用这些宏或原子操作。局部变量或文件作用域的静态变量且无并发访问如果确定没有并发访问使用宏只会增加不必要的开销和代码噪音。进行硬件原子操作时当你使用atomic_t,atomic64_t,atomic_read(),atomic_set(),atomic_xchg()等原子操作API时它们内部已经包含了必要的屏障和volatile语义无需再包装READ_ONCE/WRITE_ONCE。4.3 使用规范与常见陷阱类型匹配WRITE_ONCE会进行严格的类型检查确保写入的值与变量类型匹配。这是好事能提前发现类型错误。uint32_t reg; WRITE_ONCE(reg, 0x12345678); // 正确 WRITE_ONCE(reg, some_ptr); // 编译警告/错误类型不匹配不要对表达式使用宏的参数必须是左值可以取地址。// 错误 READ_ONCE(ptr-field); // 如果ptr本身可能被其他线程修改这样写不安全 // 正确做法先将指针读到一个本地变量 struct foo *local_ptr READ_ONCE(ptr); if (local_ptr) { do_something(local_ptr-field); }注意位域Bit-fieldC语言标准未规定位域的并发访问行为不同编译器实现差异很大。直接对位域使用READ_ONCE/WRITE_ONCE可能无法保证原子性。最佳实践是避免在多线程间共享位域或者使用锁保护或者将位域转换为整数进行原子操作。性能考量这些宏会阻止编译器优化可能带来轻微的性能开销。因此不要滥用。只在确有必要的地方使用。在性能极其敏感的路径上需要仔细权衡。5. 常见问题与排查技巧实录即使明白了原理和规则在实际编码和调试中还是会遇到各种坑。下面是我和同事们踩过的一些坑以及解决方法。5.1 问题数据竞争Data Race导致的偶发性崩溃现象内核模块在多个CPU核心压力测试下偶发性地发生Oops错误指向某个共享数据结构的指针字段提示是非法指针访问。但单核运行或轻负载下从未出现。排查过程首先检查了所有访问该数据结构的代码路径都使用了自旋锁进行保护看起来没有问题。使用CONFIG_KCSAN内核并发性检查器重新编译内核并测试KCSAN报告了一个在该锁保护范围之外的、对该数据结构中某个计数器变量的读操作存在数据竞争。审查代码发现有一个性能监控线程为了统计信息在没有获取锁的情况下直接读取了这个计数器。// 监控线程 unsigned long get_count(void) { // 错误无锁读取共享变量 return shared_stats-count; }根因与解决这就是典型的缺失READ_ONCE的场景。即使这个监控线程只是“读一下”在并发写存在的情况下也可能读到撕裂的值如果count是64位且在32位系统上或者由于编译器优化读到陈旧的值。更严重的是在弱内存序架构上即使读到了正确的count值也可能因为内存序问题导致后续基于此值访问的其他结构体字段比如shared_stats-name时shared_stats指针本身已经失效比如已被释放并重用。正确的做法是unsigned long get_count(void) { struct stats *local_stats; local_stats READ_ONCE(shared_stats); // 一次性读取指针 if (likely(local_stats)) { return READ_ONCE(local_stats-count); // 一次性读取计数器 } return 0; }同时修改shared_stats的代码必须使用WRITE_ONCE并且在必要时配合smp_wmb()确保指针的发布是安全的。排查技巧遇到难以复现的并发bugCONFIG_KCSAN是你的第一道利器。它能检测到很多潜在的数据竞争。其次可以尝试在READ_ONCE/WRITE_ONCE可能缺失的地方主动加上它们看问题是否消失。5.2 问题循环优化导致的“幽灵”挂起现象一个等待事件标志的驱动在极少数情况下即使事件标志已被另一个CPU设置等待循环也无法退出CPU占用率100%。代码片段// CPU A (设置者) event_flag 1; // 漏了 WRITE_ONCE smp_wmb(); // 写了内存屏障 // CPU B (等待者) while (!event_flag) { // 漏了 READ_ONCE cpu_relax(); }根因与解决问题出在等待循环上。由于缺少READ_ONCE编译器在开启高优化级别如-O2时可能会将while (!event_flag)优化成cmp DWORD PTR [event_flag], 0 je .loop_start # 如果为0跳回循环开始 # 但这里没有重新从内存加载 event_flag实际上更可能的情况是编译器将event_flag的值缓存在了寄存器中循环只是在反复测试寄存器而寄存器里的值永远不会更新。smp_wmb()保证了CPU A的写入顺序但无法强制CPU B的编译器重新加载变量。加上READ_ONCE即可解决。实操心得任何在循环条件中检查的、可能被外部修改的共享变量都必须使用READ_ONCE。这是一个黄金法则。5.3 问题结构体赋值不是原子的现象一个用于传递简短消息的共享缓冲区有时消费者会读到一部分是旧值、一部分是新值的“错乱”消息。错误代码struct message { int type; int data[4]; }; struct message shared_msg; // 生产者 struct message new_msg { .type 1, .data {1,2,3,4} }; shared_msg new_msg; // 结构体赋值可能不是原子的 // 消费者 if (shared_msg.type 1) { // 可能读到新的type process_data(shared_msg.data); // 但这里的data可能还是旧的 }根因与解决shared_msg new_msg;这个赋值操作编译器会生成一个memcpy但这个memcpy不是原子的也没有volatile语义。消费者可能在拷贝过程中间读取到结构体。必须使用WRITE_ONCE来包装整个结构体的写入对于消费者如果读取整个结构体也应该使用READ_ONCE。// 生产者 WRITE_ONCE(shared_msg, new_msg); // 利用 __builtin_memcpy 保证一次性写入 // 消费者 struct message local_msg READ_ONCE(shared_msg); // 一次性读取整个结构体 if (local_msg.type 1) { process_data(local_msg.data); }注意这只解决了“撕裂”问题。如果生产者和消费者需要更严格的内存顺序例如确保type的写入先于data被看到可能还需要搭配内存屏障使用。5.4 调试与验证技巧查看预处理后的代码使用gcc -E可以查看宏展开后的代码确认READ_ONCE/WRITE_ONCE是否按预期展开。查看汇编代码使用objdump -d或gcc -S查看生成的汇编确认对共享变量的访问指令是否如预期般存在并且没有可疑的优化比如将多次读取合并、将变量缓存在寄存器。使用编译器屏障在极少数情况下你可能需要更强的编译器顺序保证。barrier()宏或asm volatile( ::: memory)可以作为一个完整的编译器优化屏障阻止屏障前后的所有内存访问被重排。但它的粒度比READ_ONCE/WRITE_ONCE粗会影响性能应谨慎使用。理解架构差异在x86这种拥有较强内存模型TSO的架构上很多并发错误可能被掩盖。问题往往在移植到ARM、PowerPC等弱内存序架构时暴露。因此在弱内存序架构上进行并发测试至关重要。READ_ONCE和WRITE_ONCE是Linux内核并发编程的基石之一。它们看似简单却蕴含着对硬件和编译器行为的深刻理解。正确使用它们是编写稳定、可靠内核代码的必备技能。记住一个简单的原则当你不能确定一段共享内存的访问是否安全时加上它们通常是成本最低、最保险的选择。随着你对内存模型和并发语义的理解加深你会更准确地判断何时必须用何时可以不用从而在代码正确性和性能之间找到最佳平衡点。