Linux 设备驱动中的并发控制(总结笔记)
笔记来源:Linux 设备驱动学习笔记 ------ 第 20 章《Linux 设备驱动中的并发控制》
关键词:并发、竞态、临界区、原子操作、自旋锁、信号量、互斥体、完成量
一、并发与竞态的基本概念
并发(concurrency):多个执行单元同时、并行地被执行。
竞态(race conditions) :并发的执行单元对共享资源(硬件资源、软件上的全局变量、静态变量等)进行访问时,很容易导致的结果错误。
1. 竞态产生的原因
- 多个 CPU:SMP(对称多处理)是一种紧耦合、共享存储的系统模型,多核同时访问同一共享资源;
- 进程调度:一个进程在内核执行时,可能被另一高优先级进程打断;
- 中断与进程之间:硬中断、软中断、Tasklet(底半部)与进程之间并发,高优先级的中断可以打断低优先级,中断可以打断进程。
2. 解决方案
解决竞态问题的途径是保证对共享资源的互斥访问------一个执行单元在访问共享资源时,其他执行单元被禁止访问。
访问共享资源的代码区域称为临界区(Critical Sections),临界区需要以某种互斥机制加以保护。Linux 设备驱动中可采用的互斥途径有:
中断屏蔽、原子操作、自旋锁、信号量、互斥体(以及用于同步的完成量)。
二、原子操作(atomic)
原子操作可以保证对一个整型数据的修改是排他性的。
Linux 内核提供了一系列函数来实现原子操作,分为两类:
- 针对整型变量的原子操作;
- 针对位的原子操作。
位和整型变量的原子操作都依赖于底层 CPU 的原子指令,因此所有这些函数都与 CPU 架构密切相关 。这种排他性不仅适用于同一个核内部的并发,还适用于多核之间的并发。
1. 整型原子操作 API
| 函数名 | 功能 |
|---|---|
ATOMIC_INIT |
定义原子变量并初始化为给定值,如 atomic_t t = ATOMIC_INIT(2); |
atomic_set |
设置原子变量的值 |
atomic_read |
返回原子变量的值 |
atomic_add |
原子变量增加 i |
atomic_sub |
原子变量减少 i |
atomic_inc |
原子变量增加 1 |
atomic_dec |
原子变量减少 1 |
atomic_inc_and_test |
原子地递增,并测试最终的值是否为零 |
atomic_dec_and_test |
原子地递减,并测试最终的值是否为零 |
atomic_sub_and_test |
原子地递减 i,并测试最终的值是否为零 |
atomic_inc_return |
自增操作,并返回新的值 |
atomic_dec_return |
自减操作,并返回新的值 |
2. 位原子操作 API
| 函数名 | 功能 |
|---|---|
set_bit |
设置位(位图操作,输入子系统会用到) |
clear_bit |
清除位 |
change_bit |
改变位 |
test_and_set_bit |
测试并设置位 |
test_and_clear_bit |
测试并清除位 |
test_and_change_bit |
测试并改变位 |
3. 代码示例
c
#include <asm/atomic.h>
/* 定义并初始化:设备可用标志,1 = 空闲 */
static atomic_t dev_available = ATOMIC_INIT(1);
static int dev_open(struct inode *inode, struct file *filp)
{
/* 原子地减 1 并测试:结果不为 0 说明本来就不是 1,即已被占用 */
if (!atomic_dec_and_test(&dev_available)) {
atomic_inc(&dev_available); /* 恢复计数值,让别的进程还能再尝试 */
return -EBUSY;
}
return 0; /* 抢占成功,设备被独占 */
}
static int dev_release(struct inode *inode, struct file *filp)
{
atomic_inc(&dev_available); /* 释放设备,恢复可用标志 */
return 0;
}
原子操作适合的场景:计数器、标志位这类简单变量的保护,开销极小。
三、自旋锁(spinlock)
1. 原理
自旋锁是一种经典的、对临界资源进行互斥访问的手段:
- 在某 CPU 上运行的代码需先执行一个原子操作 ,该操作对某个内存变量进行测试并设置(Test-And-Set);
- 由于是原子操作,在该操作完成之前其他执行单元不可能访问这个内存变量;
- 若测试结果表明锁空闲,则获得自旋锁、继续执行;
- 若锁被占用 ,程序将在一个小的循环内重复"测试并设置"操作 ------ 即"原地打转",故名为自旋锁(忙等待 Busy-wait)。
适用范围 :主要针对 SMP 或单 CPU 但内核可抢占的情况;对于单 CPU 且内核不支持抢占的系统,自旋锁退化为空操作。
注意 :自旋锁"忙等待"期间 CPU 不会让出,因此持有自旋锁的代码绝对不能睡眠,临界区必须极短(微秒级)。
2. API
| 函数名 | 功能 |
|---|---|
spin_lock_init |
初始化自旋锁 |
spin_lock |
获得自旋锁 |
spin_trylock |
尝试获得自旋锁(不阻塞) |
spin_unlock |
释放自旋锁 |
| 中断中使用 | |
spin_lock_irqsave |
中断中申请自旋锁(保存中断状态并关中断) |
spin_unlock_irqrestore |
中断中释放自旋锁(恢复中断状态) |
3. 代码示例
c
#include <linux/spinlock.h>
static spinlock_t lock;
static unsigned long flags;
static int shared_data = 0;
/* 模块加载时初始化 */
static int __init dev_init(void)
{
spin_lock_init(&lock);
...
}
/* ---------- 进程上下文中使用 ---------- */
spin_lock(&lock);
shared_data++; /* 临界区:访问共享资源 */
spin_unlock(&lock);
/* ---------- 中断上下文中使用(或临界区可能被本核中断打断时) ---------- */
spin_lock_irqsave(&lock, flags);
shared_data++;
spin_unlock_irqrestore(&lock, flags);
/* ---------- 只想试探一下,拿不到就先干别的 ---------- */
if (spin_trylock(&lock)) {
shared_data++;
spin_unlock(&lock);
} else {
/* 获取失败,做别的事,不要忙等 */
}
四、信号量(semaphore)
信号量是操作系统中最经典的用于同步和互斥 的方法,与操作系统中的经典概念 PV 操作对应:
P(down):申请信号量,计数值减 1,若小于 0 则睡眠等待;V(up):释放信号量,计数值加 1,并唤醒等待者。
初始化时计数值为 1 的信号量即可当互斥锁用;计数值 大于 1 时是计数信号量,可用于限制并发数量。
1. API
| 函数名 | 功能 |
|---|---|
sema_init |
初始化信号量 |
down |
获得信号量(不可被信号打断) |
down_interruptible |
获得信号量(可以被信号打断,推荐) |
down_trylock |
尝试获得信号量(不阻塞) |
up |
释放信号量 |
2. 代码示例
c
#include <linux/semaphore.h>
static struct semaphore sem;
static int __init dev_init(void)
{
/* 第二个参数:同时允许的持有者数量,1 = 互斥使用 */
sema_init(&sem, 1);
...
}
static ssize_t dev_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
if (down_interruptible(&sem)) /* P 操作,可被信号打断 */
return -ERESTARTSYS;
/* 临界区:访问共享资源 */
up(&sem); /* V 操作 */
return count;
}
计数信号量(限制并发数)示例:
c
sema_init(&sem, 5); /* 最多允许 5 个进程同时进入临界区 */
五、互斥体(mutex)
自旋锁和互斥锁都是解决互斥问题的基本方法,互斥体依赖于自旋锁实现。
互斥体是进程级的,用于多个进程之间对资源的互斥:
- 如果竞争失败,会发生进程上下文切换 ,当前进程进入睡眠态,CPU 将运行其他进程;
- 因为进程上下文切换的开销比较大,长时间的资源占用建议使用互斥体;
- 与自旋锁相反:互斥体可以在持锁期间睡眠,但只能用于进程上下文,不能用于中断上下文。
1. API
| 函数名 | 功能 |
|---|---|
mutex_init |
初始化互斥体 |
mutex_lock |
获得互斥体 |
mutex_lock_interruptible |
获得互斥体(可以被信号打断) |
mutex_trylock |
尝试获得互斥体(不阻塞) |
mutex_unlock |
释放互斥体 |
2. 代码示例
c
#include <linux/mutex.h>
static struct mutex my_mutex;
static int __init dev_init(void)
{
mutex_init(&my_mutex);
...
}
static long dev_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
mutex_lock(&my_mutex); /* 竞争失败会睡眠,进程上下文切换 */
/* 临界区:可能耗时的操作,持锁期间允许睡眠 */
mutex_unlock(&my_mutex);
return 0;
}
六、完成量(Completion)
完成量主要用于同步操作:一个执行单元等待另一个执行单元完成某件事。
- 用"同步阻塞"的代码手段,去等待一个"异步发生"的事件;
- 发送通知的一方可以在中断上下文,等待通知的一方不能是中断上下文(等待方会睡眠)。
典型场景:等待硬件 DMA 完成 、等待内核线程启动等。
1. API
| 函数名 | 功能 |
|---|---|
struct completion my_completion; |
定义完成量 |
init_completion(&my_completion); |
初始化完成量 |
reinit_completion(&my_completion); |
重新初始化完成量(重复使用时) |
wait_for_completion(struct completion *c); |
等待完成量(阻塞) |
void complete(struct completion *c); |
唤醒完成量(唤醒一个等待者) |
void complete_all(struct completion *c); |
唤醒完成量(唤醒所有等待者) |
2. 代码示例:等待内核线程启动
c
#include <linux/completion.h>
static struct completion thread_done;
static int my_thread(void *data)
{
/* ... 线程初始化工作 ... */
complete(&thread_done); /* 通知:我已就绪(可在任何上下文) */
return 0;
}
static int __init dev_init(void)
{
init_completion(&thread_done); /* 初始化完成量 */
kthread_run(my_thread, NULL, "my_kthread");
wait_for_completion(&thread_done); /* 等待方:必须在进程上下文 */
/* 走到这里,保证线程已经真正跑起来了 */
return 0;
}
若同一个 completion 需要多次"等待---唤醒",每次唤醒后用
reinit_completion()重新初始化即可复用。
七、四种机制差异对比(重要)
| 特性 | 自旋锁 (Spinlock) | 信号量 (Semaphore) | 互斥体 (Mutex) | 完成量 (Completion) |
|---|---|---|---|---|
| 核心机制 | 忙等待 (Busy-wait) | 睡眠等待 (Sleep-wait) | 睡眠等待 (Sleep-wait) | 事件通知 (Event-based) |
| 能否睡眠 | 绝对不能 | 可以 | 可以 | 等待方可以睡眠 |
| 上下文限制 | 进程上下文、中断上下文、软中断/Tasklet | 仅限进程上下文 | 仅限进程上下文 | 进程上下文 + 中断上下文 (completion 可在中断中发送) |
| 是否可递归 | 不可(死锁) | 可(计数信号量) | 不可(死锁) | 不涉及 |
| 性能开销 | 极小(无上下文切换) | 较大(有进程调度) | 较小(比信号量轻量) | 中等 |
| 持有时间 | 极短(微秒级) | 较长 | 较长 | 取决于事件发生时间 |
| 典型应用场景 | 中断处理、短临界区、多核间共享数据保护 | 遗留代码、需要计数资源的场景(如限制并发数) | 进程间互斥访问共享资源、持有锁期间可能睡眠 | 等待某个异步操作完成(如等待硬件 DMA 完成、线程启动) |
八、总结与选型建议
- 原子操作(atomic):对整型变量(或位图)的原子操作,适合保护简单的计数器、标志位;
- 自旋锁 :开销小,短时忙等待,多用于中断 中,当然非中断中也可以使用;持锁期间绝不能睡眠;
- 信号量 :因为互斥场景已经有互斥体了,所以信号量多用于计数信号量(限制并发数),也可以当作互斥体使用;会导致休眠,开销大;
- 互斥体 :在驱动中做互斥的首选之一,同一时刻只能被一个进程使用;会导致休眠,开销大,适合持锁时间长的场合;
- 完成量 :主要做同步 操作 ------ 一个执行单元等待另一个执行单元执行完某事;做同步会导致休眠,开销大;发送通知的一方可以在中断上下文,等待通知的一方不能是中断上下文。
一句话选型口诀:
- 保护一个整数/位图 → 原子操作
- 中断上下文 / 临界区极短 → 自旋锁
- 进程间互斥、持锁时间长 → 互斥体
- 限制最多 N 个并发 → 计数信号量
- 等待一个异步事件完成 → 完成量
完。如果这篇文章对你有帮助,欢迎点赞、收藏、评论交流!