并发与同步:自旋锁、信号量、互斥锁
Linux 是多任务多 CPU 的系统,驱动里的资源可能被同时访问,导致竞态。这篇讲驱动开发中常用的同步机制:原子操作、自旋锁、信号量、互斥锁,各自的适用场景和注意事项。
大家好,我是黒漂技术佬。
单线程写代码不用考虑并发,但 Linux 内核是多任务、多 CPU 的:
- 多个进程同时调用驱动的 read/write
- 中断和进程同时访问同一块数据
- 多核上同一个函数在不同 CPU 同时执行
这些都会导致竞态(race condition),数据错乱、链表崩、内核 panic。
所以驱动里必须做同步,保护共享资源。这篇讲最常用的几种同步机制。
一、竞态是怎么产生的?
常见的并发场景
- 多进程并发:多个应用同时 open 同一个设备,同时 read/write
- SMP 多核:同一个驱动代码在两个 CPU 上同时跑
- 中断与进程:进程正在操作数据,中断来了也操作同一份
- 内核抢占:进程执行到一半被调度走,另一个进程进来也操作
- 底半部:tasklet/workqueue 和进程上下文并发
后果
- 全局变量值错乱
- 链表结构破坏
- 硬件寄存器操作乱序
- 内存泄漏、重复释放
- 内核 panic
解决方案
用同步机制保护共享资源,保证同一时间只有一个执行者访问临界区。
二、原子操作
最简单的同步,针对单个整数变量的原子读写。
原子整型
c
#include <linux/atomic.h>
atomic_t counter = ATOMIC_INIT(0);
// 读
int val = atomic_read(&counter);
// 写
atomic_set(&counter, 10);
// 加/减
atomic_inc(&counter);
atomic_dec(&counter);
atomic_add(5, &counter);
atomic_sub(3, &counter);
// 减并测试是否为0
if (atomic_dec_and_test(&counter)) {
// 减完变成0了
}
// 比较并交换
atomic_cmpxchg(&counter, old, new);
原子位操作
c
unsigned long flags = 0;
set_bit(0, &flags); // 置位
clear_bit(1, &flags); // 清位
change_bit(2, &flags); // 翻转
test_and_set_bit(3, &flags); // 测试并置位,返回原来的值
适用场景
- 单个计数器、标志位
- 引用计数
- 简单的状态标记
不适用
- 复杂数据结构(链表、结构体多个字段)
- 需要多步操作一起保护的
原子操作只能保护单个变量,复杂的要用锁。
三、自旋锁(spinlock)
特点
- 拿不到锁的时候原地自旋等待(忙等)
- 不睡眠,不调度
- 中断上下文里可以用
- 锁持有时间要非常短
- 多核才有意义,单核就是关抢占
定义和初始化
c
#include <linux/spinlock.h>
spinlock_t lock;
spin_lock_init(&lock);
加锁解锁
c
spin_lock(&lock);
// 临界区
spin_unlock(&lock);
中断里也会访问的情况
如果中断处理函数里也要拿这个锁,用普通 spin_lock 会死锁(进程拿到锁了,中断来了也等锁,中断不返回进程没法释放)。
要用保存中断状态的版本:
c
unsigned long flags;
spin_lock_irqsave(&lock, flags); // 加锁 + 关本地中断 + 保存状态
// 临界区
spin_unlock_irqrestore(&lock, flags); // 解锁 + 恢复中断状态
其他变体
c
spin_lock_irq(&lock); // 加锁+关中断
spin_unlock_irq(&lock); // 解锁+开中断
spin_lock_bh(&lock); // 加锁+关底半部(软中断)
spin_unlock_bh(&lock); // 解锁+开底半部
自旋锁的使用规则
- 持有时间要短:忙等等待,占着 CPU 不干活
- 临界区不能睡眠:睡眠了别人就一直自旋等,死锁
- 不能递归:自己拿了锁再拿一次,死锁
- 中断共享的锁要用 irqsave 版本:防止死锁
- 保护数据,不是代码:想清楚要保护什么资源,最小化临界区
适用场景
- 中断上下文和进程共享的数据
- 锁持有时间很短的场景
- 多核同步
四、信号量(semaphore)
特点
- 拿不到锁就睡眠,让出 CPU
- 进程上下文才能用(中断上下文不能睡眠)
- 可以允许多个持有者(计数信号量)
- 持有时间可以长一点
定义和初始化
c
#include <linux/semaphore.h>
struct semaphore sem;
sema_init(&sem, 1); // 初始值1,相当于互斥锁
P 操作(获取)
c
down(&sem); // 拿不到就睡眠,不可中断
down_interruptible(&sem); // 可被信号打断
down_trylock(&sem); // 尝试拿,拿不到立刻返回,不睡眠
V 操作(释放)
c
up(&sem);
计数信号量
初始值设成 N,就允许 N 个持有者同时进入:
c
sema_init(&sem, 5); // 最多5个同时访问
适合限制资源数量的场景(比如缓冲区只有 5 个)。
五、互斥锁(mutex)
特点
- 信号量的特殊情况(初始值=1),但更简单、更高效
- 专门做互斥用的,同一时间只有一个持有者
- 睡眠等待,进程上下文用
- 内核推荐用 mutex 而不是二值信号量
定义和初始化
c
#include <linux/mutex.h>
struct mutex my_mutex;
mutex_init(&my_mutex);
加锁解锁
c
mutex_lock(&my_mutex);
// 临界区
mutex_unlock(&my_mutex);
其他
c
mutex_lock_interruptible(&lock); // 可中断
mutex_trylock(&lock); // 尝试加锁,不等待
mutex_is_locked(&lock); // 判断是否被锁
mutex 使用规则
- 谁加锁谁解锁(不能在 A 加锁 B 解锁)
- 不能递归加锁(会死锁)
- 进程上下文使用(中断里不能用)
- 持有期间可以睡眠(但尽量别太久)
mutex vs 二值信号量
功能差不多,mutex 更优化,专门为互斥场景设计的。一般互斥场景优先用 mutex。
六、怎么选?一张表总结
| 机制 | 等待方式 | 上下文 | 能否睡眠 | 持有时间 | 适用场景 |
|---|---|---|---|---|---|
| 原子操作 | 无 | 任意 | - | - | 单个变量、计数器 |
| 自旋锁 | 忙等 | 任意 | 不能 | 很短 | 中断共享、短临界区 |
| 信号量 | 睡眠 | 进程 | 能 | 可较长 | 计数、资源限制 |
| 互斥锁 | 睡眠 | 进程 | 能 | 可较长 | 互斥、大部分场景 |
选型原则
- 单个变量:原子操作
- 中断里也要访问 / 时间很短:自旋锁(irqsave 版本)
- 进程上下文互斥:mutex(首选)
- 需要计数(允许多个持有者):信号量
- 不确定用什么:大部分情况用 mutex 就行
七、完成量(completion)
作用
一个线程等另一个线程完成某件事,类似「事件通知」。
定义
c
#include <linux/completion.h>
struct completion my_comp;
init_completion(&my_comp);
等待
c
wait_for_completion(&my_comp); // 一直等
wait_for_completion_timeout(&my_comp, HZ); // 超时
wait_for_completion_interruptible(&my_comp); // 可中断
完成通知
c
complete(&my_comp); // 唤醒一个等待者
complete_all(&my_comp); // 唤醒所有等待者
重新初始化
c
reinit_completion(&my_comp);
适用场景
- 等待硬件操作完成
- 等待中断处理完
- 一个线程等另一个线程做完事
比信号量更适合「一次性事件通知」的场景。
八、等待队列(wait_queue)
作用
进程等待某个条件成立,条件不满足就睡眠,满足了被唤醒。驱动里非常常用。
定义
c
#include <linux/wait.h>
wait_queue_head_t my_wq;
init_waitqueue_head(&my_wq);
等待(条件满足才返回)
c
// 等待 condition 为真,可中断
wait_event_interruptible(my_wq, condition);
// 带超时
wait_event_interruptible_timeout(my_wq, condition, msecs_to_jiffies(1000));
// 不可中断
wait_event(my_wq, condition);
condition 是一个 C 表达式,为真就返回,为假就睡眠等待。
唤醒
c
wake_up(&my_wq); // 唤醒所有
wake_up_interruptible(&my_wq); // 唤醒可中断等待的
wake_up_nr(&my_wq, nr); // 唤醒N个
典型用法
c
// read 里:没数据就等
ssize_t my_read(...)
{
wait_event_interruptible(my_wq, has_data);
// 有数据了,拷贝给用户
}
// 中断里:数据来了,唤醒等待的进程
irqreturn_t my_irq(...)
{
has_data = 1;
wake_up_interruptible(&my_wq);
}
驱动里 read 阻塞等待数据,基本都用等待队列。
九、死锁
什么是死锁
两个线程各拿一把锁,又等对方的锁,永远等下去。
线程A:拿锁1 → 等锁2
线程B:拿锁2 → 等锁1
怎么避免
- 按固定顺序加锁:所有线程都先锁1再锁2,不要反过来
- 减少锁的数量:能不用就不用,能一把锁解决就不用两把
- 用 trylock:拿不到就退,避免死等
- 不要递归加锁
- 锁的粒度清晰:每个锁保护什么要明确
十、常见坑
坑 1:中断里用 mutex
mutex 会睡眠,中断上下文不能用。中断里要用自旋锁(irqsave 版本)。
坑 2:自旋锁持有期间睡眠
自旋锁临界区里调用了会睡眠的函数(kmalloc GFP_KERNEL、copy_from_user、mutex_lock 等),要么直接 panic,要么死锁。
坑 3:没考虑中断并发
进程用了普通 spin_lock,但中断里也访问数据,导致竞态。要用 spin_lock_irqsave。
坑 4:锁的粒度太大
一把大锁保护所有东西,并发性能差。该拆的拆,不同资源不同锁。
坑 5:忘了释放锁
错误路径 goto 回滚的时候漏了解锁,下次再拿就死锁。
坑 6:等待队列条件判断不对
wait_event 的 condition 要在拿到锁之后判断,或者本身是原子变量。条件判断和唤醒要配合好,不然丢唤醒。
十一、本篇小结
- 竞态来源:多进程、多核、中断、抢占、底半部,驱动必须考虑同步
- 原子操作:保护单个整型/位变量,最简单
- 自旋锁:忙等待,任意上下文可用,持有时间要短,中断共享要用 irqsave
- 互斥锁 mutex:睡眠等待,进程上下文用,互斥场景首选
- 信号量:可以计数,允许多个持有者,资源限制场景用
- 完成量 completion:一个线程等另一个完成事件,适合「等某件事做完」
- 等待队列:进程等待条件成立,驱动 read 阻塞等数据常用
- 选型原则:单变量用原子,短+中断用自旋锁,进程互斥用 mutex,等事件用等待队列
- 避免死锁:固定加锁顺序、减少锁数量、锁粒度清晰
下一篇讲平台设备驱动与设备树:platform 总线、设备树语法、驱动和设备的匹配方式。
我是黒漂技术佬。