尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Linux内核同步机制深度剖析:自旋锁、RCU与死锁实战

Linux内核同步机制深度剖析:自旋锁、RCU与死锁实战 干内核开发这些年面试过不少新人几乎每次都绕不开一组问题自旋锁能不能在中断上下文用mutex为什么不能在硬中断里拿RCU到底是怎么做到读侧无锁的Linux内核同步机制这个主题说大不大说小不小但它就像操作系统的安全绳——写业务代码时你可以靠业务逻辑绕开并发问题写内核代码一旦锁用错轻则性能雪崩重则整机死机而且这种问题往往只在特定硬件、特定负载下才复现排查起来极其痛苦。这篇文章不是教科书式的罗列API而是按我在实际项目中“被坑过、调过、优化过”的路径把Linux内核同步机制背后的设计思路、选型逻辑、典型死锁案例和排查手段串一遍。适合正在学内核源码的读者、做嵌入式Linux驱动开发的朋友以及准备内核岗位面试的人。读完你会发现同步机制的核心不是背API而是理解“为什么这个场景必须用这个原语”。1. 同步机制全景先搞明白并发从哪里来1.1 并发根源中断、抢占与多核很多新手有个误区以为写内核代码只要锁够多就不会出事。其实不弄清并发从哪来锁永远是乱上的。Linux内核里的并发来源可以拆成三个独立维度这三个维度组合起来就是同步机制要解决的全部问题。第一个维度是中断。硬件中断随时可能打断当前执行流中断处理程序会立刻抢占当前CPU上正在运行的代码。这意味着即使在单核CPU上驱动里的普通函数也可能被中断处理程序并发访问。第二个维度是内核抢占。2.6内核引入抢占式内核后一个在内核态运行的进程可能被更高优先级的进程打断导致它持有的锁被另一个进程请求形成看似“不可能发生”的竞争。第三个维度才是真正考验并发的SMP多核。两个CPU同时执行同一段代码真正做到了物理上的并行。这三个维度叠加就产生了一个关键认知同步不是“有锁就行”而是要明确到底在防谁的并发。防中断要用关中断防抢占要用禁止抢占防SMP要上原子指令三者对应不同的原语混用就出乱子。我见过一个驱动只用了自旋锁保护共享变量却忘了关本地中断。结果CPU0正在自旋锁临界区里CPU1来了硬中断中断处理程序也抢同一个锁——CPU0直接被自己人锁死整机触发soft lockup。这就是典型的“没搞清楚防谁”的后果。1.2 同步原语全景图别一上来就写锁Linux内核的同步原语很多但按设计目标可以分几大类自旋锁spinlock解决“忙等”场景互斥锁与信号量mutex/semaphore解决“睡眠等待”场景读写锁rwlock与RCU解决“读多写少”场景顺序锁seqlock解决“写优先于读”场景原子操作atomic则是最底层的基石。再加上内存屏障memory barrier解决CPU乱序问题per-CPU变量解决“静态并发”问题。选型时我习惯先问三个问题临界区有多长处于什么上下文读写比例是多少临界区短、上下文不能睡那就是自旋锁临界区长、允许睡眠那就是mutex数据读多写少且读者性能敏感优先考虑RCU或读写锁。一张简表可以快速定位场景推荐原语核心原因短临界区中断上下文spin_lock_irqsave忙等且关本地中断防死锁长临界区进程上下文mutex睡眠让出CPU不浪费算力读多写少读者极多RCU读侧无锁性能几乎无损写者必须立刻看到最新值seqlock读者重试写者无等待单变量计数、标志位atomic_t硬件单指令完成开销最小这表不是金科玉律但在选型之初能帮你把方向走对。真到了性能瓶颈阶段再结合perf和lock统计去逐点优化也不迟。2. 自旋锁与睡眠锁两条技术路线的博弈2.1 自旋锁原理忙等背后的硬件依赖自旋锁的本质是“不释放CPU地等待”。它的核心操作是原子地完成“读锁状态、判断、置位”这个原子性由硬件指令保证。x86上用带有LOCK前缀的指令ARM64上使用LDXR/STXR这样的独占加载/存储指令对。理解了这点你就明白为什么自旋锁临界区必须短其他CPU在空转等待转一圈没拿到就再转一圈这个过程CPU执行单元被占着但啥正事也没干。自旋锁还有一个容易被忽略的作用它隐含了关闭抢占。在非RT内核中拿到自旋锁的代码路径如果被抢占另一个进程又去抢同一个锁被抢走的CPU还在自旋等待那拿到锁的进程永远没机会继续执行直接死锁。所以spin_lock内部会禁止内核抢占临界区执行完再恢复。这也是为什么自旋锁睡觉是内核编程的大忌——睡眠会触发调度调度时若试图再次获得同一把锁前面说过的死锁就会立刻上演。我在调试时见过无数类似日志BUG: scheduling while atomic: kworker/0:1/25/0x00000002这就是典型的“原子上下文里睡眠”。这个0x00000002就是preempt_count保存的状态信息它告诉你当前处于关抢占状态。看到这行日志第一反应就该是代码把只能在进程上下文用的机制mutex、kmalloc用GFP_KERNEL等带进了自旋锁保护区域。2.2 互斥锁与信号量睡眠唤醒机制的代价与收益与自旋锁的忙等不同互斥锁在锁被占有时选择“睡眠等待”。内核里对应的是struct mutex等待者会被挂到这把锁的等待队列上进入睡眠状态直到持有者释放时通过唤醒机制把排队者叫醒继续竞争。这里最关键的设计考量就是睡眠-唤醒的开销远大于简单自旋。x86上mutex的一次快速加锁解锁只有几十纳秒但一旦发生真正的睡眠唤醒涉及上下文切换和调度器介入开销直接上升两三个数量级。所以mutex适合临界区长的场景——虽然单次切换贵但等待期间CPU可以做别的事整体吞吐反而更高。mutex与信号量semaphore经常被混淆。semaphore本质是个计数器允许多个持有者而mutex是二值的、持有者可以安全递归的语义其实也已经被放弃Linux的mutex明确不支持递归加锁。实际工程中绝大多数互斥场景用mutex就对了信号量更多用于资源计数场景比如限制并发访问的设备。我见过有人拿信号量当互斥锁用计数设1结果代码里是能用但语义表达极差后续维护的人看到semaphore默认会认为可以有多个持有者这就是埋坑。任何睡眠锁的底层都离不开等待队列。热词里的eventfd其实就是一套更轻量的事件唤醒机制本质上是把“文件描述符”映射到内核等待队列进程通过read/poll阻塞在eventfd上另一个上下文通过write触发唤醒。理解了等待队列与唤醒机制看eventfd、futex快速用户态互斥都会一通百通因为它们的核心都是“排队 唤醒 重新竞争”。2.3 中断上下文铁律为什么spinlock能而mutex不能进程上下文、硬中断上下文、软中断上下文这三态决定了你能用哪套同步原语。硬中断处理程序绝对禁止睡眠——中断上下文不归属于任何进程没有task_struct可以调度sleep就是系统崩溃。因此硬中断里只能使用自旋锁而且要选择关闭本地中断的变体。为什么自旋锁在中断里还要特地关本地中断考虑这个场景进程上下文持锁执行同一CPU上来了硬中断中断处理程序也去抢同一把锁。此时锁持有者被中断打断中断程序自旋等锁而锁永远不可能被释放——持有者已经不在运行了。这就是单核上的死锁。spin_lock_irqsave在加锁时同时关闭本地CPU中断硬中断根本不会执行这个经典死锁就无从谈起。很多新手会问都SMP时代了关本地中断还有意义吗有意义。关本地中断防的是“本CPU上的执行流重入”自旋锁防的是“其他CPU的并发访问”二者缺一不可。热词里有“arm64内核spinlock睡眠死锁”这正是实际内核开发中的高发事故有人把自旋锁保护的临界区拉长到几百微秒期间触发了睡眠或显式调度轻则触发调度器警告重则在RT实时内核上触发死锁检。RT内核为了降低自旋锁的关抢占时间会把自旋锁实现为可睡眠的互斥锁这让“自旋锁睡眠”问题在RT环境下的表现更加敏感这也是面试官最爱问的进阶题之一。3. 读写锁、顺序锁与RCU读多写少场景的性能探索3.1 读写锁读者共享、写者排他如果数据结构有鲜明的读多写少特征用普通互斥锁会冤枉大量读者——所有读者串行化性能直接死掉。读写锁rwlock_t允许任意多个读者同时持锁只有写者需要独占。听起来很美好但在实际使用中读写锁在绝大多数内核子系统中已经不再是首选原因有两点。第一是cache line颠簸。多个读者虽然不互斥但都要去读同一个锁变量来检查状态这个锁变量所在的cache line会在多个CPU之间频繁传输所有读者都在抢同一块缓存反而产生大量缓存一致性流量。读者越多这个内耗越大。第二是写者饥饿问题。不断到来的读者流会让写者迟迟拿不到锁如果写者代表的是配置更新、路由变化那数据面延迟就会抖动剧烈。所以读写锁适合“读者规模可控、写者频率低”的场景比如少量CPU上的配置表同步。3.2 顺序锁宁可让读者重试也要保证写者不被饿死顺序锁seqlock的思路和读写锁截然不同写者永远优先读者不做互斥而是通过序列号校验自己的读操作是否被写者打断。读者读之前取一个序号读完后再取一次序号如果前后一致且为偶数说明这次读是完整的否则就重试。这种情况下读者被打断的概率很低重试代价也可控。内核里最典型的使用者是jiffies_64的读取。64位时钟值在32位平台上无法用单次原子读完成如果读的瞬间值正在更新读到半个旧值半个新值那时间就乱了。seqlock机制顺滑地解决了这个问题读者读两次如果发现序列号变化了就再读一次最多重试两三次就能拿到一致快照。这类“天然极高写频率、读者读一致性要求高”的场景就是seqlock的主场。普通业务代码里如果你需要维护一个指标计数器、状态快照且写操作来自定时器而读操作来自用户请求也可以参考这个思路。3.3 RCU读侧无锁的终极方案RCURead-Copy Update是我觉得内核同步里最优雅也最难掌握的原语。它的核心思想是读者不拿任何锁直接读写者修改数据时先复制一份出来在副本上改然后通过原子的指针替换发布新版本旧版本不会立即释放而是等待一个宽限期grace period确保所有可能正在读旧版本的读者都离开临界区后才真正回收旧数据。读侧无锁的性能收益极其可观。以路由查找为例在纯RCU实现下读者就是一次指针加载加解引用无原子操作、无cache line竞争在几十个核上几乎可以线性扩展。这也是为什么网络子系统、文件系统的dentry缓存、netfilter规则集在内核中都采用RCU实现。内核里RCU的使用模式非常固定读者用rcu_read_lock()在非抢占RCU下甚至只关抢占几乎没有额外代价然后通过rcu_dereference()读指针写者用rcu_assign_pointer()发布再调用synchronize_rcu()或call_rcu()延迟回收。写者侧要注意synchronize_rcu()会阻塞直到所有CPU都经历一次静止状态。这通常是毫秒级所以RCU的写者不能用在快速路径上。还有一个细节容易被踩rcu_dereference和rcu_assign_pointer不只是普通赋值它们内部隐含了内存屏障。发布侧必须保证“先写数据、后更新指针”读侧必须保证“先读指针、后读数据”否则多核下有概率读到半初始化的对象。我自己曾经只写了个普通指针赋值结果在ARM64高并发测试下隔三差五出现悬空指针——后来才明白RCU能成立的关键就在于这些成对的屏障语义。内核还提供了RCU的调试检测工具比如CONFIG_PROVE_RCU可以帮你抓出错误使用场景。我用syzkaller跑内核模糊测试的时候遇到的一大类崩溃就是RCU相关——非法上下文调用synchronize_rcu、宽限期回调里睡眠这些都能通过CONFIG_PROVE_RCU直接暴露。如果你想在内核里fuzz找问题建议先把这套调试选项开开。4. 原子操作与内存屏障最底层的一砖一瓦4.1 原子操作一次搞定读-改-写所有同步原语的底层最终都落在原子操作上。atomic_t提供了一系列“读-修改-写”的单指令保证比如atomic_inc、atomic_dec_and_test它们在x86上由LOCK前缀指令实现在ARM64上由LDXR/STXR独占指令对实现。如果没有原子操作自旋锁本身都无法实现因为“测试并置位”必须是一个不被打断的整体。实际工程中最简单的引用计数、标志位切换、统计计数都应该优先用atomic操作而不是拿自旋锁去保护一个普通变量。原因还是性能原子操作即使加锁前缀也只是锁内存总线开销远小于自旋锁解引用锁内访问的总和。不过要注意atomic操作只保证“单变量原子性”不保证多变量的整体一致性。比如你要同时更新两个计数器就必须回到锁的层面。我在驱动开发里维持一个习惯先画数据流图把单变量计数和复杂数据结构分开对待前者一律原子操作后者再考虑锁。热词里有“内核缓冲”相关搜索。从同步视角看环形缓冲区ring buffer的设计就是一种通过原子读写指针避免锁竞争的高性能方案。内核的per-CPU变量与环形缓冲区配合可以把“多CPU写、单CPU读”的高频日志、性能计数做到完全无锁化。理解原子操作后你去看内核里各种无锁队列的实现就会很轻松——它们的核心就是用读指针和写指针的原子更新来保证生产者与消费者互不踩踏。4.2 内存屏障编译器与CPU都在偷偷乱序原子操作解决的是“读改写”的原子性但还有一个更隐蔽的问题是顺序性。写代码时你认为先执行A再执行B实际上编译器和CPU都可能把顺序打乱。编译器为了优化寄存器分配会重排指令CPU为了填充流水线会乱序执行存储子系统也会通过store buffer等机制让其他核看到的写入顺序与你想象的不同。内存屏障的作用就是限制这种乱序。smp_mb()是全屏障smp_rmb()只限制读之间重排smp_wmb()只限制写之间重排。在x86上由于TSO总存储序模型写屏障在多数场景是CPU空操作但在ARM64、PowerPC这些弱内存模型架构上屏障极其重要。最经典的案例是生产者-消费者模式生产者写入数据后置位“数据就绪”标志消费者看到标志后读取数据。如果中间没有写屏障消费者可能先看到标志后看到的是旧数据这在弱内存模型下是真实会发生的。内核源码里有很多蕴含屏障的API让人困惑比如spin_lock既保证锁本身的竞争关系也充当了全屏障rcu_assign_pointer自带发布语义。我在读内核代码时遇到一个巧记规则凡是“一个核写、另一个核读”的跨核通信中间有同步原语的原语通常自带屏障没有同步原语的裸变量就要考虑是否显式加smp_mb()。凡是自己造无锁数据结构的地方内存屏障永远要画在图上画不出来就说明还没想清楚。4.3 ARM64上的自旋锁从WFE到锁的进化热词里反复出现“arm64内核spinlock睡眠死锁”这里展开讲讲。ARM64的经典自旋锁用LDXR/STXR实现快速路径如果获取失败会执行WFE等待事件指令进入低功耗等待由其他核释放锁时发送事件唤醒而不是在x86上那样纯总线轮询。这种实现本身是很高效的但WFE的引入让锁的调试难度上一个台阶——你看到CPU卡在某条指令上未必是死锁可能只是在低功耗等待。ARM64的另一个变化是内核5.x之后逐步合入了队列自旋锁qspinlock。qspinlock的好处是锁竞争激烈时等待者排成队列每个CPU只在它的前驱释放时被唤醒避免了所有CPU同时轮询同一个cache line的传统自旋锁“惊群”问题。ARM64 qspinlock的适配很考验对体系结构的理解MCS锁qspinlock的前身要求锁变量的同一个cache line上能存链表节点信息而ARM64的cache line较长节点布局如果没对齐性能反而会劣化。所以不要因为x86上有qspinlock就默认ARM64也能直接获得同样收益我实测过锁竞争下的吞吐提升确实存在但需要配合内核配置和热点路径的针对性优化。还有一个高频细节是“自旋锁禁止睡眠”在ARM64上的具体表现。RT内核中spinlock很大程度上被替换为rt_mutex实现临界区允许被抢占但代价是引入了更复杂的PI优先级继承机制。这带来一个典型的坑驱动假定“自旋锁临界区不会被调度”在RT内核上就会暴露问题比如临界区里用了只在原子上下文才允许的接口或者临界区时长不可控导致延迟抖动。如果你做嵌入式ARM64平台强烈建议在烧板前先把RT内核与标准内核各跑一遍同一个驱动测试两个内核的锁行为差异是驱动兼容性的试金石。5. 实操避坑死锁案例、lockdep定位与性能优化5.1 经典死锁案例我从同事的教训里总结的三条铁律写锁代码最怕的是死锁。死锁有三个经典触发路径每个我都见过真实事故。第一是锁顺序不一致。两个CPU分别持有锁A、锁B又互相等待对方的锁形成ABBA死锁。比如CPU1持有锁A后试图获取锁BCPU2持有锁B后试图获取锁A两边都在等对方释放永远等不到。解决方式是给所有锁定一个全局顺序凡是多处加锁的代码永远按同一顺序获取。内核文档里称为锁排序。第二是递归加锁。同一个执行流试图获取自己已经持有的非递归锁直接把自己锁住。Linux的mutex明确不支持递归第二次加同一把锁就触发死锁检测。解决方式是拆分锁粒度或者改用专门的计数机制而不是依赖递归锁。第三是自旋锁临界区里睡眠这在2.3节提过。它的表现很迷惑——有时不是立刻崩溃而是系统随机卡死因为睡眠后调度其他进程其他进程又去抢同一把锁整个系统进入微妙的状态。热词里的“arm64 spinlock睡眠死锁”就是这个问题的ARM64版本。实践中我把这三条铁律贴在工位上加锁统一顺序、绝不递归持锁、原子上下文绝不睡眠。5.2 lockdep内核自带的死锁侦探Linux有一个极其强大的死锁检测工具lockdep对应内核配置CONFIG_PROVE_LOCKING。它的工作方式是运行时记录所有加锁序列构建“锁依赖图”每当出现新的依赖关系就检查是否形成环——有环就说明存在ABBA死锁可能立刻打印一份锁依赖冲突报告。这个东西厉害在只要你在测试中触发过一次加锁顺序交叉哪怕当时没有真正死锁lockdep就能把隐患揪出来。我调试过一个驱动死锁dmesg输出一大段splat关键信息在Possible interrupt unsafe locking scenario附近以及Possible unsafe locking scenario分类。找到两个锁名之后再结合当前的加锁栈回溯基本能定位是哪个入口路径先拿A后拿B哪个路径又是先拿B后拿A然后按全局顺序统一即可。排查步骤值得记下来确认内核开启了CONFIG_PROVE_LOCKING与CONFIG_DEBUG_ATOMIC_SLEEP运行时复现问题收集dmesg中的lockdep splat从splat中提取两个锁的名字与获取栈确认ABBA或单锁递归类型修代码统一加锁顺序或拆分锁再跑一轮lockdep确认无新splat。注意lockdep只在调试内核上开启生产环境必须关闭因为它有可观的开销。给客户量产固件前我习惯专门编一版开启全套调试选项的“体检内核”在测试环境压力跑24小时确认无lockdep告警再合入release分支。这套流程能过滤掉绝大多数同步隐患。5.3 性能优化实测从futex到percpu再到大内核锁当同步不是出错而是影响性能时优化路径也有章可循。首先要量化竞争程度可以用perf lock report查看锁竞争事件也可以用内核的lock_statCONFIG_LOCK_STAT统计每把锁的等待时间、自旋次数、冲突次数。我在一次网络转发性能调优中发现路由表查询锁的冲突率达到每秒几十万次当时第一反应就是RCU化——读者彻底无锁后PPS吞吐直接翻了将近一倍。如果锁的粒度没法继续缩小另一个常用手段是per-CPU变量。每个CPU只操作自己那副本完全无锁读取时如果有跨CPU一致性需求再额外处理。内核的很多统计计数器都采用这种方式。还有一招是批量操作减少加锁次数——一次锁内处理一批对象而不是逐对象加锁。这个思路和用户态编程里的“批量提交”一致但在内核里收益更加直接。热词里有“linux修改进程名称”这样的操作类需求其实从同步视角来看进程名修改涉及task_struct里的comm字段写操作与ps、top读取之间的同步内核里也是用rcu或者seqlock等机制保护。这里想表达的是同步机制不只是锁它贯穿内核所有子系统你会到处看到这些原语的身影。看内核代码时能认出“这里为什么用seqlock”“那里为什么用RCU”比你背下所有API都重要。面试时回答“mutex与spinlock怎么选”的问题如果能把中断上下文、抢占、临界区长度三者串起来讲基本就是过关了。我个人在实际操作中的体会是入门同步机制第一站应该是自旋锁和mutex先把这两个的适用场景和限制写清楚然后带着问题去读内核源码里对应的实现比如kernel/locking/spinlock.c和kernel/locking/mutex.c最后再啃RCU和内存屏障这两个硬骨头。如果你能独立把arm64上的自旋锁实现从LDXR/STXR到qspinlock的演进讲明白并把lockdep的一篇splat报告完整解读出来那内核同步机制这块你已经超过绝大多数候选人了。
返回列表