Linux 内核内存屏障与无锁编程深度实战:从 Compiler Reordering 到 Cache Coherence
一、为什么需要内存屏障?
现代处理器和编译器为了性能会进行激进的重排序(reordering)。在单线程场景下,这些重排序对程序员透明;但在多核并发场景下,重排序会导致其他 CPU 看到不一致的内存状态,产生极难复现的 Heisenbug。
内存屏障(Memory Barrier / Fence)正是为了给程序员提供一种手段,控制内存访问的可见顺序(visibility ordering)。
1.1 四个维度的重排序
┌──────────────────────────────┐
Source Code │ Compiler Reordering │
├──────────────────────────────┤
LLVM IR │ CPU Out-of-Order Execution │
├──────────────────────────────┤
L1 Cache │ Store Buffer / Invalid Queue │
├──────────────────────────────┤
L3 / Memory │ Cache Coherence Protocol │
└────────────────────────────────┘
- 编译器重排序:编译器优化(-O2/-O3)会重排没有数据依赖的指令
- CPU乱序执行:现代 OoO CPU 可在保持单线程语义前提下乱序执行
- Store Buffer:写操作先进入 Store Buffer 后才刷入缓存,导致"写可见延迟"
- 缓存一致性延迟:MESI/MESIF/MOESI 协议下,Invalidate 消息传播非瞬时完成
Linux 内核提供了四个层级的屏障原语来处理这四类重排序。
二、Linux 内核屏障原语完整分类
2.1 编译器屏障 vs CPU 屏障
// compiler barrier:仅禁止编译器重排序,不产生任何额外指令
barrier(); // 展开为 asm volatile("" ::: "memory")
// "memory" clobber 告诉编译器内存可能已被修改
// 强制所有缓存变量刷回/重新加载
// 数据依赖屏障(data dependency barrier)
read_barrier_depends(); // 在弱序架构(alpha, ia64, some arm)上
// 产生读屏障指令;在 x86 上退化为 barrier()
2.2 完整内存屏障(Full Fence)
mb(); // 全内存屏障:之前的读写全部完成后,才能执行之后的读写
rmb(); // 读内存屏障:之前的读全部完成后,才能执行之后的读
wmb(); // 写内存屏障:之前的写全部完成后,才能执行之后的写
这三个原语在 x86 上的实现:
// arch/x86/include/asm/barrier.h
#define mb() asm volatile("mfence" ::: "memory")
#define rmb() asm volatile("lfence" ::: "memory")
#define wmb() asm volatile("sfence" ::: "memory")
但在 ARM64 上,实现完全不同:
// arch/arm64/include/asm/barrier.h
#define mb() dsb(sy) // Data Synchronization Barrier, Full System
#define rmb() dsb(ld) // DSB, Load domain
#define wmb() dsb(st) // DSB, Store domain
// dsb 指令编码:
// DSB SY : 等待所有内存访问完成,涵盖所有观察点
// DSB LD : 等待所有读操作完成
// DSB ST : 等待所有写操作完成
2.3 获取-释放语义(Acquire-Release)
对称屏障(mb/rmb/wmb)代价高昂。现代 C11/C++11 引入的 acquire-release 语义是一种更高效的非对称屏障:
// Linux 内核中 acquire/release 原语
smp_load_acquire(&ptr); // 等价于:读取 + rmb() + 编译器屏障
smp_store_release(&flag, 1); // 等价于:wmb() + 写入 + 编译器屏障
关键规则:
- Acquire 后的所有内存访问,不能重排序到 Acquire 之前
- Release 之前的所有内存访问,不能重排序到 Release 之后
- 不多不少,刚好满足生产者-消费者模式需求
ARM64 上 smp_store_release 的实现非常优雅:
#define smp_store_release(p, v) \
do { \
compiletime_assert_atomic_type(*p); \
switch (sizeof(*p)) { \
case 4: \
asm volatile ( \
"stlr %w1, %0" \
: "=Q" (*p) : "r" (v) : "memory"); \
break; \
case 8: \
asm volatile ( \
"stlr %1, %0" \
: "=Q" (*p) : "r" (v) : "memory"); \
break; \
} \
} while (0)
stlr(Store-Release)是指令级 release 语义,硬件保证之前的写操作对该存储可见。
2.4 原子操作与屏障的对比
Linux 内核原子操作可分为两组:
// 不带屏障的原子操作(极少使用)
atomic_read(&counter);
atomic_set(&counter, val);
// 带隐式屏障的原子操作(默认使用)
atomic_inc(&counter); // 隐含 smp_mb() 前后全屏障
atomic_dec_and_test(&cnt); // 同上
// 显式指定内存序的原子操作(推荐,性能更好)
atomic_fetch_add_relaxed(&c, 1); // 无额外屏障
atomic_fetch_add_acquire(&c, 1); // acquire 序
atomic_fetch_add_release(&c, 1); // release 序
atomic_fetch_add(&c, 1); // 全屏障(默认)
三、实战一:经典Acquire-Release 模式
3.1 生产者-消费者问题
这是展示内存屏障价值最直接的例子。假设有两个线程通过共享内存交换数据:
// ===== 错误示例(可能有 bug) =====
struct buffer {
int data; // 数据
int ready; // 标志位
};
void producer(struct buffer *buf, int value)
{
buf->data = value; // 写数据
// 如果没有 release 语义,CPU 可能先让 ready=1 对其它核可见
buf->ready = 1; // 标记就绪!
}
int consumer(struct buffer *buf)
{
while (buf->ready == 0) // 等待就绪
cpu_relax();
// 如果没有 acquire 语义,读到的 data 可能是旧值
return buf->data;
}
在 ARM 处理器上,上面的代码有明确的 Data Memory Barrier 需求:
producer:
┌─────────────────┐
│ store data=42 │ ──┐
└─────────────────┘ │ Store Buffer
┌─────────────────┐ │ 可能重排!
│ store ready=1 │ ──┘
└─────────────────┘
consumer:
┌─────────────────┐ CPU 乱序执行
│ load ready │ ──────────────┐
│ eax=1 (新值) │ │
├─────────────────┤ │ 可能先看到 ready=1
│ load data │ ←─────────────┘ 然后读到 data=0(旧值)
│ eax=0 (旧值!) │
└─────────────────┘
3.2 正确实现:使用内核 API
struct buffer {
int data;
int ready;
};
void producer(struct buffer *buf, int value)
{
buf->data = value;
// smp_store_release 保证:data 的写入一定对其它核可见
// 之后,ready=1 才变成可见
smp_store_release(&buf->ready, 1);
}
int consumer(struct buffer *buf)
{
// smp_load_acquire 保证:读到 ready=1 之后
// 所有的读操作都在"看到 ready=1 之后"执行
while (!smp_load_acquire(&buf->ready))
cpu_relax();
return buf->data; // 一定是 value,不可能是旧值
}
3.3 另一个视角:观察者效应
内存屏障的核心作用不是让写操作"立刻完成",而是建立因果关系链:
CPU0: CPU1:
data = value (1) 写数据
smp_store_release(&ready, 1) (2) 写 ready
smp_load_acquire(&ready) (3) 下载 ready=1
read data = value (4) 读 data,一定看到最新值
关键不变量:
(2) → (3) 观察到 ready=1 的因果链
(1) happens-before (2) [release语义保证]
(3) happens-before (4) [acquire语义保证]
因此 (1) happens-before (4):一定读到最新数据
四、实战二:RCU 实现机制深度解析
RCU(Read-Copy-Update)是 Linux 内核最精妙的同步机制之一,它实现了真正的无锁读端(读端零开销)。
4.1 RCU 读端原语
// 读端临界区开始
rcu_read_lock();
// 安全地读取 RCU 保护的指针,不会被悬空
ptr = rcu_dereference(g_ptr);
use(ptr);
// 读端临界区结束
rcu_read_unlock();
rcu_read_lock() 的开销极其微小——它在 UP(单核)内核上只是一个 preempt_disable(),甚至不做任何事;在 CONFIG_PREEMPT 下也只是记录当前 CPU 的状态。
4.2 RCU 写端:Grace Period 与内存屏障
写端更新 RCU 保护的指针:
// 写端更新
void update_rcu(struct old_data *old, int new_val)
{
struct old_data *new = kmemdup(old, sizeof(*old), GFP_KERNEL);
new->value = new_val;
// rcu_assign_pointer 内部是 smp_store_release
// 保证 new 内容对其它核可见后,才更新指针
rcu_assign_pointer(g_ptr, new);
// 等待所有现有读端退出
synchronize_rcu();
// 现在可以安全释放旧数据
kfree(old);
}
4.3 Grace Period 的时序图
CPU0 (写端) CPU1 (读端) CPU2 (读端)
───────── ────────── ──────────
T0 准备新数据
rcu_assign_pointer
(store-release)
进入 rcu_read_lock
synchronize_rcu() 进入 rcu_read_lock
开始等待...
dereference g_ptr
看到新指针! ...
使用新数据 use ptr
... rcu_read_unlock ─────────► rcu_read_unlock
────► 退出
T1 [所有读端已完成]
返回旧数据可以释放!
关键:rcu_read_lock/unlock 自身不含任何内存屏障
barrier 需求由 synchronize_rcu() 和 quiescent state 机制满足
4.4 rcu_assign_pointer 实现
// include/linux/rcupdate.h
#define rcu_assign_pointer(p, v) \
do { \
uintptr_t _r_a_p__v = (uintptr_t)(v); \
\
if (__builtin_constant_p(v) && (_r_a_p__v) == 0) \
WRITE_ONCE(p, (typeof(*p))_r_a_p__v); \
else \
smp_store_release(&p, _r_a_p__v); \
} while (0)
WRITE_ONCE + smp_store_release 确保编译器和 CPU 都不会把"写 new 内容"和"更新指针"重排。
五、实战三:无锁单链表——list_del_rcu
5.1 为什么不能用普通 list_del
普通链表的删除 list_del 包含两步:
static inline void list_del(struct list_head *entry)
{
__list_del(entry->prev, entry->next);
entry->next = LIST_POISON1; // 设置毒值
entry->prev = LIST_POISON2;
}
如果另一个 CPU 的读端正在遍历这个链表(例如使用 list_for_each_entry),当它读到了 entry->next = LIST_POISON1 后,继续访问该指针会导致崩溃。
5.2 list_del_rcu 的无锁实现
// kernel 4.x 之后的实现
static inline void list_del_rcu(struct list_head *entry)
{
__list_del(entry->prev, entry->next);
entry->prev = LIST_POISON2; // 只标记 prev,不碰 next
}
static inline void __list_del(struct list_head *prev, struct list_head *next)
{
next->prev = prev;
WRITE_ONCE(prev->next, next); // 关键!仅用 WRITE_ONCE
}
关键点:list_del_rcu 不修改 entry->next。这意味着:
- 读端正在访问
entry时,仍然可以通过entry->next安全地遍历下一个节点 - 但一旦读端从链表中"离开"
entry,就永远不会再访问它 - 写端可以在 grace period 结束后安全释放
entry
5.3 配合 rcu_dereference 使用
int lookup_rcu(struct list_head *head, int key)
{
struct my_node *node;
int ret = -1;
rcu_read_lock();
list_for_each_entry_rcu(node, head, list) {
// rcu_dereference 在弱序架构上需要读屏障
// 在 x86 上只是 barrier()
if (READ_ONCE(node->key) == key) {
ret = READ_ONCE(node->value);
break;
}
}
rcu_read_unlock();
return ret;
}
5.4 完整增删示例
// 添加节点
void add_node(struct list_head *head, int key, int val)
{
struct my_node *node = kmalloc(sizeof(*node), GFP_KERNEL);
node->key = key;
node->value = val;
spin_lock(&list_lock); // 写端之间需要互斥
list_add_rcu(&node->list, head);
spin_unlock(&list_lock);
}
// 删除节点
void del_node(struct list_head *head, int key)
{
struct my_node *node, *tmp;
spin_lock(&list_lock);
list_for_each_entry_safe(node, tmp, head, list) {
if (node->key == key) {
list_del_rcu(&node->list);
spin_unlock(&list_lock);
synchronize_rcu(); // 等待读端全部退出
kfree(node); // 安全释放
return;
}
}
spin_unlock(&list_lock);
}
六、实战四:Seqlock——写多读多的救星
当 RCU 场景下写操作频繁但不需要等待 Grace Period 时,seqlock 是更好的选择。
6.1 Seqlock 的核心思想
Seqlock 基于一个序列计数器。写端在操作前序列号 +1(奇数表示写中),操作完成后 +1(偶数表示完成)。读端通过检测序列号的奇偶性和是否变化来判断读到的是否为一致数据。
// include/linux/seqlock.h
typedef struct seqcount {
unsigned sequence ____cacheline_aligned_in_smp;
} seqcount_t;
typedef struct {
seqcount_t seqcount;
spinlock_t lock;
} seqlock_t;
6.2 写端实现
static inline void write_seqlock(seqlock_t *sl)
{
spin_lock(&sl->lock); // 写端互斥
write_seqcount_begin(&sl->sl_seqcount); // 序列号变奇数
}
static inline void write_sequnlock(seqlock_t *sl)
{
write_seqcount_end(&sl->sl_seqcount); // 序列号变偶数
spin_unlock(&sl->lock);
}
// write_seqcount_begin 的实现
static inline void write_seqcount_begin(seqcount_t *s)
{
s->sequence++;
smp_wmb(); // 写屏障:保证序列号更新可见后,才执行后面的写
}
static inline void write_seqcount_end(seqcount_t *s)
{
smp_wmb(); // 写屏障:保证数据写入完成,才更新序列号
s->sequence++;
}
关键点:写端的两次 sequence++ 都需要 wmb() 配合:
begin的 wmb 确保"序列号变奇数"领先于任何数据修改end的 wmb 确保所有数据修改在"序列号变偶数"之前完成
6.3 读端实现
static inline unsigned read_seqbegin(const seqlock_t *sl)
{
unsigned ret;
do {
ret = read_seqcount_begin(&sl->sl_seqcount);
// 等价于:
// ret = READ_ONCE sl->seqcount->sequence;
// smp_rmb();
// return ret;
} while (unlikely(ret & 1)); // 奇数=正在写,重试
return ret;
}
static inline int read_seqretry(const seqlock_t *sl, unsigned start)
{
smp_rmb();
return read_seqcount_retry(&sl->sl_seqcount, start);
// 等价于: unlikely(sl->seqcount->sequence != start)
}
读端使用 rmb()(读内存屏障)确保:
- 序列号读取后,所有后续数据读取不会在"看到序列号之前"执行
6.4 完整用例:jiffies 与 xtime 的更新
Linux 内核的 get_seconds() 和 current_kernel_time() 就使用了 seqlock 模式:
struct timespec64 get_time(void)
{
struct timespec64 now;
unsigned int seq;
do {
seq = read_seqbegin(&xtime_lock);
now = xtime;
// 可能还有其他依赖 xtime 的字段
} while (read_seqretry(&xtime_lock, seq));
return now;
}
void update_time(struct timespec64 new_time)
{
write_seqlock(&xtime_lock);
xtime = new_time;
write_sequnlock(&xtime_lock);
}
6.5 Seqlock vs RCU 选型指南
| 维度 | RCU | Seqlock |
|---|---|---|
| 读端开销 | 几乎为 0 | 重试循环(有写时) |
| 写端开销 | 等待 Grace Period | 仅 spinlock |
| 写端阻塞读端 | 否 | 不阻塞,但读端需重试 |
| 适用场景 | 读多写少 | 写多(或读写均衡),简单数据结构 |
| 读者一致性 | 看到完整旧数据或完整新数据 | 需要重试保证一致性 |
七、实战五:Per-CPU 变量与内存屏障
per-CPU 变量是 Linux 内核最高效的并发机制之一——完全无锁、无缓存一致性流量。
7.1 per-CPU 的内存序保证
// 声明 per-CPU 变量
static DEFINE_PER_CPU(struct stats, cpu_stats);
// 在 CPU 0 上:读取其它CPU 的 per-CPU 变量需要内存屏障
unsigned long get_cpu1_count(void)
{
unsigned long val;
// preempt_disable 防止被抢占导致 CPU 迁移
preempt_disable();
// 需要 rmb 确保读取时看到的是最新写入的值
val = per_cpu(cpu_stats.count, 1);
preempt_enable_no_resched();
return val;
}
7.2 this_cpu_* 操作
this_cpu_* 操作是 RMW(read-modify-write)操作在单个 CPU 上的优化版本,它们比 atomic_* 更快:
// 启用抢占时的 this_cpu_inc
this_cpu_inc(stats.count);
// 等价于:
unsigned long __percpu *ptr = get_cpu_ptr(&stats.count);
(*ptr)++;
put_cpu_ptr(&stats.count);
// 但要读取其它 CPU 的 per-CPU 值,必须配合抢占和屏障:
this_cpu_add(stats.count, 1); // 本 CPU:无竞争
per_cpu(stats.count, 1); // 其它 CPU:需要序列化
7.3 percpu_refcount 实战
Linux 内核的 percpu_ref 结合了 per-CPU 高效性和 RCXGrace Period 的安全性:
struct percpu_ref ref;
struct my_device *dev;
// 初始化
int ret = percpu_ref_init(&ref, release_fn, 0, GFP_KERNEL);
if (ret) return ret;
// 获取引用(per-CPU 路径:极快)
percpu_ref_get(&ref);
// 切换到 atomic 模式,为销毁做准备
// 调用后,所有新的 get 请求将失败
percpu_ref_kill(&ref);
// 等待所有 per-CPU 引用释放
// 内部会处理同步,并调用 release_fn
percpu_ref_exit(&ref);
八、硬件视角:ARM64 内存模型深度解读
8.1 ARM64 是弱序架构(Weak Memory Model)
与 x86 的 TSO(Total Store Order)不同,ARM64 允许更多的重排序:
允许的 ARM64 重排序:
读-读重排(Load-Load Reordering)
读-写重排(Load-Store Reordering)
写-写重排(Store-Store Reordering) ← 仅对 non-device memory
写-读重排(Store-Load Reordering)
保证的顺序:
地址依赖(Address Dependency):LDR X0, [X1]; LDR X2, [X0] → 不重排
控制依赖(Control Dependency):CBZ X0, label; LDR X2, [X1] → 不重排
8.2 DMB/DSB/ISB 指令家族
// 数据内存屏障(Data Memory Barrier)
dmm oshld // 仅Inner Shareable域的读
dmb oshst // 仅Inner Shareable域的写
dmb sy // 全系统
// 数据同步屏障(Data Synchronization Barrier)
// 比 DMB 更强:等待到所有流水线排空
dsb sy
// 指令同步屏障(Instruction Synchronization Barrier)
// 刷新流水线,确保之前指令全部生效后才取新指令
isb sy
8.3 LDAR/STLR:Load-Acquire/Store-Release
ARMv8 引入了专门的 LSE(Large System Extensions)指令:
ldar w0, [x1] // Load-Acquire:读+acquire 语义
stlr w2, [x1] // Store-Release:写+release 语义
// 对应的内联 asm(GCC/Clang):
#define load_acquire(p) \
({ \
typeof(*p) __l; \
switch (sizeof(__l)) { \
case 4: \
asm volatile ("ldar %w0, [%1]" \
: "=r" (__l) : "r" (p)); \
break; \
case 8: \
asm volatile ("ldar %0, [%1]" \
: "=r" (__l) : "r" (p)); \
break; \
} \
__l; \
})
#define store_release(p, v) \
do { \
switch (sizeof(*p)) { \
case 4: \
asm volatile ("stlr %w1, [%0]" \
:: "r" (p), "r" (v) : "memory");\
break; \
case 8: \
asm volatile ("stlr %1, [%0]" \
:: "r" (p), "r" (v) : "memory");\
break; \
} \
} while (0)
8.4 完整的 StoreBuffer 交互模型
CPU0 (写端) CPU1 (读端)
────────── ──────────
STR x0, [data] LDR x1, [data]
↓ (进入 Store Buffer) ...
DMB STR x1, [flag] LDAR x2, [flag]
↓ (等待 data 刷入缓存) ↓ (acquire:等待所有之前读完成)
↓ 接着读 data
store buffer flush! LDR x3, [data]
↓
LDAR 保证了在看到 flag=1 时
一定能看到 data 的最
九、内核调试:如何发现内存屏障 Bug
9.1 KCSAN(Kernel Concurrency Sanitizer)
Linux 5.8+ 引入的 KCSAN 是检测数据竞争的工具,它不需要额外的锁或屏障注解:
# 编译内核时开启
CONFIG_KCSAN=y
# 运行时检查(有约 10-50% 开销)
echo 1 > /sys/kernel/debug/kcsan/enable
# 编译期增加 KCSAN 注解到自定义代码
#include <linux/kcsan.h>
// 标记为原子访问
kcsan_disable_current();
shared_var = new_value;
kcsan_enable_current();
9.2 LOCKDEP 内存序验证
LOCKDEP 除了死锁检测外,也验证 lock_seqcount_begin/end 等序列化模式是否正确配对。
9.3 常见误区排查清单
✗ 错误 1:在 RCU 读端访问需要同步的字段
rcu_read_lock();
val = ptr->field; // ← 如果写端可以通过非 RCU 路径修改 field,这就是 race!
...
✗ 错误 2:忘记 synchronize_rcu() 就 kfree
rcu_assign_pointer(g_ptr, new);
kfree(old); // ← 还有读端可能持有 old,崩溃!
✗ 错误 3:用 smp_mb() 代替 acquire/release,过度同步
atomic_set(&flag, 1);
smp_mb(); ← 应该用 smp_store_release,性能更好
...
✓ 正确做法:明确 happens-before 关系,用最小代价的屏障
十、前沿进展:C23/C++26 与内核趋势
10.1 标准 C11 原子与内核封装
虽然内核使用 atomic_t,但新的子系统开始采用标准接口:
// 标准 C
_Atomic(int) counter = 0;
atomic_fetch_add_explicit(&counter, 1, memory_order_release);
int v = atomic_load_explicit(&counter, memory_order_acquire);
// 对应的 Linux 内核等价物
atomic_t counter = ATOMIC_INIT(0);
atomic_inc_return_release(&counter); // 实际不存在,示意
int v = atomic_read_acquire(&counter);
10.2 Rust for Linux 的内存序封装
Rust-for-Linux 项目对内存屏障进行了安全封装:
// rust/kernel/sync/atomic.rs(示意)
pub struct AtomicI32 {
v: UnsafeCell<i32>,
}
impl AtomicI32 {
pub fn load(&self, order: Ordering) -> i32 {
let val = unsafe { self.v.get().read_volatile() };
match order {
Ordering::Acquire => {
// smp_load_acquire 语义(ARM64 上的 DMB ISH)
barrier::load_acquire_barrier();
}
Ordering::Relaxed => {}
_ => unreachable!(),
}
val
}
}
10.3 ai-mL 硬件验证
新的学术方向是使用机器学习预测不同内存序配置下的性能最优解:
输入:程序依赖图、缓存拓扑、访问模式
┌─────────────────────┐
│ ML Memory Order │
│ Optimizer │
└─────────────────────┘
输出:最优内存序参数
relaxed → acquire/release → seq_cst
直至满足性能和正确性约束
十一、总结
| 原语 | 使用场景 | 性能开销 |
|---|---|---|
barrier() |
仅需防止编译器重排 | 零(仅编译时) |
READ_ONCE/WRITE_ONCE |
访问可能被并发修改的变量(无 RMW) | 零(x86)/ 极低 |
smp_rmb/wmb/mb() |
精细控制 CPU/缓存可见顺序 | 高(50-200 cycles) |
smp_load_acquire/store_release |
生产者-消费者模式 | 中(20-80 cycles) |
rcu_read_lock/unlock() |
读多写少场景 | 极低(~5 cycles) |
synchronize_rcu() |
等待读端退出 | 非常高(ms级) |
seqlock |
写多、读需一致性 | 中等(重试成本) |
percpu |
本 CPU 访问 | 零(无竞争) |
核心心智模型:
- 屏障不产生"即时同步",只建立因果关系链
- 编译器屏障 ≠ CPU 屏障,需要两者配合
- 最低代价原则:先用
READ_ONCE/WRITE_ONCE,不够用再用 acquire-release,最坏情况才用 full fence - 没有免费午餐:正确性和性能是 trade-off,选错了轻则崩溃慢则数据损坏
- 《Is Parallel Programming Hard, And, If So, What Can You Do About It?》Paul E. McKenyon(圣经级文档)
- Linux 内核文档
Documentation/memory-barriers.txt(最权威的技术文档) - ARM64 Architecture Reference Manual: B2.3 (Memory Model)
- Preshing on Programming: https://preshing.com (优秀的通俗文章)

发表评论 取消回复