一、从一道面试题说起
面试官抛出一个经典问题:"两个 CPU 核心同时执行 a=1 和 b=1,之后各自读取对方的变量,理论上可能读到旧值吗?"很多人的直觉是"不可能",但答案是——在某些硬件上确实可能,在 Linux 内核中则取决于你用了什么屏障。
这个问题的背后,是 CPU 为了性能而做的各种"投机取巧":Store Buffer、Invalidate Queue、预测执行、Store Forwarding。理解这些机制,是写出高性能并发代码的前提,也是阅读 Linux 内核 spinlock、RCU、无锁数据结构的钥匙。
本文将从缓存一致性协议出发,逐层递进:
- CPU 缓存架构与 MESI 协议
- Store Buffer 与 Invalidate Queue:为什么需要屏障
- 四类内存屏障(LoadLoad/StoreStore/LoadStore/StoreLoad)
- Linux 内核屏障原语 (smp_mb/smp_rmb/smp_wmb) 与 RCU 中的应用
- 写入 Store Buffer(极快,几纳秒)
- 继续执行后续指令(不等待 Invalidate ACK)
- 等 ACK 到达后,再将脏数据刷入 L1 缓存
- 将消息存入 Invalidate Queue(不立即失效)
- 回复 ACK
- 等到合适时机才真正执行失效
- 核心 A:a=1 写入 Store Buffer,flag=1 写入 Store Buffer(可能先被刷出)
- 核心 B:flag 的新值先到达(因为 Queue 延迟旧失效),看到的 a 仍为旧值
- 需要写屏障确保 a=1 先于 flag=1 对其他核心可见
- writer 加锁:
sequence++(变为奇数)用 smp_wmb() - writer 解锁:smp_wmb() 后
sequence++(变为偶数) - reader 读前:smp_rmb()
- reader 读后:smp_rmb()
- 能不用屏障就不用:SPSC 用 acquire/release,seq_cst 只在全局顺序需要时使用
- 消除伪共享:ALIGN(64) 关键变量,per-cpu 结构体
- 避免无谓的锁:读多写少用 seqlock 或 RCU
- NUMA 感知:per-cpu 变量天然 NUMA 友好
- ABA 问题:用 double-word CAS 或 Tagged Pointer
- Hazard Pointer:用于无锁内存回收(比 RCU 更灵活)
我将通过 x86-TSO 和 ARM/PowerPC 的对比来理解内存模型,然后深入探讨伪共享的检测与消除,接着剖析无锁环形缓冲区在 DPDK 和 Linux 中的实现,最后延伸到无锁引用计数和无锁任务调度器的生产级应用。
二、缓存一致性协议:MESI 详解
现代多核 CPU 每个核心拥有独立 L1/L2 缓存,共享 L3 缓存。当多个核心读写同一内存地址时,缓存一致性协议保证每个核心看到的数据最终一致。
2.1 MESI 四状态模型
| 状态 | 全称 | 含义 |
|---|---|---|
| M (Modified) | 已修改 | 缓存行已被核心修改,与内存不一致,独占拥有 |
| E (Exclusive) | 独占 | 缓存行与内存一致,且其他核心没有副本 |
| S (Shared) | 共享 | 缓存行与内存一致,其他核心也可能有副本 |
| I (Invalid) | 无效 | 缓存行内容不可用(未缓存或已被他人修改) |
状态转换示例:
核心0读X → 若其他核心无副本:E状态;其他核心有:S状态
核心0写X → M状态,同时通过总线发送Invalidate消息使其他核心缓存行失效
核心1再读X → 从核心0或内存获取最新数据,两核心均为S状态
2.2 MOESI 与 MESIF:协议演进
MOESI 增加 Owned (O) 状态:当某核心修改数据后,其他核心仍持有共享副本时,该核心进入 O 状态,负责在未来将数据写回内存。AMD 使用此协议。
MESIF 增加 Forward (F) 状态:指定一个响应者为"转发者",减少总线流量。Intel 使用此协议。
2.3 缓存行的真相
CPU 缓存管理以 缓存行(Cache Line) 为单位,现代处理器通常为 64 字节(少数 ARM 为 128 字节)。这意味着:
struct foo {
atomic_int counter; // 4B
atomic_int flags; // 4B
char padding[56]; // 56B
}; // 整个结构刚好一个缓存行
核心矛盾:缓存一致性协议以缓存行为粒度运作。两个核心频繁读写同一缓存行上的不同变量时,会导致"乒乓效应"——缓存行反复在 M/S/I 间跳动,严重拖慢速度。这就是伪共享(False Sharing)。
三、Store Buffer 与 Invalidate Queue:屏障需求的根源
如果核心写操作必须等待其他核心的 Invalidate ACK 后再继续,性能会不可接受。因此现代 CPU 引入了两级缓冲:
3.1 Store Buffer
当核心执行写操作时:
这导致:核心自身能"看到"自己的写入(通过 Store Forwarding),但其他核心可能暂时看不到。
3.2 Invalidate Queue
同理,核心收到 Invalidate 消息时:
这导致:核心失效操作被延迟,此时读取可能拿到已过时的值。
3.3 经典示例:为什么需要屏障
// 核心 A // 核心 B
a = 1; while (flag == 0) { /* spin */ }
flag = 1; assert(a == 1); // ❓ 可能失败吗?
在弱内存模型(ARM/PowerPC)上:
四、Linux 内核内存屏障原语
Linux 内核提供层次化的屏障原语,屏蔽架构差异:
4.1 四类屏障
| 原语 | 类型 | 保证 | 典型用途 |
|---|---|---|---|
smp_mb() | 全屏障 | 所有 Load/Store 在屏障两侧有序 | Dekaker 锁、临界区边界 |
smp_rmb() | 读屏障 | Load 在屏障两侧有序 | 读取数据前先检查标志位 |
smp_wmb() | 写屏障 | Store 在屏障两侧有序 | 生产者先写数据再设标志 |
smp_store_release() | Store-Release | 屏障前的 Store 先于屏障后的所有操作 | Lock-free 发布 |
smp_load_acquire() | Load-Acquire | 屏障后的 Load 晚于屏障前的所有操作 | Lock-free 获取 |
4.2 各架构实现
// x86 (TSO模型,硬件保证几乎所有顺序)
#define smp_mb() asm volatile("lock; addl $0,0(%%esp)" ::: "memory")
#define smp_rmb() barrier() // 编译器屏障即可
#define smp_wmb() barrier() // x86 硬件保证 Store 顺序
// ARM64 (弱内存模型,需要显式指令)
#define smp_mb() asm volatile("dmb ish" ::: "memory")
#define smp_rmb() asm volatile("dmb ishld" ::: "memory")
#define smp_wmb() asm volatile("dmb ishst" ::: "memory")
// Store-Release / Load-Acquire (ARM64)
#define smmp_store_release(p, v) \
asm volatile("stlr %w1, %0" : "=Q"(*p) : "r"(v) : "memory")
#define smp_load_acquire(p) \
asm volatile("ldar %w0, %0" : "=r"(*(p)) : "Q"(*(p)) : "memory")
4.3 RCU 中的屏障应用
RCU (Read-Copy-Update) 是 Linux 内核最精妙的机制之一,核心思想:读者零开销(无需屏障),写者通过 Grace Period 协调更新。
// RCU 读者(无需任何屏障)
rcu_read_lock();
p = rcu_dereference(head); // 一次 Load-Acquire(防止预测执行跨越)
if (p) {
do_something_with(p);
}
rcu_read_unlock();
// RCU 写者
new_item = kmalloc(sizeof(*new_item), GFP_KERNEL);
new_item->next = head->next;
new_item->data = new_data;
smp_store_release(&head->next, new_item); // Store-Release
// 或调用 call_rcu() 调度异步回收旧数据
RCU 的关键约束:rcu_dereference() 在读者侧提供 Load-Acquire,rcu_assign_pointer() 在写者侧提供 Store-Release。这两者保证了:写者先初始化数据再发布指针,读者拿到指针后能看到完整初始化的数据。
五、伪共享:高性能计算的隐形杀手
5.1 诊断:如何发现伪共享
伪共享的典型症状:并发度越高,性能反而越差——16 核跑不过 4 核。
使用 perf 检测:
# 记录 L1 缓存未命中
perf stat -e L1-dcache-load-misses,L1-dcache-loads ./your_program
# 使用 perf c2c (Cache-to-Cache) 精确检测伪共享
sudo perf c2c record -F 10000 --all-user ./your_program
sudo perf c2c report -c pid, tid, iaddr --full-symbols
perf c2c 输出中,关注 RMT (Remote Cache Hit Modified) .hitm 事件——表示另一核心持有你要读取的行的 Modified 状态,这是伪共享的确定性证据。
5.2 六种伪共享消除方案
// 方案1:缓存行对齐填充
struct aligned_counter {
atomic_long value;
char padding[64 - sizeof(atomic_long)]; // 填充到64B
} ____cacheline_aligned;
// 方案2:编译器属性(GCC/Clang)
struct counter {
atomic_long value;
} __attribute__((aligned(CACHE_LINE_SIZE)));
// 方案3:C11 alignas
struct alignas(std::hardware_destructive_interference_size) Counter {
std::atomic value;
};
// 方案4:per-cpu 变量(Linux 内核)
DEFINE_PER_CPU(struct stats, cpu_stats);
// 每个核心操作自己的副本,最后汇总
// 方案5:局部变量 + 批量提交
// 在内循环中使用 per-thread 局部计数器,循环结束后一次性合并
// 方案6:volatile 不实用,应该用原子操作
// 注意:volatile 不保证原子性,需要 std::atomic 或 __atomic_*
5.3 实战:伪共享对自旋锁的影响
Linux 内核 ticket spinlock (3.18 之前) 伪共享案例:
// 老实现:next 和 owner 在同一缓存行
struct raw_spinlock {
unsigned int next; // 下一个排队号
unsigned int owner; // 当前持有者
};
// 问题:未被持有的锁,每轮询一次 owner(S 状态),但其他核心的
// ticket_spin_unlock() 将该行变为 I 状态(因为 owner 被写)→ 反复回滚
// 修复:Linux 4.2 + 调整为分离字段(或 padding)
六、无锁数据结构的工程化实现
6.1 C11 memory_order 选择指南
| memory_order | 允许重排 | 开销 | 适用场景 |
|---|---|---|---|
| relaxed | 任意重排 | 无原子开销(仅保证原子性) | 计数器(仅最终一致性) |
| consume | 数据依赖有序 | 架构几乎不用 | RCU 指针加载 |
| acquire | 后续重排之前 | 读屏障 | 锁获取、数据读取 |
| release | 之前重排之后 | 写屏障 | 锁释放、数据发布 |
| acq_rel | 两侧有序 | 读写屏障 | fetch_add 等 RMW |
| seq_cst | 全序 | 全屏障 | 默认,保证全局一致 |
6.2 无锁单生产者单消费者 (SPSC) 环形缓冲区
这是 DPDK rte_ring 和内核 IPC 的核心:
#include <stdatomic.h>
#define RING_SIZE 1024 // 必须是2的幂
typedef struct {
int buffer[RING_SIZE];
alignas(64) atomic_size_t head; // 生产者写
alignas(64) atomic_size_t tail; // 消费者写
} spsc_ring_t;
int spsc_enqueue(spsc_ring_t *r, int value) {
size_t head = atomic_load_explicit(&r->head, memory_order_relaxed);
size_t tail = atomic_load_explicit(&r->tail, memory_order_acquire);
if ((head - tail) >= RING_SIZE)
return -1; // 满
r->buffer[head % RING_SIZE] = value; // ① 写数据
atomic_thread_fence(memory_order_release); // ② Store barrier
atomic_store_explicit(&r->head, head + 1, memory_order_release);
return 0;
}
int spsc_dequeue(spsc_ring_t *r, int *value) {
size_t tail = atomic_load_explicit(&r->tail, memory_order_relaxed);
size_t head = atomic_load_explicit(&r->head, memory_order_acquire);
if (head == tail)
return -1; // 空
*value = r->buffer[tail % RING_SIZE]; // ① 读数据(在 acquire 后)
atomic_store_explicit(&r->tail, tail + 1, memory_order_release);
return 0;
}
关键:生产者用 memory_order_release 抬起 head,保证 buffer 写入先于 head 更新被消费者看到;消费者用 memory_order_acquire 读 head,保证看到最新 head 后也看到完整写入的 buffer。
6.3 无锁引用计数 (Lock-Free RefCount)
typedef struct {
_Atomic int ref_count;
void *data;
} ref_counted_t;
void ref_acquire(ref_counted_t *obj) {
// relaxed 即可:我们只关心计数递增,不关心数据可见性
atomic_fetch_add_explicit(&obj->ref_count, 1, memory_order_relaxed);
}
void ref_release(ref_counted_t *obj) {
// 必须用 release:保证之前的析构操作先于计数归零被看到
if (atomic_fetch_sub_explicit(&obj->ref_count, 1, memory_order_release) == 1) {
atomic_thread_fence(memory_order_acquire); // 获取围栏:确保看到所有之前的修改
free(obj->data);
free(obj);
}
}
为什么 fetch_sub 用 release 而非 relaxed?因为如果最后一个持有者在释放前做了修改,其他并发 release 线程可能未看到这些修改——release 确保所有之前的写操作在计数归零前被所有线程观察到。
6.4 无锁任务窃取队列 (Work-Stealing)
Cilk、Go scheduler、Tokio 的核心调度算法,每个 worker 有自己的双端队列:
typedef struct {
_Atomic int64_t top, bottom;
task_t **ring; // 环形数组
} work_stealing_deque;
// 仅本 worker 调用
void local_push(work_stealing_deque *d, task_t *task) {
int64_t b = atomic_load_explicit(&d->bottom, memory_order_relaxed);
d->ring[b % RING_SIZE] = task;
atomic_thread_fence(memory_order_release); // 保证 task 写入先于 b+1 可见
atomic_store_explicit(&d->bottom, b + 1, memory_order_relaxed);
}
// 仅本 worker 调用
task_t *local_pop(work_stealing_deque *d) {
int64_t b = atomic_load_explicit(&d->bottom, memory_order_relaxed) - 1;
atomic_store_explicit(&d->bottom, b, memory_order_relaxed);
atomic_thread_fence(memory_order_seq_cst); // 防止与 steal 重排
int64_t t = atomic_load_explicit(&d->top, memory_order_relaxed);
if (t <= b) {
task_t *task = load_consume(&d->ring[b % RING_SIZE]); // consume
if (t == b) {
// 最后一个任务,需要与 steal 竞争
if (!atomic_compare_exchange_strong(&d->top, &t, t + 1))
task = NULL;
}
return task;
}
atomic_store_explicit(&d->bottom, b + 1, memory_order_relaxed);
return NULL;
}
// 其他 worker 调用
task_t *steal(work_stealing_deque *d) {
int64_t t = atomic_load_explicit(&d->top, memory_order_acquire);
atomic_thread_fence(memory_order_seq_cst);
int64_t b = atomic_load_explicit(&d->bottom, memory_order_acquire);
if (t < b) {
task_t *task = d->ring[t % RING_SIZE];
if (atomic_compare_exchange_strong(&d->top, &t, t + 1))
return task;
}
return NULL; // 失败(竞争丢失)或为空
}
七、生产环境中的内存顺序策略
7.1 从 Linux 内核 spinlock 学习
Linux 5.x spinlock 实现(ARM64):
// qspinlock: 快速路径用原子 swap 或 CAS
static __always_inline void queued_spin_lock(struct qspinlock *lock)
{
u32 val = atomic_fetch_add(1, &lock->val); // 等待锁
if (val == 0)
return; // 快速获取
// ... 慢路径:排队等待,使用 pvqspinlock 避免在虚拟化场景下无意义自旋
// 在排队时使用 smp_mb() 保证前面的操作完成
}
static __always_inline void queued_spin_unlock(struct qspinlock *lock)
{
atomic_store(0, &lock->val); // store-release 隐含
// stlr 指令:所有之前的 Store 和 Load 先于 unlock 可见
}
要点:lock 获取 = acquire,release = release。这保证了临界区内的所有操作不会被重排到 lock 之前或 unlock 之后——这正是互斥的基础。
7.2 DPDK rte_ring 的内存屏障选择
DPDK 选择 memory_order_acquire/release 而非 seq_cst:
// DPDK enqueue (简化)
// 1. 使用 head 作为 SPSC 的信号
// 2. 生产者 release 抬起 head → 消费者 acquire 读 head → 看到完整入队的数据
// 3. 仅一个全屏障在 free_head 判断时使用
// 实测影响:seq_cst 在 x86 上无明显差异(因为 TSO),
// 但在 ARM 上 release/acquire 比 seq_cst 快 ~10%
7.3 避免的陷阱
// 陷阱1:错误使用 volatile (不提供原子性或顺序)
volatile int flag = 0; // ❌ 编译器屏障,但不是跨平台的原子操作
atomic_int flag = 0; // ✅ 正确
// 陷阱2:误以为 fetch_add 的默认 order = release
atomic_fetch_add(&cnt, 1); // 默认 seq_cst; 如果不是 RMW 需要用 acq_rel
// 陷阱3:用 spinlock 实现临界区,但内部操作跨越锁定边界
spin_lock(&mutex);
data.x = 1;
spin_unlock(&mutex);
// 另一线程 spin_lock + 读 data.x → 需要 release/acquire 保证可见
// 陷阱4:在循环中使用 relaxed 但依赖数据循环不变式
while (atomic_load(&flag, relaxed)) // ✅ 监控 flag,读 buffer 必须 acquire
atomic_load(&head, acquire) // ❌ 之前应该用 acquire
八、进阶:Linux 内核 Lock-Free 设计模式
8.1 kref:内核引用计数
// include/linux/kref.h
struct kref {
refcount_t refcount; // 使用 refcount_t(检查溢出)
};
static inline void kref_get(struct kref *kref)
{
refcount_inc(&kref->refcount); // memory_order_relaxed
}
static inline int kref_put(struct kref *kref, void (*release)(struct kref *))
{
if (refcount_dec_and_test(&kref->refcount)) { // memory_order_release
release(kref); // release 语义保证之前的修改全部可见
return 1;
}
return 0;
}
这与用户态 RefCount 设计一致。注意 refcount_t 与 atomic_t 的区别:refcount_t 会检测下溢和溢出,防止 UAF (Use-After-Free) 和 Reference Leak。
8.2 seqlock:内核超轻量读写锁
// Seqlock 适用于写少读多场景,读者无锁
typedef struct {
seqcount_t sequence; // 偶数=无写,奇数=正在写
spinlock_t lock;
} seqlock_t;
// 读者
unsigned int seq;
do {
seq = read_seqcount_begin(&seqlock);
// ... 临界区读操作
} while (read_seqcount_retry(&seqlock, seq));
// 作者
write_seqlock(&seqlock);
// ... 临界区写操作
write_sequnlock(&seqlock);
Seqlock 是 memory_order_release/acquire 的经典应用:
如果读者看到序列号变更,说明读到了半完成状态,重试即可。
8.3 RCU Lock-Free 风格
RCU 的 call_rcu() 在合适时(所有读者已完成)异步调用回收。最经典的用法是无锁链表:
// 插入
new_entry->next = head;
smp_wmb(); // 必须先初始化再链接
rcu_assign_pointer(head, new_entry);
// 删除
old = head;
new_head = head->next;
rcu_assign_pointer(head, new_head);
call_rcu(&old->rcu_head, free_node); // 异步回收
九、性能实测与调优建议
9.1 屏障开销基准(x86-64)
// 循环 1e9 次原子操作(纳秒/次操作)
relaxed: ~5 ns (无任何屏障)
acquire: ~6 ns (x86 上为 compiler barrier)
release: ~5 ns (x86 上为 compiler barrier)
acq_rel: ~6 ns
seq_cst: ~18 ns (mfence 或 lock 前缀)
// ARM64 (Neoverse N1)
relaxed: ~6 ns
acquire: ~16 ns (dmb ishld)
release: ~16 ns (dmb ishst)
seq_cst: ~32 ns (dmb ish × 2)
9.2 调优清单
十、总结
内存屏障与缓存一致性是所有高性能并发代码的地基。我们用一段话总结全文:
CPU 为了快而投机取巧(Store Buffer/Invalidate Queue),内存屏障是你和硬件之间的契约。完整地写屏障(Store→Release),完整地读屏障(Acquire→Load),它们定义了临界区的边界。选择
memory_order_acquire/release而非seq_cst,是为了让编译器知道数据流的方向,让 CPU 仅做最小必要的等待——性能来自精确的表达,而非盲目保守。
| 学习层次 | 核心概念 | 实战收益 |
|---|---|---|
| 了解 MESI | 缓存行 64B → 乒乓效应 | 理解伪共享(False Sharing) |
| 理解 Store Buffer | 写入可能延迟可见 | 解释为什么需要写屏障 |
| 熟练使用 smp_mb/rmb/wmb | Linux 内核编程 | 阅读 spinlock/RCU 源码不卡壳 |
| 掌握 memory_order | C/C++11 原子 | 手写无锁数据结构 |
| perf c2c 检测 | 数据驱动定位伪共享 | 生产调优从猜测变为科学 |
| 生产级 Ref/RCU/seqlock | Linux 内核 Lock-Free 设计 | 写出真正高性能、可靠的并发系统 |

发表评论 取消回复