第8讲:看不见的敌人——多核并发与内存屏障

上一讲我们让两个核心跑了起来,全局就绪队列也加上了自旋锁。
你信心满满地运行测试:两个任务各自对一个共享变量累加 100 万次,结果…… 居然还是错的!
更诡异的是,即使你用了自旋锁,偶尔还是会出现数据不一致。

调试了一整天,你终于发现:原来 CPU 会悄悄地打乱指令顺序
你在代码里写着 a=1; flag=1;,在另一个核心看来,却可能先看到 flag=1,后看到 a=1
这就是多核并发世界最隐秘的敌人——内存乱序执行
而打败它的利器,叫 内存屏障


1. 关中断在多核下失效了

单核时代,我们用 enter_critical() 关中断来保护共享数据,因为任务切换只发生在中断边界。
多核下,即使当前核心关了中断,其他核心依然可以并行访问同一块内存

考虑下面这个简单例子:两个核同时执行 count++(count 初始 0)。
我们为它加上自旋锁(spinlock):

spin_lock(&lock);
count++;
spin_unlock(&lock);

如果自旋锁实现正确,最终 count 应该是 2。但现实往往不如人意:即使加了锁,在某些 CPU 上仍然可能出错——原因不在锁的逻辑,而在 CPU 对内存访问的“重排序”。


2. CPU 的“小聪明”:指令重排序

为了提高执行效率,现代 CPU 采用流水线乱序执行技术。
硬件会动态调整指令顺序,填充分支延迟、缓存未命中等“气泡”,只要不改变单线程的语义就行。

2.1 编译器也会重排

即便 CPU 不乱序,编译器在优化时(-O2)也可能重排指令。例如:

// 原始代码
x = 1;
y = 2;

// 可能被编译器优化为(如果 x 和 y 无依赖)
y = 2;
x = 1;

这对单线程无影响,但对多线程却是灾难。

2.2 CPU 硬件重排序的例子(经典 StoreStore 乱序)

考虑两个核心共享变量 dataflag

Core 0 (写入) Core 1 (读取)
data = 123; while (flag == 0) {}
flag = 1; assert(data == 123);

直觉上,Core 1 看到 flag 变成 1 时,data 肯定已经是 123。
但在弱内存模型(ARM, PowerPC, RISC-V)的 CPU 上,可能发生:

  • Core 0 实际执行:先写 flag(缓存命中,很快),后写 data(缓存未命中,慢)。
  • Core 1 看到 flag == 1 时,Core 0 还没把 data 写回内存,Core 1 的缓存里 data 可能是旧值。
    于是 assert(data == 123) 失败。

这就是著名的 StoreStore 重排序(写-写乱序)。
x86 是强内存模型,通常不会重排 StoreStore,但仍有其他类型的乱序(如 StoreLoad)。


3. 内存屏障:下达“秩序令”

内存屏障(Memory Barrier)是一条特殊指令,告诉 CPU 和编译器:屏障前后的内存访问顺序必须严格保持

常见的屏障类型:

  • smb_mb()(全屏障):禁止所有 Load/Load、Load/Store、Store/Store、Store/Load 越过屏障。
  • smp_wmb()(写屏障):仅保证屏障前的写操作在屏障后的写操作之前完成(对其他核可见)。
  • smp_rmb()(读屏障):类似,仅保证读顺序。
  • smp_mb()(Linux 内核常用):全屏障,等价于 mb()

在我们的 flag 例子中,Core 0 需要加一个写屏障:

data = 123;
smp_wmb();    // 保证 data 写入 global 可见后,才执行 flag 的写入
flag = 1;

Core 1 则需要一个读屏障:

while (flag == 0) {}
smp_rmb();    // 保证 flag 读取之后才读取 data
assert(data == 123);

4. 自旋锁的正确实现:离不开内存屏障

自旋锁的原理:用一个整型变量 lock,0 表示未锁,1 表示锁定。
lock 操作必须是原子的:测试并设置(Test-And-Set)或比较并交换(CAS)。

4.1 错误的自旋锁(无屏障)

void spin_lock(int *lock) {
    while (1) {
        if (*lock == 0) {
            *lock = 1;
            break;
        }
    }
}

这个实现有两个致命缺陷:

  • 非原子:两个核心可能同时读到 0,然后同时写 1,都认为自己拿到了锁。
  • 无屏障:即使用了原子操作(如 xchg),CPU 仍可能把锁内的代码重排到锁外。

4.2 基于原子交换的正确实现(x86)

void spin_lock(int *lock) {
    while (__sync_lock_test_and_set(lock, 1)) {
        // 自旋等待
        while (*lock) cpu_relax();
    }
    smp_mb();   // 获取锁后,防止临界区内操作被重排到锁外
}

void spin_unlock(int *lock) {
    smp_mb();   // 释放锁前,确保临界区内所有操作已完成
    __sync_lock_release(lock);  // 写 0,原子释放
}

关键点

  • 使用 GCC 内置原子操作 __sync_lock_test_and_set(或 C11 的 atomic_exchange)。
  • 在获取锁后加 smp_mb(),防止临界区代码被拉到锁判断之前。
  • 在释放锁前加 smp_mb(),防止临界区代码被拉到锁清空之后。

在 ARM/RISC-V 上,原子交换指令本身通常不隐含屏障,因此必须显式插入 dmb(Data Memory Barrier)


5. 自旋锁的代价与适用场景

自旋锁简单,但缺点明显:

  • 忙等待:如果锁被长时间持有,等待的核心会一直自旋(消耗电量、浪费流水线)。
  • 不公平:可能导致饥饿(一个核心反复拿到锁,另一个总在等)。
  • 优先级反转:低优先级的核心持有锁,高优先级核心自旋等待——无法让低优先级核心让出 CPU(因为自旋不调度)。

因此,自旋锁只适合临界区极短(几十个指令)的场景,比如保护队列的头尾指针。
对于可能阻塞的操作(如等待 I/O),必须使用睡眠锁(mutex),让任务进入阻塞态,而不是自旋。

在多核 OS 中,调度器自身(如就绪队列)通常用自旋锁保护,因为操作极快。


6. 实战:用自旋锁保护全局就绪队列(完整示例)

回到第 7 讲的全局队列模型,加上正确的自旋锁和内存屏障:

// 全局就绪队列(链表)
tcb_t *ready_list;
spinlock_t ready_lock;

// 向就绪队列添加任务(例如在任务创建或唤醒时)
void add_to_ready(tcb_t *task) {
    spin_lock(&ready_lock);
    task->next = ready_list;
    ready_list = task;
    spin_unlock(&ready_lock);
}

// 从就绪队列取出最高优先级任务
tcb_t *get_from_ready(void) {
    tcb_t *task = NULL;
    spin_lock(&ready_lock);
    if (ready_list) {
        task = ready_list;
        ready_list = ready_list->next;
    }
    spin_unlock(&ready_lock);
    return task;
}

每个核心的调度循环:

void core_scheduler_loop(void) {
    while (1) {
        tcb_t *next = get_from_ready();
        if (next == NULL) {
            // 无任务,等待 IPI 唤醒
            wfi();
            continue;
        }
        // 做上下文切换(注意:切换时需要关本地中断,但不需要持有 ready_lock)
        switch_to(next);
    }
}

这样,即使多个核心同时调用 add_to_readyget_from_ready,自旋锁也能保证队列操作的原子性,且内存屏障确保链表指针的可见性。


7. 内存屏障的陷阱:不要过度使用

屏障会阻止 CPU 优化,带来性能损失(几十到上百个周期)。
滥用屏障会让你的多核程序比单核还慢。

原则

  • 尽量使用高层次的同步原语(mutex, semaphore),它们内部已经包含了必要的屏障。
  • 只有在你手写无锁数据结构或直接操作共享内存时才显式使用屏障。
  • 对于 x86,大多数情况下普通 lock 前缀的指令已隐含全屏障,无需额外 mfence;但对 ARM/RISC-V,几乎每个原子操作都需要配对屏障。

可移植性:推荐使用 C11 标准原子库(stdatomic.h),它会自动生成合适的屏障:

#include <stdatomic.h>
atomic_int lock = 0;
void spin_lock() {
    int expected = 0;
    while (!atomic_compare_exchange_weak(&lock, &expected, 1)) {
        expected = 0;
    }
    atomic_thread_fence(memory_order_acquire);
}
void spin_unlock() {
    atomic_thread_fence(memory_order_release);
    atomic_store(&lock, 0);
}

8. 本讲小结 & 下集预告

今天我们从“加了锁还出错”的抓狂场景出发,终于揭开了多核并发最隐蔽的面纱:

  • 指令重排序(编译器和 CPU 都会做)让内存访问顺序变得不可预测。
  • 内存屏障 强制顺序,是正确实现锁和原子操作的基石。
  • 自旋锁 必须结合原子操作和屏障,才能保护共享数据。
  • 自旋锁只适合短临界区,长时间等待必须用睡眠锁。

现在,我们的双核 OS 终于可以正确运行了。但还有一个更底层的恶魔在沉睡:缓存一致性
即使代码顺序正确,每个核心的私有缓存仍可能导致数据不一致——一个核心改了变量,另一个核心却读到旧值,连锁都救不了(因为锁变量本身也可能被缓存)。

下一讲:每个核的心中都有一个“私有缓存”——MESI 协议与伪共享
我们将深入 CPU 缓存架构,了解 MESI 协议如何保证多核缓存一致性,以及“伪共享”这个性能杀手是如何悄悄拖慢你的系统。


✍️ 思考与练习

  1. 验证重排序:在 x86 和 ARM 开发板上运行著名的“Peterson 算法”或“Dekker 算法”测试,观察是否出现意料之外的结果。然后加入内存屏障,再次测试。
  2. 实现自旋锁:在 QEMU 模拟的双核 RISC-V 上,用内联汇编实现 amoswap 原子交换,并加上正确的 fence 指令。比较有/无屏障时的并发累加结果。
  3. 思考死锁:如果一个核心在持有自旋锁时被中断,而中断处理函数中又试图获取同一个自旋锁,会发生什么?如何避免?(提示:中断中不应获取锁,或使用“关中断+自旋锁”双重保护)
  4. 阅读资料:查阅你所用 CPU 的架构参考手册,了解其内存模型(x86 TSO,ARM Weakly Ordered)以及推荐的屏障宏。

欢迎在评论区分享你遇到过的乱序 Bug 或调试心得!我们下一讲再见。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐