Linux内核与驱动:5.并发与竞争

本节先介绍在Linux驱动程序中的并发与竞争的处理,再介绍应用层与驱动层并发与竞争的区别。

1.在Linux驱动中什么会产生竞争?

在 Linux 内核中,竞争通常源于以下四个方面:

  1. 多核并发 (SMP):多个物理 CPU 核同时运行不同的进程或中断。

  2. 内核抢占 (Preemption):高优先级的进程抢占当前正在执行的低优先级进程。

  3. 硬件中断 (Interrupts):硬件触发中断,强制 CPU 停止当前任务转而执行中断处理函数(ISR)。

  4. 软中断与 Tasklet:内核的异步延迟执行机制。

2.并发处理机制

本节将系统介绍 Linux 驱动中解决并发竞争的四种常用机制:原子变量自旋锁信号量互斥锁,包括原理、适用场景及典型代码示例。

2.1原子变量(Atomic Variables)

原子变量是一种特殊的整型变量,其读写、加减、测试等操作由硬件保证在单条指令内完成,不会被中断或其它 CPU 核心打断。Linux 内核提供 atomic_t 类型以及一系列原子操作函数。

适用场景:

  • 计数器:如统计设备打开的引用计数、丢包计数等。

  • 标志位:仅需进行"读取-修改-写回"且不涉及复杂数据结构的场景。

常用API:

cpp 复制代码
#include <linux/types.h>

atomic_t cnt = ATOMIC_INIT(0);
// 读原子变量
int val = atomic_read(&cnt);
// 加/减
atomic_inc(&cnt);      // 自增 1
atomic_dec(&cnt);      // 自减 1
atomic_add(2, &cnt);   // 加 2
atomic_sub(1, &cnt);   // 减 1

// 测试并设置(如果当前值为 0,则设置为 1 并返回 true)
if (!atomic_add_unless(&cnt, 1, 1)) {
    // 已经是 1,无法设置
}

// 设置新值
atomic_set(&cnt, 10);

优点:不涉及线程切换,不消耗多余 CPU,效率极高。

2.2自旋锁(spinlock)

原理:

自旋锁是一种忙等待锁。当线程尝试获取已被占用的自旋锁时,它会在一个循环中不断检测锁的状态("自旋"),直到锁被释放。自旋期间该线程不会睡眠,也不会让出 CPU。

  • 在单核非抢占内核中,自旋锁退化为空操作(因为一次只能有一个线程运行,只需关中断即可)。

  • 在多核系统中,自旋锁通常会在获取前禁用内核抢占,防止其他 CPU 上的进程抢占当前线程导致死锁。

核心特性 :持有自旋锁期间绝对不能睡眠 (不能调用 schedule()copy_from_user()mutex_lock() 等可能阻塞的函数),否则其他等待锁的 CPU 会永远自旋,系统死锁。

由于自旋锁是忙等的,所以自旋锁的临界区尽可能的短,快进快出。

适用场景:

  • 临界区极短(几个内存访问、几条指令),锁持有时间远小于两次上下文切换开销。

  • 保护中断上下文与进程上下文共享的数据 (需结合 spin_lock_irqsave() 禁止本地中断)。

  • SMP 环境下对共享数据结构的快速更新,如链表、哈希表节点插入删除。

常用API:

cpp 复制代码
#include <linux/spinlock.h>

DEFINE_SPINLOCK(my_lock);          // 静态初始化
spinlock_t my_lock;
spin_lock_init(&my_lock);          // 动态初始化

// 普通版本(假定不会在中断中使用)
spin_lock(&my_lock);
/* 临界区 */
spin_unlock(&my_lock);

// 禁用本地中断的版本(防止中断处理程序争用)
unsigned long flags;
spin_lock_irqsave(&my_lock, flags);
/* 临界区 */
spin_unlock_irqrestore(&my_lock, flags);

自旋锁不允许持有者睡眠的原因:

典型的死锁场景 (The Deadlock Trap):假设你在 CPU A 上持有了自旋锁,然后调用了 msleep() 睡着了:

  1. CPU A 进入睡眠,调度器被迫切换到了 进程 B
  2. 进程 B 恰好也需要访问被这个自旋锁保护的资源,于是它也调用了 spin_lock()。
  3. 由于自旋锁是"忙等"锁,进程 B 会在 CPU A 上疯狂原地打转(自旋),等待锁释放。
  4. 关键点 :持有锁的那个进程还在睡眠队列里躺着呢!它需要 CPU A 重新调度它才能继续运行并释放锁。但此时 CPU A 正在被 进程 B 的自旋动作 100% 占用。
  5. 结果 :进程 B 永远等不到锁,持有锁的进程永远回不来。CPU A 彻底锁死。

2.3信号量

我们讲自旋锁不允许休眠,是忙等的,信号量是一种可以引起休眠的锁。它通常包含一个计数器,允许 N 个执行单元同时进入临界区(如果 N=1,则等同于互斥锁)。信号量就相当于钥匙,如果有5把钥匙就是可以同时有五个人可以访问。

  • 行为:拿不到锁时,进程会进入休眠状态,释放 CPU 给其他任务。

  • 适用场景:临界区执行时间较长,涉及磁盘 IO 或大量内存拷贝。

  • 限制严禁在中断上下文使用。因为中断不能休眠。

常用API:

cpp 复制代码
#include <linux/semaphore.h>

struct semaphore sem;
sema_init(&sem, 1);    // 初始值 1,互斥信号量(类似于互斥锁)
sema_init(&sem, 5);    // 初始值 5,允许 5 个并发

// 获取信号量(可被信号中断,返回 -EINTR)
if (down_interruptible(&sem)) {
    return -ERESTARTSYS;
}
/* 临界区 */
up(&sem);              // 释放信号量

// 不可中断版本(较少用,因为难以终止进程)
down(&sem);

2.4互斥锁(Mutex)

互斥锁(mutex)是信号量的一种特例(计数值为 1),但它有更严格的约束和更优的性能。互斥锁也导致进程睡眠,但比信号量更轻量,且增加了调试检查(例如不能在中段上下文使用、不能递归获取、持有者必须由同一线程释放)

适用场景:

  • 标准的互斥访问(一次只有一个线程进入临界区)。

  • 临界区可能较长,但不需要计数功能。

  • 进程上下文中保护共享资源。

常用API:

cpp 复制代码
#include <linux/mutex.h>

DEFINE_MUTEX(my_mutex);          // 静态初始化
struct mutex my_mutex;
mutex_init(&my_mutex);           // 动态初始化

mutex_lock(&my_mutex);
/* 临界区 */
mutex_unlock(&my_mutex);

// 可被信号中断的版本
if (mutex_lock_interruptible(&my_mutex)) {
    return -ERESTARTSYS;
}

2.5对比

需求场景 推荐方案 理由
仅保护一个简单的整数 原子变量 效率最高,无锁开销
临界区极短,且可能在中断中使用 自旋锁 中断不能休眠,自旋锁是唯一选择
临界区很长,涉及 IO 操作 互斥锁 避免长时间占用 CPU,允许系统调度
允许多个读者同时访问,仅写者互斥 读写锁/RCU 优化高频读取场景的性能

3.驱动并发与应用并发的区别

在 Linux 系统中,应用层(User Space)与驱动层(Kernel Space)在处理并发(Concurrency)与竞争(Race Condition)时,虽然目标一致(都是保护共享资源),但其实现机制、约束条件和底层逻辑有着天壤之别。

以下从五个维度对两者的差异进行详细拆解:

1. 竞争对手的"身份"差异

这是理解两者区别的根基。在应用层,你面对的干扰相对单一;而在驱动层,干扰无处不在。

  • 应用层(竞争对手:进程/线程)

    • 多线程:同一个进程内的线程抢夺全局变量。

    • 多进程:不同进程通过共享内存或文件进行竞争。

    • 特点:所有的竞争者都受操作系统调度器(Scheduler)管辖,行为可预测。

  • 驱动层(竞争对手:上下文/中断/硬件)

    • 进程上下文:不同应用进程通过系统调用进入内核,同时操作同一个驱动。

    • 中断上下文:正在执行驱动代码时,硬件突然触发中断,中断处理函数(ISR)强行插入并修改数据。

    • 多核抢占(SMP):在多核 CPU 上,核 A 在运行驱动,核 B 也在运行同样的驱动代码,两者物理并行。

    • 软中断/Tasklet:内核的异步延迟执行机制。


2. 同步机制的"工具箱"对比

A. 互斥锁(Mutex)与信号量(Semaphore)
  • 应用层 :调用 pthread_mutex_lock。如果拿不到锁,线程会被操作系统挂起(休眠),直到锁释放。

  • 驱动层 :也存在 struct mutex。但关键区别在于:驱动层互斥锁绝对不能在中断上下文中使用。因为中断上下文没有进程实体,一旦进入休眠(调度),内核就无法再切回中断现场,导致系统"挂死"。

B. 自旋锁(Spinlock)------驱动层的"核心杀手锏"
  • 应用层 :极少使用(虽然有 pthread_spin_lock)。因为应用层无法关抢占,如果持锁线程被切走,其他线程会白白空转 CPU。

  • 驱动层必选方案

    • 逻辑:拿不到锁时不睡觉,而是原地打转(自旋)。

    • 进阶版spin_lock_irqsave。它在加锁的同时关闭本地 CPU 中断。这是为了防止:进程拿到锁后被中断抢占,而中断里也想拿这个锁,导致"原地自旋死锁"。

C. RCU (Read-Copy-Update) ------驱动层的"黑科技"
  • 应用层:很难实现,通常需要复杂的库支持。

  • 驱动层 :广泛使用。它的核心思想是"读者不加锁,写者先拷贝"。在读取路由表、设备列表等读多写少的场景下,性能几乎是无损的。


3. "睡眠"的权限差异

这是开发者最容易犯错的地方。

  • 应用层 :几乎任何时候都可以睡眠。不管是等 IO、等锁还是手动 sleep,OS 会帮你处理好上下文切换。

  • 驱动层严禁随意睡眠

    • 持有自旋锁时:严禁睡眠(因为别人正在原地等你,你一睡,大家一起死)。

    • 处于中断上下文时:严禁睡眠(没有进程实体,无法被调度器唤醒)。

    • 而在应用层,你永远不需要关心自己是否处于"中断上下文"。


4. 硬件层面的控制力

  • 应用层:完全没有硬件控制权。你无法告诉 CPU"现在不要响应键盘中断"。

  • 驱动层:拥有最高权限。

    • 关中断(Local IRQ Disable):驱动可以暂时关闭当前 CPU 的中断响应,从而在物理上消灭"被中断打断"的可能性。

    • 关抢占(Preemption Disable):可以告诉内核,"在我这段代码跑完前,不要把我切走"。


5. 内存顺序与屏障(Memory Barriers)

在现代多核处理器中,为了性能,CPU 会对指令进行重排

  • 应用层 :通常不需要担心。编译器和高级语言(如 C++11 的 std::atomic)已经帮你封装好了内存模型。

  • 驱动层:必须手动处理。

    • 当你操作硬件寄存器时顺序至关重要。比如:必须先给硬件写数据,再发"启动"指令。

    • 驱动开发者必须显式使用 wmb()(写屏障)、rmb()(读屏障)来强制 CPU 按照代码顺序执行,否则硬件会因为指令重排而行为异常。

相关推荐
牢七2 小时前
CVE-2022-37202 nday 研究 sql
linux·windows·microsoft
打工人1379号2 小时前
2K3000常见问题合集
linux·运维·服务器
冰冷的希望2 小时前
【系统】非虚拟机,物理机安装Ubuntu教程,Windows与Linux(Ubuntu)双系统共存!
linux·windows·ubuntu·系统架构·vmware·双系统·pe系统
不想看见4042 小时前
在AI时代下,刷LeetCode题的价值与意义
开发语言·c++·qt
minji...2 小时前
Linux 进程信号(四)内核态&&用户态,sigaction,可重入函数,volatile,SIGCHLD信号
linux·运维·服务器
新兴AI民工2 小时前
【Linux内核二十九】进程管理模块:CFS调度器check_preempt_wakeup
linux·linux内核·wakeup
南境十里·墨染春水2 小时前
C++ 笔记 多重继承 菱形继承(面向对象)
开发语言·c++·笔记
cpp_25012 小时前
P1569 [USACO ?] Generic Cow Protests【来源请求】
数据结构·c++·算法·题解·洛谷·线性dp
Albert Edison2 小时前
【ProtoBuf 语法详解】选项 option
开发语言·c++·序列化·反序列化·protobuf