首页 / 资讯中心 / 文章详情

Linux内核驱动并发控制:从原子操作到RCU同步原语详解

Linux内核驱动并发控制:从原子操作到RCU同步原语详解 ★ FEATURED ARTICLE
写驱动难难在哪十个有九个会告诉你难在并发。用户态程序写个全局变量最多也就线程之间争一争加个mutex了事。到了内核里问题一下子变得复杂多个CPU核同时在跑、中断随时打断、抢占随时发生你精心维护的那块共享数据可能前一刻还好好的下一刻就被别处改得面目全非。尤其在做Linux驱动开发时设备状态、缓冲区、寄存器映射区几乎处处都是共享资源。这也是我写这篇“内核中的互斥与同步”的初衷把这套并发控制的家底子从头理一遍搞明白每个同步原语是怎么设计的、适合什么场景、用的时候有什么坑。这篇文章适合刚学驱动不久、想系统补一补内核并发基础的人也适合写过一些字符设备但总在锁的选择上犯迷糊的朋友。我不打算堆概念而是结合驱动里真实会遇到的情况把原子操作、自旋锁、互斥锁、读写锁、RCU这些挨个讲透最后用一个带同步逻辑的虚拟设备实例串起来再聊聊死锁和排查经验。这篇是系列第九篇前面的坑踩多了反而觉得并发这块最值得认真写一写。1. 并发从哪来先搞清楚我们要防谁1.1 四种典型的并发场景很多人一提到并发脑子里只有“多核同时跑”。但其实在内核驱动里并发来源远比这丰富而且每种来源对锁的要求都不一样。我习惯把它分成四类。第一类是SMP真并行。现在随便一块开发板都是四核起步两个核同时在执行你驱动里的同一个函数这在硬件上就构成了真正的同时访问。这种并发靠原子指令和锁来解决是同步原语的主要战场。第二类是中断与软中断的抢占。当CPU正在执行驱动里的临界区代码时外部设备触发中断同一个CPU上的当前进程被强行打断去执行中断处理程序。如果中断处理程序也碰同一份数据那就是在同一颗CPU上发生了交错访问。这类并发很隐蔽因为单核上看起来不会冲突但实际上临界区被“割裂”了。第三类是进程的上下文切换与内核抢占。一个进程在驱动的read函数里睡着了调度器换上了另一个进程如果这两个进程都打开了同一个设备节点它们操作同一份文件私有数据或设备全局状态就又构成了竞争。第四类是用户态的多进程/多线程访问。这个大家都熟两个用户程序同时write同一个设备节点如果不做约束设备里的缓冲区就会被交叉写入。看清这四类你就会明白一个道理内核里的同步不是“防另一颗CPU”而是“防任何可能导致执行流交错的东西”。所以同步原语的选择核心就看临界区里能不能睡眠、持有时间有多长、读者多还是写者多。1.2 用代码感受一下数据竞争光说不练假把式。我拿一个最典型的驱动场景举例一个计数器read函数里读它并返回给用户write函数里给它加某个值。static int dev_count; static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *off) { char tmp[32]; int len; /* 这里读 dev_count但另一颗CPU可能正在改它 */ len snprintf(tmp, sizeof(tmp), %d\n, dev_count); return simple_read_from_buffer(buf, count, off, tmp, len); } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *off) { int val; if (kstrtoint_from_user(buf, count, 10, val)) return -EINVAL; dev_count val; /* 这里改 dev_count */ return count; }这段代码看着人畜无害但多核环境下编译出的汇编可能是这样的read里先把dev_count读到寄存器然后做格式化write里先把dev_count读到另一个寄存器加上val再写回去。如果两个核同时执行read可能读到旧值write的读-改-写三步之间也可能被另一颗核插入操作最终加的数值被覆盖丢掉。这种bug不会稳定复现可能跑几万次才出一次错。更麻烦的是问题现象很怪要么是读到的值忽大忽小要么是写进去的数值丢了很难联想到是并发问题。我早期开发时就因为这种“数据竞争”排查了整整一个礼拜最后加了个原子变量问题立刻消失。所以教训就一句话你的共享数据有没有可能被两个以上执行流同时接触只要有可能就一定要上同步手段。2. 内核为我们准备的一排锁选型之前先认门2.1 原子操作最简单也最容易被轻视原子操作是内核并发控制的“地基级”手段。它靠硬件指令保证一个“读-改-写”过程不会被打断或者说不允许别的CPU看到中间状态。常用的有两类一类是atomic_t整数原子操作另一类是位图原子操作比如set_bit、clear_bit、test_and_set_bit。拿刚才的计数器来说改成原子版本只需要三步static atomic_t dev_count ATOMIC_INIT(0); /* read里读 */ int val atomic_read(dev_count); /* write里加 */ atomic_add(val, dev_count);atomic_read和atomic_add一个读一个写数据竞争问题就解决了。这背后的逻辑很简单atomic_add在底层就是一个不可分割的总线级操作别的CPU要么看到加之前的值要么看到加之后的值绝不会看到加了一半的中间态。我在驱动里常用原子变量管理“设备打开次数”、“中断标志位”、“资源是否被占用”这类简单状态。比如设备只允许同时打开一次就可以用atomic_inc_unless_negative这类高级接口配合一个初始化的值来判断。不过要提醒一句原子操作只适合保护“单个变量”的小动作。如果你的临界区是“先读A再根据A改B”这两个动作之间有依赖关系那就不是一个原子变量能兜住的了必须上锁。很多人觉得原子操作太简单不屑于用。我的观点恰恰相反能用原子操作解决的问题就不要引入锁。锁会带来睡眠、调度、缓存失效、优先级反转等一系列连锁反应而原子操作几乎没有额外代价。内核源码里大量用原子变量管理状态标志就是这个道理。2.2 自旋锁不可睡眠的临界区自旋锁在驱动开发里地位极高尤其在中断上下文。它的设计思想很朴素某个CPU进入临界区之前先尝试获取锁如果锁被别人持有它就原地反复检测、空转等待也就是“自旋”直到持有者释放锁。这个设计的核心约束随之而来持有自旋锁的时候绝对不能在临界区里睡眠。因为睡眠意味着让出CPU而别的CPU正死等你这个锁你醒不来它就一直空转系统性能瞬间归零严重时直接看门狗超时重启。所以自旋锁只适合保护那些“执行时间极短、不会睡眠”的临界区比如修改一个寄存器、翻转一个状态位、操作一个链表节点。自旋锁最典型的场景是保护中断处理程序和进程上下文共享的数据。这里有个非常容易踩的坑如果进程上下文持锁期间发生了中断中断处理程序也来抢同一把锁则会死锁——处理器已经被中断打断中断程序在等锁而锁的持有者被中断卡住永远无法释放。解决方式是用spin_lock_irqsave它在拿锁之前先把本CPU的中断状态保存并关掉中断锁释放时再恢复原状spinlock_t lock; unsigned long flags; spin_lock_irqsave(lock, flags); /* 临界区修改共享数据 */ spin_unlock_irqrestore(lock, flags);我实测下来在驱动开发中凡是中断处理函数要访问的共享数据保护它就必须用带irqsave的变体。单纯用spin_lock只防多核不防中断打断隐患很大。曾经有个案例驱动跑着跑着系统随机卡死查了几天才发现就是在中断和进程上下文共享了一个自旋锁而没用irqsave版本。2.3 信号量与互斥锁睡得着觉的锁如果说自旋锁是“等不起就死磕”信号量和互斥锁就是“等不起就先睡一会”。这两种锁在临界区里允许睡眠也就是说临界区可以执行较长时间的操作比如拷贝数据、访问硬件寄存器、等待某个状态变化。先讲信号量。内核里的信号量用struct semaphore表示它维护一个计数。计数值为1时相当于互斥计数值大于1时可以允许多个持有者。经典的down和up操作对应P和V用法如下struct semaphore sem; sema_init(sem, 1); down(sem); /* 获取若不可得则睡眠等待 */ /* 临界区 */ up(sem); /* 释放 */信号量在早期内核里大量用于实现互斥但现在内核社区基本不推荐用信号量做互斥了原因很多语义复杂允许计数大于1就容易有人误用没有所有者概念任何线程都能up容易写出错误代码没有优先级继承机制会出现优先级反转问题。所以现在一般建议直接用mutex。互斥锁struct mutex是专门为互斥场景设计的语义比信号量清晰得多。基本操作是struct mutex lock; mutex_init(lock); mutex_lock(lock); /* 临界区可以睡眠可以做耗时操作 */ mutex_unlock(lock);互斥锁有几个硬性规定违反了直接导致系统报错甚至崩溃同一个线程不能对已经持有的mutex再次mutex_lock这是自死锁。mutex_unlock只能由锁的持有者调用不能跨线程释放。中断上下文绝不能使用mutex_lock因为它可能睡眠而中断上下文根本不允许睡眠。持有mutex期间不能调用可能引起睡眠的函数比如copy_to_user除非你已经仔细斟酌过路径。实际上copy_to_user本身是可能睡眠的很多老手在临界区里用它后导致死锁、数据不一致这个我在第四部分会详细说。驱动开发里字符设备的read/write/ioctl这类进程上下文的操作绝大多数情况下就用mutex保护设备数据结构这是最舒服也最不容易出错的方案。我自己的原则是进程上下文、临界区可能耗时的首先考虑mutex中断上下文或临界区极短的才用自旋锁。2.4 读写锁与RCU读多写少的特解很多驱动里的共享数据具有一个明显特征读操作极其频繁写操作很少。比如一个设备状态表、一组配置参数用户态动不动就来读但只有初始化或特殊命令时才写。如果这种情况下无条件用互斥锁所有读操作都被串行化白白损失性能。内核为此提供了读写锁。读写自旋锁允许“多个读者同时持有”但写者必须独占。读写信号量同样多个读者可以同时持有读锁写者则要等待所有读者退出。头文件分别是linux/rwlock.h和linux/rwsem.h。读写锁的使用示例rwlock_t rwlock; read_lock(rwlock); /* 多读者可同时到这里读 */ read_unlock(rwlock); write_lock(rwlock); /* 写者独占 */ write_unlock(rwlock);不过读写锁有个臭名昭著的缺点写者饥饿。如果读者络绎不绝写者可能长期等不到锁导致写操作延时无限拉长。在内核这类对实时性敏感的场景里这个缺点会被放大。所以现在内核里很多“读多写少”的场景转向了另一个更激进的方案——RCU。RCU的核心理念是“读者不用加锁”。读者在读一个共享数据结构时即使写者正在修改读者读到的可能是一个旧版本但这不影响正确性。写者修改数据时先复制一份副本在副本上完成修改然后通过一个指针切换把新版本发布出去。之后等待一个“宽限期”确保所有读者都已经不再持有旧版本的引用才真正释放旧内存。RCU的驱动级应用在网络设备的统计信息、路由表这些场景极为广泛。普通的字符设备驱动里用到RCU的倒是不多但如果你维护的是多CPU网络设备驱动处理每个包都要读取的配置数据RCU几乎是标配。RCU的API也比较多常见的有rcu_read_lock、rcu_read_unlock、rcu_assign_pointer、rcu_dereference。使用RCU的门槛更高特别是内存屏障和宽限期语义很难理解新手阶段我建议先知道有这个东西能看懂别人代码即可真要到优化阶段再深入。3. 驱动实战一个带同步的虚拟设备实现3.1 设计思路和状态布局说了这么多理论是时候拼一个真实感十足的示例了。假设我要实现一个简单的虚拟字符设备它具备以下功能维护一个32位的状态寄存器用户态可以读取状态、写入配置值同时驱动内部有一个内核线程或定时器会周期性更新某些状态位。这个设备在一开始设计时就注定有并发用户态的读写操作与内核内部的周期更新会同时访问同一份设备结构体。如果不做同步读取可能读到撕裂后的中间状态写入可能覆盖周期更新产生的数据。设备结构体我这样设计struct demo_dev { struct mutex lock; /* 保护配置和状态的一致访问 */ spinlock_t status_lock; /* 保护仅由中断/底半部更新的字段 */ u32 status; /* 内部状态位集合 */ u32 config; /* 用户可配置的数值 */ atomic_t open_count; /* 打开计数 */ wait_queue_head_t read_wait; /* 等待数据可读 */ struct timer_list timer; /* 周期更新定时器 */ };这里我刻意铺了三种原语互斥锁管用户态与进程上下文相关的字段自旋锁管定时器回调里要动的字段原子变量管打开计数。不同并发源用不同的锁来防这是驱动设计里很重要的一课锁不一定要“一把锁锁所有”而是看每个字段的访问路径来决定保护的粒度。这既能减少锁竞争也避免把互斥锁带进中断上下文。3.2 关键代码与锁的放置读写和配置接口用mutex保护代码长这样static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *off) { struct demo_dev *dev filp-private_data; char kbuf[64]; int len; if (mutex_lock_interruptible(dev-lock)) return -ERESTARTSYS; len snprintf(kbuf, sizeof(kbuf), status0x%08x config0x%08x\n, dev-status, dev-config); mutex_unlock(dev-lock); return simple_read_from_buffer(buf, count, off, kbuf, len); }注意两个细节。第一个是mutex_lock_interruptible不是mutex_lock。因为用户态read是有可能被信号打断的如果直接mutex_lock信号进来后进程无法响应表现为程序卡在read上按CtrlC都杀不掉。用interruptible版本被打断后返回非零驱动返回-ERESTARTSYS系统会自动重试或让用户态收到错误信号。这是驱动从业者的基本素养。第二个细节是我把持锁范围控制到最小。读取状态和配置只需要拼接字符串所以只在临界区里做了格式化把打印内容放进了内核缓冲区然后锁外释放。copy_to_user这类可能睡眠的调用摆在了锁外面避免了持锁睡眠的大忌。写端口的代码类似但涉及写入配置后要立即生效我特意在锁内调用了一个本地更新函数static void demo_apply_config_locked(struct demo_dev *dev, u32 cfg) { /* 修改配置并同步到硬件或内部状态 */ dev-config cfg; /* 这里可能还涉及寄存器读写操作但都是极短操作 */ } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *off) { struct demo_dev *dev filp-private_data; u32 cfg; int ret; ret kstrtou32_from_user(buf, count, 0, cfg); if (ret) return ret; mutex_lock(dev-lock); demo_apply_config_locked(dev, cfg); mutex_unlock(dev-lock); return count; }这段代码里有一个很容易被初学者忽略的点当你需要“先读共享配置再根据它执行后续逻辑”时整个判断和动作必须放在同一把锁的保护下。很多人会先把值读到一个局部变量然后放锁再根据局部变量做事。这种“读后放锁再行动”的模式如果后续和共享状态有耦合照样会踩并发。驱动里经常强调“把临界区画完整”就是这个意思。3.3 中断上下文的路口谁不能进虚拟设备需要模拟一个硬件中断源最简单的做法是使用内核定时器或高分辨率定时器在回调里模拟中断的状态更新。定时器回调运行在软中断上下文绝不允许睡眠因此它只能访问自旋锁保护的字段绝不能碰mutexstatic void demo_timer_callback(struct timer_list *tl) { struct demo_dev *dev from_timer(dev, tl, timer); unsigned long flags; spin_lock_irqsave(dev-status_lock, flags); dev-status | 0x01; /* 模拟硬件置位某个状态 */ /* 这里还可以触发中断下半部但注意千万别碰 dev-lock */ spin_unlock_irqrestore(dev-status_lock, flags); }这里有个全系统通用但特别容易犯错的问题如果你在定时器回调里试图获取mutex内核会直接报“BUG: sleeping function called from invalid context”然后可能死锁或崩溃。原因前面说过软中断上下文本来就不允许睡眠而mutex_lock内部一旦拿不到锁就要睡。反过来如果你在用户态读配置的mutex临界区里试图spin_lock_irqsave获取定时器用的自旋锁也要小心锁顺序。我的经验是在驱动里划分两类数据——进程上下文数据、中断上下文数据两类数据各用各的锁尽量减少交叉持锁路径这样能省掉大量死锁排查的时间。4. 死锁、性能与排查我在实际项目中踩过的坑4.1 死锁的几种典型姿势死锁问题在并发编程里是老生常谈但驱动开发中死锁的姿态尤其刁钻。我把常见的归纳成四类。第一类是“自死锁”。同一个线程对同一个mutex连续加锁两次。这种大多出现在重构代码时一个持锁函数内部又调用了另一个也需要同锁的辅助函数。解决办法是设计时区分_locked版本和对外版本。我在前面的demo_apply_config_locked命名里就是这样做的函数名明确告诉你“调用它之前必须持有锁”避免有人在持锁路径里再重复加锁。第二类是“锁顺序反转”。两个执行流分别持有一把锁又想获取对方的锁结果互相等待。典型场景是设备驱动里有A锁和B锁路径一先拿A再拿B路径二先拿B再拿A当两个路径并发时就会死锁。这个问题没有特别取巧的办法只能在设计上规定一个全局锁顺序所有人都按这个顺序加锁。第三类是“中断与进程上下文共用非irq版本自旋锁”。前面提过如果进程持锁时被中断打断中断处理程序也来抢同一把锁就死锁了。这是我最想强调的一次踩坑当时驱动程序在某个ioctl路径上用了spin_lock保护一个共享状态中断处理函数里也用spin_lock保护同一个状态。平时跑起来没事但只要某个中断恰好落在临界区期间整机直接假死。换成spin_lock_irqsave后问题再没出现过。第四类是“持锁睡眠”。最常见的就是在持有mutex的临界区里调用copy_to_user或msleep。虽然有少数场景确实可以在持有信号量时睡眠但互斥锁的语义和调度机制决定了在持有mutex时睡眠会引发很大的麻烦包括优先级反转和长时间持锁导致其他进程饥饿。内核里有might_sleep宏做检查调试版内核会在你可能睡眠的地方告警开发时务必开着这些检查。4.2 性能诊断与锁粒度锁用对了不代表就万事大吉持锁时间过长、竞争过于激烈同样会造成性能问题。我见过最典型的反面教材是驱动里所有操作包括只是读取一个配置值都去拿一个全局互斥锁。结果多核跑压力测试时这个锁成了全系统的瓶颈四个CPU有仨在等锁性能直接暴跌。处理锁粒度的原则其实就三条第一锁保护的临界区要尽量短把耗时操作挪到锁外第二不同的数据用不同的锁别一把锁锁遍整个设备第三读多写少的路径优先考虑读写锁或RCU而不是简单粗暴地全用互斥锁。我在做性能分析时喜欢用两种手段第一种是perf lock或者lock_stat直接看锁的等待时间和竞争次数第二种是看CPU的context-switch和irq占比如果发现CPU时间大量消耗在锁等待上再去定位具体是哪把锁。用/proc/lock_stat可以比较直观地看到每个锁的acquisitions和contention数据这个文件在大多数内核发行版里默认开启不过需要root权限。有一点要提醒性能优化要建立在正确性基础上。先把并发问题解决干净再谈性能。我见过有人为了“性能”把锁去掉结果数据错乱这个代价比性能损失大得多。4.3 我用过的调试工具与方法驱动并发调试图纸上谈兵没有用必须配合工具实打实地查。我的排查工具箱里大致有这三样。第一个是内核的锁调试机制。在编译内核时开启CONFIG_DEBUG_MUTEXES、CONFIG_DEBUG_SPINLOCK和CONFIG_PROVE_LOCKING。PROVE_LOCKING就是大名鼎鼎的lockdep它会在运行时动态分析锁的获取顺序并构建依赖图一旦发现可能死锁的序列立刻打印警告信息。我在开发驱动时一直开这个选项别的高端分析都可以不做lockdep的告警等于直接告诉你哪里有死锁风险。它虽然会增加不少运行时开销但开发阶段完全值得。第二个是KASAN和KMSAN用来检测内存的越界、野指针和未初始化数据访问。很多并发问题表面上是数据错乱根本原因是内存访问越界KASAN能很快揪出来。不过要注意KASAN与lockdep同时开启时内存开销很大一般调试时二选一即可。第三个是压力测试脚本。我写驱动后必做的一项工作是写一个用户态的压力工具多线程并发读写设备节点同时在内核里用一个debugfs节点反复触发定时器和中断源。跑个几小时观察有没有lockdep告警、有没有数据不一致、有没有系统卡死。这类测试不能省因为并发问题就是概率问题跑的时间越长信心越足。我还特别想分享一个排查技巧当系统死锁后陷入假死第一步不是盲目加打印而是先看sysrq也就是键盘组合键SysRq加w或l它能导出当前所有CPU的调用栈。配合kdump或串口日志你能立刻看到每个CPU卡在哪个函数、哪把锁上。这个方法在我职业生涯里救过我好几次连头绪都没有的死锁看完堆栈基本就锁定目标了。4.4 心态与习惯建议学并发控制最难的不是API而是思维方式的转变。我刚学驱动时也总喜欢“先写功能再想并发”结果每次上压力测试就翻车。后来我养成一个习惯写驱动的第一步不是画功能流程图而是画数据访问图。凡是能在图上交汇的数据都在写第一行代码前想好用什么锁保护。另一个建议是不要盲目复制网上的锁用法。很多网上示例为了简化故意省略中断上下文和并发场景照搬到自己驱动里就会炸。你真正要学的不是某个API长什么样而是“为什么这个场景需要这种锁”的判断逻辑。这个逻辑建立起来后面遇到任何新硬件新框架你都只需要对照场景做选择题就行。还有一条很实用的小原则锁的命名要直白。给锁起名时直接反映出它保护什么、使用限制是什么。比如cfg_lock、status_lock配合注释说明“中断上下文禁用”还是“仅进程上下文使用”。代码写出来是给人看的这种命名能防止自己和同事未来踩坑。写在最后我对同步的体会写驱动也有年头了回头看“互斥与同步”这一章我觉得内核开发者最需要建立的不是API记忆而是一种“并发直觉”——看到一个共享变量大脑自动跳出几条路径谁会读它谁会写它谁会持有锁然后调用我这些路径里哪些可能在中断里跑想通了这些选锁根本不用查手册完全是对号入座。最后分享一条经验在临界区代码前后加注释写清楚“这个锁为什么存在”是我见过性价比最高的维护手段。半年后回来看自己的代码你会感谢当年那个多写了几行注释的自己。锁这个东西用对了润物细无声用错了尸横遍野愿每位驱动开发新手都能在踩坑中成长在分享中进步。
阅读完成 · 觉得有帮助?
咨询建站