3. 共享内存(下):同步机制(自旋锁与信号量)在内核中的实现
好,咱们接着聊共享内存。上一节我们把共享内存的创建、映射、物理页分配都捋了一遍。但有个关键问题我一直悬着没说——同步。
你想想看,两个任务同时往同一块共享内存里写数据,会发生什么?数据错乱、指针飞掉、系统崩溃……我早年做嵌入式项目时就吃过这个亏。两个核抢着更新一个状态标志,结果调试了整整三天,最后发现是少了把锁。
所以今天,我们就深入内核,看看自旋锁和信号量这两个最基础的同步机制,在内核里到底是怎么实现的。
3.1 自旋锁:最简单的“别抢”机制
自旋锁,说白了就是“忙等待”。一个任务想拿锁,拿不到就原地打转,一直检查锁的状态,直到拿到为止。
为什么叫“自旋”?就像陀螺一样,在原地转圈,不干别的事。
内核里自旋锁的核心结构体长这样:
// Linux 内核自旋锁结构(简化版)
typedef struct {
volatile unsigned int lock; // 0 表示未锁定,1 表示已锁定
} spinlock_t;
注意那个 volatile,这是告诉编译器:这个变量可能被别的任务修改,别给我优化掉了。我见过有人写驱动时忘了加 volatile,结果锁永远拿不到,因为编译器把读取操作优化成了寄存器里的旧值。
自旋锁的获取操作,在 ARM 架构下通常是这样:
// 自旋锁获取(ARM 汇编伪代码)
spin_lock:
mov r1, #1
1: ldrex r2, [r0] // 独占加载锁变量
cmp r2, #0 // 检查是否已锁定
strexeq r2, r1, [r0] // 如果未锁定,尝试写入1
cmpeq r2, #0 // 写入是否成功?
bne 1b // 不成功就重试
dmb // 内存屏障,保证后续操作可见
这里用到了 ldrex/strex 指令对,这是 ARM 提供的“读-改-写”原子操作。为什么不用普通的 ldr/str?因为两个核可能同时执行 str,后写的会覆盖先写的,锁就丢了。
核心要点:自旋锁适用于锁持有时间极短的场景。比如保护一个变量、更新一个链表节点。如果锁持有时间超过几十微秒,自旋锁就是灾难——CPU 空转,浪费电,还拖慢其他任务。
我的经验:在实时系统中,我一般规定自旋锁的持有时间不超过 10 条指令。超过这个量级,我会改用信号量或互斥锁。曾经有个同事在中断上下文里用了自旋锁,结果持锁时间太长,导致高优先级中断丢失——嗯,那是一次惨痛的线上事故。
3.2 信号量:让出 CPU 的优雅方案
信号量就不一样了。拿不到锁时,任务不会傻等,而是把自己挂起,让出 CPU。等锁释放了,内核再把它唤醒。
信号量的核心数据结构:
// Linux 内核信号量结构(简化版)
struct semaphore {
atomic_t count; // 可用资源数
struct list_head wait_list; // 等待队列
spinlock_t lock; // 保护自身的自旋锁
};
有意思吧?信号量内部居然也用了自旋锁。因为操作等待队列时,必须保证原子性。这就像套娃——大锁里面套小锁。
信号量的 down 操作(获取资源):
void down(struct semaphore *sem) {
unsigned long flags;
spin_lock_irqsave(&sem->lock, flags);
if (likely(sem->count > 0)) {
// 有可用资源,直接减一
sem->count--;
spin_unlock_irqrestore(&sem->lock, flags);
return;
}
// 没有资源,把自己加入等待队列
__add_wait_queue(&sem->wait_list, current);
spin_unlock_irqrestore(&sem->lock, flags);
// 让出 CPU,进入睡眠
schedule();
// 被唤醒后,重新竞争锁
// 注意:这里可能有虚假唤醒,需要再次检查
}
这里有个细节:spin_lock_irqsave 不仅拿了自旋锁,还关了本地中断。为什么?因为信号量的等待队列可能被中断处理程序访问。如果只拿锁不开中断,中断来了却拿不到锁,就会死锁。
避坑指南:我曾经在写一个驱动时,在中断上下文里调用了 down()。结果系统直接 panic 了。因为信号量的 schedule() 会导致睡眠,而中断上下文不允许睡眠。记住:中断上下文只能用自旋锁,不能用信号量。
信号量的 up 操作(释放资源):
void up(struct semaphore *sem) {
unsigned long flags;
spin_lock_irqsave(&sem->lock, flags);
if (likely(list_empty(&sem->wait_list))) {
// 没有等待者,直接增加资源数
sem->count++;
spin_unlock_irqrestore(&sem->lock, flags);
return;
}
// 有等待者,唤醒第一个
struct task_struct *waiter = list_first_entry(&sem->wait_list,
struct task_struct,
wait_list);
__remove_wait_queue(&sem->wait_list, waiter);
wake_up_process(waiter);
spin_unlock_irqrestore(&sem->lock, flags);
}
你看,up 操作会优先唤醒等待者,而不是直接增加 count。这是为了公平性——先等的人先得到资源。
3.3 自旋锁 vs 信号量:怎么选?
我整理了一个对比表,方便你快速决策:
| 特性 | 自旋锁 | 信号量 |
|---|---|---|
| 等待方式 | 忙等待(CPU 空转) | 睡眠等待(让出 CPU) |
| 适用上下文 | 中断、原子上下文 | 进程上下文(可睡眠) |
| 持有时间 | 极短(微秒级) | 可长可短 |
| 资源计数 | 只有 0 和 1 | 可计数(允许多个) |
| 上下文切换 | 无 | 有(开销较大) |
| 优先级反转 | 不会(不涉及调度) | 可能发生(需协议处理) |
我个人习惯是:能用信号量就不用自旋锁。除非你确定锁的持有时间极短,或者你必须在中断上下文里干活。
3.4 共享内存中的实际应用
回到共享内存的场景。假设两个任务通过共享内存传递一个环形缓冲区:
// 共享内存中的环形缓冲区结构
struct ring_buffer {
spinlock_t lock; // 自旋锁,保护读写指针
int head; // 写指针
int tail; // 读指针
char data[BUFFER_SIZE];
};
写数据的任务:
void ring_buffer_write(struct ring_buffer *rb, char c) {
spin_lock(&rb->lock);
// 检查缓冲区是否满
if ((rb->head + 1) % BUFFER_SIZE == rb->tail) {
spin_unlock(&rb->lock);
return; // 缓冲区满,丢弃数据
}
rb->data[rb->head] = c;
rb->head = (rb->head + 1) % BUFFER_SIZE;
spin_unlock(&rb->lock);
}
读数据的任务:
int ring_buffer_read(struct ring_buffer *rb, char *c) {
int ret = 0;
spin_lock(&rb->lock);
if (rb->head != rb->tail) {
*c = rb->data[rb->tail];
rb->tail = (rb->tail + 1) % BUFFER_SIZE;
ret = 1;
}
spin_unlock(&rb->lock);
return ret;
}
这里用自旋锁就对了。因为读写指针的操作只有几条指令,锁持有时间极短。如果用信号量,反而会因为上下文切换带来不必要的开销。
一个小技巧:如果你发现自旋锁竞争很激烈(比如两个核频繁抢锁),可以考虑用 RCU(Read-Copy-Update) 或者 无锁数据结构。但那是更高级的话题了,咱们后面章节再聊。
3.5 总结
今天的内容,说白了就三句话:
- 自旋锁:忙等待,适合极短持有、中断上下文
- 信号量:睡眠等待,适合长持有、进程上下文
- 共享内存:必须配合同步机制使用,否则数据一致性无法保证
嗯,下次你写共享内存的代码时,记得先问问自己:这个锁该用自旋锁还是信号量?想清楚了再动手,能省不少调试时间。
下一节,我们聊聊 RCU 机制——一种让读者几乎不用加锁的同步方案。敬请期待。