os操作系统——第8讲:多核并发与内存屏障
目录
第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 乱序)
考虑两个核心共享变量 data 和 flag:
| 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_ready 或 get_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 协议如何保证多核缓存一致性,以及“伪共享”这个性能杀手是如何悄悄拖慢你的系统。
✍️ 思考与练习
- 验证重排序:在 x86 和 ARM 开发板上运行著名的“Peterson 算法”或“Dekker 算法”测试,观察是否出现意料之外的结果。然后加入内存屏障,再次测试。
- 实现自旋锁:在 QEMU 模拟的双核 RISC-V 上,用内联汇编实现
amoswap原子交换,并加上正确的fence指令。比较有/无屏障时的并发累加结果。 - 思考死锁:如果一个核心在持有自旋锁时被中断,而中断处理函数中又试图获取同一个自旋锁,会发生什么?如何避免?(提示:中断中不应获取锁,或使用“关中断+自旋锁”双重保护)
- 阅读资料:查阅你所用 CPU 的架构参考手册,了解其内存模型(x86 TSO,ARM Weakly Ordered)以及推荐的屏障宏。
欢迎在评论区分享你遇到过的乱序 Bug 或调试心得!我们下一讲再见。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)