并发与同步:自旋锁、信号量、互斥锁

并发与同步:自旋锁、信号量、互斥锁

Linux 是多任务多 CPU 的系统,驱动里的资源可能被同时访问,导致竞态。这篇讲驱动开发中常用的同步机制:原子操作、自旋锁、信号量、互斥锁,各自的适用场景和注意事项。

大家好,我是黒漂技术佬。

单线程写代码不用考虑并发,但 Linux 内核是多任务、多 CPU 的:

  • 多个进程同时调用驱动的 read/write
  • 中断和进程同时访问同一块数据
  • 多核上同一个函数在不同 CPU 同时执行

这些都会导致竞态(race condition),数据错乱、链表崩、内核 panic。

所以驱动里必须做同步,保护共享资源。这篇讲最常用的几种同步机制。


一、竞态是怎么产生的?

常见的并发场景

  1. 多进程并发:多个应用同时 open 同一个设备,同时 read/write
  2. SMP 多核:同一个驱动代码在两个 CPU 上同时跑
  3. 中断与进程:进程正在操作数据,中断来了也操作同一份
  4. 内核抢占:进程执行到一半被调度走,另一个进程进来也操作
  5. 底半部: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);     // 解锁+开底半部

自旋锁的使用规则

  1. 持有时间要短:忙等等待,占着 CPU 不干活
  2. 临界区不能睡眠:睡眠了别人就一直自旋等,死锁
  3. 不能递归:自己拿了锁再拿一次,死锁
  4. 中断共享的锁要用 irqsave 版本:防止死锁
  5. 保护数据,不是代码:想清楚要保护什么资源,最小化临界区

适用场景

  • 中断上下文和进程共享的数据
  • 锁持有时间很短的场景
  • 多核同步

四、信号量(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 使用规则

  1. 谁加锁谁解锁(不能在 A 加锁 B 解锁)
  2. 不能递归加锁(会死锁)
  3. 进程上下文使用(中断里不能用)
  4. 持有期间可以睡眠(但尽量别太久)

mutex vs 二值信号量

功能差不多,mutex 更优化,专门为互斥场景设计的。一般互斥场景优先用 mutex。


六、怎么选?一张表总结

机制 等待方式 上下文 能否睡眠 持有时间 适用场景
原子操作 任意 - - 单个变量、计数器
自旋锁 忙等 任意 不能 很短 中断共享、短临界区
信号量 睡眠 进程 可较长 计数、资源限制
互斥锁 睡眠 进程 可较长 互斥、大部分场景

选型原则

  1. 单个变量:原子操作
  2. 中断里也要访问 / 时间很短:自旋锁(irqsave 版本)
  3. 进程上下文互斥:mutex(首选)
  4. 需要计数(允许多个持有者):信号量
  5. 不确定用什么:大部分情况用 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. 按固定顺序加锁:所有线程都先锁1再锁2,不要反过来
  2. 减少锁的数量:能不用就不用,能一把锁解决就不用两把
  3. 用 trylock:拿不到就退,避免死等
  4. 不要递归加锁
  5. 锁的粒度清晰:每个锁保护什么要明确

十、常见坑

坑 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 总线、设备树语法、驱动和设备的匹配方式。

我是黒漂技术佬。

相关推荐
纯音乐sjc1 小时前
51单片机入门Day1:从概念到第一个IO操作踩坑记录
单片机·嵌入式硬件·51单片机
网络小白不怕黑1 小时前
10.邮件服务器搭建并脚本实现监控httpd状态
linux·运维·服务器
hai3152475431 小时前
九章编译法----空间几何统一编译法
网络·汇编·人工智能·线性代数
xu_wenming2 小时前
Zephyr上开发 BLE(低功耗蓝牙)入门应用
c语言·嵌入式硬件·物联网
CN_HW2 小时前
Nginx 流量镜像配置文档-双轨国产化
服务器·前端·网络
JNX_SEMI2 小时前
ATR2652 GNSS双频LNA:0.75dB噪声系数与22dB高增益设计
前端·单片机·嵌入式硬件·物联网·硬件工程
Macbethad2 小时前
基于TwinCAT的半导体刻蚀设备SCADA管理程序技术方案
运维·网络·数据库
Carlos_Xu2 小时前
Linux基础-基础命令
linux
Luminous.2 小时前
51 单片机--day1
单片机·嵌入式硬件