从零写OS内核 | 同步原语——spinlock与信号量,操作系统是怎么协调并发访问的
两个进程同时往同一个文件里写数据——进程 A 写前 100 字节,进程 B 写后 100 字节。如果不做任何协调,会发生什么?
进程 A 刚读到一个”当前写入位置”,还没来得及写,进程 B 也读到了同一个位置——两个进程都以为自己应该写在这里。结果是数据交错、文件损坏。这就是竞态条件(Race Condition)——并发访问共享资源时,由于执行顺序的不确定性,导致结果错误。
同步原语(Synchronization Primitives)就是用来解决这个问题的:让并发访问变成串行的、有序的。今天,我们来搞清楚 spinlock 和信号量是怎么实现的。
1. 竞态条件:问题的本质
竞态条件示例:
进程 A 和进程 B 共享变量 `counter = 0`,都想执行 counter++
正确的执行顺序(A 和 B 各执行一次):
counter = 0
A: read counter → 0
A: counter++ → 1
A: write counter → 1
B: read counter → 1
B: counter++ → 2
B: write counter → 2
最终 counter = 2 ✓
但如果执行顺序错乱:
A: read counter → 0
B: read counter → 0 ← A 还没写,B 就读了
A: counter++ → 1
A: write counter → 1
B: counter++ → 1 ← B 基于旧值 0 加的
B: write counter → 1
最终 counter = 1 ✗ ← 错误!两个进程各加了一次,结果只加了 1问题出在哪里?read-modify-write 不是原子操作。要解决这个问题,必须保证同一时间只有一个进程能执行这段代码——这就是互斥(Mutual Exclusion)。
2. spinlock:忙等的互斥锁
spinlock 是最简单的互斥原语——如果锁被占用,CPU 就一直自旋(while loop),直到拿到锁。
spinlock 原理:
struct spinlock {
int locked; // 0=未占用,1=占用
}
void lock(struct spinlock *lock) {
// 如果锁被占用,自旋等待
while (atomic_test_and_set(&lock->locked, 1) == 1) {
// 空循环,不断检查锁是否释放
// 期间 CPU 不能做别的事(busy wait)
}
// 拿到了!locked = 1,退出循环
}
void unlock(struct spinlock *lock) {
// 释放锁
atomic_clear(&lock->locked, 0);
}2.1 atomic_test_and_set:硬件保证的原子性
为什么 while (x == 1) 这样的检查不能保证原子性?因为检查和设置之间可能被其他 CPU 打断。
atomic_test_and_set 是一种 CPU 指令级别的原子操作:
# x86 lock 指令前缀(让下一条指令原子化)
lock xchg %eax, (lock_addr) # 原子交换,返回旧值
# 在 C 语言里对应:
bool test_and_set(int *ptr) {
bool old = *ptr;
*ptr = 1;
return old;
}lock 前缀让 CPU 在执行 xchg 时锁定总线(或者用 cache coherency 机制),保证这个操作不被其他 CPU 打断——这就是原子性的来源。
2.2 spinlock 的问题:多核下的缓存行抖动
多核 CPU 上的 spinlock 性能问题:
CPU0 和 CPU1 共享同一个 lock 变量的缓存行:
CPU0: lock = 0 → 设为 1(拿到锁)→ 缓存行变为 M(Modified,独占)
CPU1: 读取 lock → 发现缓存行是 M → CPU1 需要等待 CPU0 释放
→ 触发 cache coherency protocol(MSI/MESI)的总线事务
CPU0: 释放 lock = 0 → 缓存行变为 I(Invalid)
CPU1: 收到 invalidate → 缓存行变为 S(Shared)→ 可以再次读取
问题:当很多 CPU 同时竞争同一个 spinlock 时,
所有 CPU 都在同一个缓存行上自旋,
缓存行的 invalidate/upgrade 流量会成为瓶颈
→ 这叫 "cache line ping-ponging"解决方案:用每核独立的 flag + CPU pause 指令:
// 改进的 MCS spinlock(每个 CPU 有一个本地节点)
struct mcs_lock_node {
struct mcs_lock_node *next; // 指向下一个等待者
int waiting; // 本地标志
};
void lock_mcs(struct mcs_lock *lock, struct mcs_lock_node *local) {
local->next = NULL;
local->waiting = 1;
// 原子地把本地节点放到锁的尾部,返回前一个持有者
struct mcs_lock_node *prev = atomic_swap(&lock->tail, local);
if (prev != NULL) {
// 锁被占用,把本地节点接到前一个持有者后面
prev->next = local;
// 自旋等待,直到 waiting 变为 0
while (local->waiting == 1) {
cpu_relax(); // PAUSE 指令,减少缓存冲突
}
}
// 拿到了!
}
void unlock_mcs(struct mcs_lock *lock, struct mcs_lock_node *local) {
if (local->next == NULL) {
// 自己是最后一个,检查是否还有人排在自己后面
if (atomic_compare_exchange(&lock->tail, local, NULL) == SUCCESS) {
// 没人排在自己后面,锁已释放
return;
}
// 有人在排队,等它设置 next
while (local->next == NULL) {
cpu_relax();
}
}
// 通知下一个等待者
local->next->waiting = 0;
}3. 信号量:允许有限并发访问的锁
spinlock 的问题是:如果锁被占用,CPU 只能空转。如果持锁的时间很长(比如等 I/O),这完全浪费 CPU。
信号量(Semaphore) 解决这个问题——不让 CPU 空等,而是让进程进入睡眠(阻塞),等锁可用时再唤醒。
信号量 vs spinlock:
spinlock:自旋,进程一直在 CPU 上跑(不能去睡觉)
→ 适合持锁时间很短的情况(< 1ms)
信号量:睡眠,进程让出 CPU(去等待队列睡觉)
→ 适合持锁时间较长的情况(I/O、等待外设等)
信号量 = 一个整数值(计数器)+ 一个等待队列3.1 信号量实现
struct semaphore {
int count; // 计数器(资源数量)
struct list_head waiters; // 等待队列(FIFO)
spinlock_t lock; // 保护信号量本身
};
void sem_init(struct semaphore *sem, int count) {
sem->count = count;
list_init(&sem->waiters);
spinlock_init(&sem->lock);
}
void sem_down(struct semaphore *sem) {
// P 操作(proberen,荷兰语"测试")
spinlock_lock(&sem->lock);
if (sem->count > 0) {
// 有资源,占用一个
sem->count--;
spinlock_unlock(&sem->lock);
} else {
// 没有资源,当前进程进入睡眠
struct process *current = get_current_process();
list_add_tail(&current->wait_link, &sem->waiters);
set_process_state(current, STATE_BLOCKED);
spinlock_unlock(&sem->lock);
// 主动让出 CPU(调度到其他进程)
schedule();
}
}
void sem_up(struct semaphore *sem) {
// V 操作(verhogen,荷兰语"增加")
spinlock_lock(&sem->lock);
if (list_empty(&sem->waiters)) {
// 没有进程在等,增加计数器
sem->count++;
} else {
// 有进程在等,唤醒一个
struct process *waiter = list_entry(sem->waiters.next,
struct process, wait_link);
list_del(&waiter->wait_link);
set_process_state(waiter, STATE_RUNNABLE);
// 被唤醒的进程会在下次调度时拿到信号量
}
spinlock_unlock(&sem->lock);
}3.2 二值信号量(互斥锁)
当信号量初始值 = 1 时,它退化为一个互斥锁(Mutex):
// 用信号量实现互斥锁
struct semaphore mutex;
sem_init(&mutex, 1); // count=1,同时只有一个进程能进入
// 访问临界区
sem_down(&mutex); // 获取锁(count--),如果 count=0 则睡眠
// 临界区代码
sem_up(&mutex); // 释放锁(count++),唤醒一个等待者这和 spinlock 的区别:spinlock 如果拿不到会自旋,mutex 如果拿不到会睡眠。睡眠避免了无意义的 CPU 消耗。
4. 互斥锁 vs spinlock:什么时候用哪个
选择原则:
持锁时间 < 1ms(临界区很短):
→ 用 spinlock(睡眠+唤醒的开销 > 持锁时间)
持锁时间 > 1ms(临界区有 I/O):
→ 用 mutex(让 CPU 做别的事)
中断处理函数里:
→ 用 spinlock(中断处理函数不能睡眠)
→ 但要尽可能快进快出,不要在中断里长时间持锁
多核环境:
→ spinlock 可能因缓存行竞争而性能差
→ 考虑 MCS lock 或 per-CPU 数据结构5. 死锁:同步原语的最大陷阱
死锁(Deadlock)是最常见的同步错误——两个或多个进程互相等待对方持有的锁,谁都无法推进。
死锁示例(两把锁):
进程 A:
lock(lock1); // 拿到 lock1
lock(lock2); // 等待 lock2
进程 B:
lock(lock2); // 拿到 lock2
lock(lock1); // 等待 lock1
结果:A 等 B 的 lock2,B 等 A 的 lock1,谁也拿不到避免死锁的策略:
1. 锁的顺序:所有进程必须按相同顺序获取锁
A: lock1 → lock2
B: lock1 → lock2 ← 同一顺序,不会死锁
2. try-lock(不要阻塞式拿锁)
if (try_lock(lock1) == FAIL) {
unlock(lock2); // 回退已拿的锁
retry();
}
3. 锁的层级检测(运行时)
检测 lock graph 是否有环
→ Linux 的 lockdep 可以检测这个
4. 避免循环等待
→ 如果能确保所有进程都先拿 lock1 再拿 lock2,
就不会有循环等待6. Linux 实践:观察锁竞争
# 用 perf 观察锁争用
perf lock record -a sleep 10
perf lock report
# 显示所有锁的:
# 名称、持有时间、争用次数、等待时间
# 观察 spinlock 争用
perf stat -e 'lock:*' ./my_program
# 查看当前进程持有的锁
cat /proc/$$/status | grep -i lock# 用 strace 观察 futex 系统调用(用户态锁的实现)
# futex(ADDR, FUTEX_WAIT, ...) = 等锁
# futex(ADDR, FUTEX_WAKE, ...) = 唤醒
strace -e trace=futex ./my_program
# 观察 futex 争用情况
cat /proc/sys/kernel/futex_timeout # futex 等待超时# 用 /proc/lock_stat 看全局锁统计(需要 root)
echo 1 > /proc/sys/kernel/lock_stat
# 运行程序
cat /proc/lock_stat
# 显示:
# 锁名 | 持有时间 | 争用次数 | 平均等待时间7. 从零实现:wandos 的同步原语
⚠️ wandos 当前状态:kernel/sync/ 目录下有 spinlock 和 semaphore 实现。以下分析其实现。
7.1 spinlock 实现(kernel/sync/spinlock.cpp)
// wandos spinlock 实现
// 源码:kernel/sync/spinlock.cpp
struct spinlock {
volatile int value; // 0=未占用,1=占用
};
// 原子 test-and-set(使用 x86 lock 前缀)
static inline bool atomic_test_and_set(volatile int *addr, int new_val) {
// x86 的原子交换:返回旧值,设置新值
// "lock" 前缀确保总线锁定,多核安全
int old;
__asm__ volatile(
"lock xchg %0, %1"
: "=r"(old), "+m"(*addr)
: "0"(new_val)
: "memory"
);
return old;
}
void spin_lock(spinlock_t *lock) {
// 自旋等待,直到成功获取锁
while (atomic_test_and_set(&lock->value, 1) == 1) {
// 空循环,CPU 忙等
// 可加 PAUSE 指令减少缓存竞争
__asm__ volatile("pause" ::: "memory");
}
}
void spin_unlock(spinlock_t *lock) {
// 释放锁
__asm__ volatile(
"movl $0, %0"
: "+m"(*lock)
:
: "memory"
);
}7.2 mutex 实现(基于信号量)
// wandos mutex 实现(基于 semaphore)
// 源码:kernel/sync/mutex.cpp
struct mutex {
struct semaphore sem; // 用二值信号量实现
struct process *owner; // 当前持有者(用于调试/递归检测)
};
void mutex_init(struct mutex *m) {
sem_init(&m->sem, 1); // count=1,二值
m->owner = NULL;
}
void mutex_lock(struct mutex *m) {
// 当前进程不能重复拿自己已经持有的锁(简单检测)
if (m->owner == get_current_process()) {
panic("recursive mutex lock!");
}
sem_down(&m->sem); // P 操作
m->owner = get_current_process();
}
void mutex_unlock(struct mutex *m) {
m->owner = NULL;
sem_up(&m->sem); // V 操作,唤醒等待者
}7.3 等待队列(用于阻塞)
// wandos 等待队列(实现信号量的等待机制)
// 源码:kernel/sync/waitqueue.cpp
struct wait_queue {
struct list_head tasks; // 等待任务链表
spinlock_t lock;
};
void wait_queue_init(struct wait_queue *wq) {
list_init(&wq->tasks);
spinlock_init(&wq->lock);
}
void wait_on(struct wait_queue *wq) {
struct process *current = get_current_process();
spinlock_lock(&wq->lock);
// 加入等待队列
list_add_tail(&current->wait_link, &wq->tasks);
// 设置为阻塞状态
set_process_state(current, TASK_BLOCKED);
spinlock_unlock(&wq->lock);
// 主动调度,让出 CPU
schedule();
}
void wake_up(struct wait_queue *wq) {
spinlock_lock(&wq->lock);
if (!list_empty(&wq->tasks)) {
// 取队列头的第一个进程唤醒
struct process *waiter = list_entry(wq->tasks.next,
struct process, wait_link);
list_del(&waiter->wait_link);
set_process_state(waiter, TASK_RUNNABLE);
}
spinlock_unlock(&wq->lock);
}7.4 wandos 和 Linux 的主要差异
- 没有 RCU(Read-Copy-Update):Linux 有 RCU 让读操作完全无锁,wandos 没有这层
- 没有 lockdep:Linux 的 lockdep 可以检测死锁和锁顺序问题,wandos 只有简单的递归检测
- 没有 per-CPU 变量:Linux 的 percpu 机制让每个 CPU 有独立数据,避免缓存行竞争,wandos 没有
- 没有 sleepable spinlock:Linux 可以在禁止抢占的情况下睡眠(sleepable RCU),wandos 不支持
8. 动手环节:实现一个生产者-消费者问题
今天的目标:用信号量实现一个环形 buffer 的生产者-消费者问题,验证同步原语的正确性。
任务 1:实现环形 buffer
#define BUFFER_SIZE 8
int buffer[BUFFER_SIZE];
int in = 0; // 生产者写入位置
int out = 0; // 消费者读取位置任务 2:用信号量控制
struct semaphore mutex; // 互斥锁(保护 buffer)
struct semaphore empty; // 空槽数量(初始 = BUFFER_SIZE)
struct semaphore full; // 已填充数量(初始 = 0)任务 3:生产者/消费者
// 生产者:生产 100 个 item
void producer(void) {
for (int i = 0; i < 100; i++) {
sem_down(&empty); // 等空槽
sem_down(&mutex); // 互斥
buffer[in] = i;
in = (in + 1) % BUFFER_SIZE;
sem_up(&mutex);
sem_up(&full); // 通知有数据
}
}
// 消费者:消费 100 个 item
void consumer(void) {
for (int i = 0; i < 100; i++) {
sem_down(&full); // 等数据
sem_down(&mutex);
int val = buffer[out];
out = (out + 1) % BUFFER_SIZE;
sem_up(&mutex);
sem_up(&empty); // 通知有空槽
// 处理 val
}
}验收标准
- 生产者和消费者并发运行,不丢失数据,不重复读取
- 用 mutex 确保 buffer 读写不会交错(验证输出完整性)
- 可以用两个线程(生产者/消费者)运行,观察结果
9. 踩坑与注意事项
坑 1:持锁时调用 sleep
在持有 spinlock 时调用 schedule() 或任何可能让进程睡眠的操作——这是灾难性的。持有 spinlock 意味着”我正在保护某个数据结构”,如果进程在保护期间睡觉,另一个进程可能试图获取同一个 spinlock,然后自旋到天荒地老(死锁)。Spinlock 只能在禁止抢占的上下文中使用(中断处理函数、禁止调度的临界区)。
坑 2:信号量 count 不匹配初始化
初始化信号量时,count 的值必须正确反映资源的数量。如果 sem_init(&mutex, 2)(count=2 的信号量当互斥锁用),会导致两个进程同时进入临界区。Mutex 必须是 count=1,二值信号量。
坑 3:唤醒后继续持有锁
sem_up 唤醒等待者后,等待者被加入就绪队列,但不立即拿到信号量——它只是被唤醒,等调度器选中后才能继续。如果 sem_up 后立即把锁变量设为 0,而唤醒的进程还没被调度,其他进程可能先拿到锁。正确的实现:唤醒者通过 wake_up 把进程设为 RUNNABLE,锁的状态由唤醒者(sem_up)设置,唤醒的进程在 sem_down 里再检查。
坑 4:锁粒度太大
如果一个锁保护了太多数据(比如整个进程表),所有进程访问任何进程信息都要竞争这把锁——系统变成单线程,性能灾难。解决:细粒度锁(每条链表一把锁)、读写锁(读多写少场景)、per-CPU 数据结构(根本不需要锁)。
写在最后
同步原语是并发安全的基石:spinlock 用硬件原子指令保证互斥,适合短临界区;信号量用睡眠/唤醒机制让 CPU 不空等,适合长临界区。两者配合,构成了操作系统并发控制的核心工具。
理解同步原语,你才能真正理解为什么多线程程序要小心翼翼地保护共享数据,以及为什么 Linux 内核里满地都是 spinlock——因为内核代码永远是并发的,不保护就有问题。
下篇预告:从零写OS内核系列阶段性总结——我们覆盖了哪些、还缺什么。
相关阅读
- Linux Kernel Source:
kernel/locking/spinlock.c(spinlock 实现) - Linux Kernel Source:
kernel/locking/semaphore.c(信号量实现) - Linux Kernel Source:
kernel/locking/mutex.c(互斥锁) - wandos:
kernel/sync/spinlock.cpp - wandos:
kernel/sync/mutex.cpp - wandos:
kernel/sync/waitqueue.cpp - 本文 Demo: https://github.com/golang12306/os-kernel-from-scratch (demos/synchronization/)
- https://github.com/zhangfuwen/wandos — Linux 内核教程
- 下一篇:《从零写OS内核 | 系列总结——我们覆盖了哪些,还缺什么》
动手环节
想深入理解本文内容?动手实践是最好的方式:
今天的目标:下载 wandos 代码仓库,实现生产者-消费者问题,理解 Linux vs wandos 的差异。
-
下载 wandos:
git clone https://github.com/zhangfuwen/wandos.git cd wandos -
找到对应模块:查看
kernel/sync/下的同步原语实现 -
实现作业:根据文中”动手环节”章节的要求,完成代码编写
-
提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request
评论