
1. 为什么内核里一定要做同步管理1.1 并发是内核运行的常态很多刚接触Linux内核的人都会有个错觉内核代码是单线程跑的一个函数执行到底不会有别人来打扰。这个想法相当危险——实际上从你按下电源键那一刻起内核就被扔进了一个高度并发的环境里。先数一下内核里的执行者都有谁。首先是CPU上的进程上下文一个四核系统里同时跑着几十个甚至上百个用户态进程每个进程随时可能通过系统调用进入内核态其次是中断上下文网卡收包、磁盘完成IO、定时器到期这些中断可以在任何时刻抢占当前CPU上的执行流然后是软中断和workqueue它们在内核线程或者中断返回路径里执行再加上多核CPU的并行执行同一份内核代码可能同时在好几个CPU核上跑。举一个最常见的例子struct file里的引用计数f_count。进程A打开同一个文件两次得到两个fd它们指向同一个struct file对象进程B又通过dup复制了一个fd也会增加这个对象的引用计数。如果A和B同时在两个CPU核上执行atomic_long_inc(file-f_count)而没有合适的同步保护这个计数可能从2变成3而不是4。一次两次看不出问题等到计数少一个某个进程close的时候把计数减到0提前释放了还在被其他人使用的文件对象这就离系统崩溃不远了。1.2 竞态条件到底怎么破坏数据同步管理的核心就是解决竞态条件race condition。理解竞态最好回到读-改-写这个最基本的三步操作。就拿最简单的计数器来说count count 1在高级语言里是一条语句但在CPU层面要拆成三条指令从内存加载count到寄存器、寄存器加1、把寄存器写回内存。多核环境下CPU A和CPU B同时执行这三条指令时序完全交错最后写回内存的值取决于谁最后执行第三步。两个核都从内存里读到了相同的旧值5各自加1变成6然后先后写回最终内存里是6但正确的答案应该是7。这场事故的根源在于读、改、写这三步操作没有作为一个不可分割的整体被执行中间被人插了一脚。这就是同步管理要解决的根本问题——保证临界区critical section一段访问共享数据的代码的原子性。要么别让人插队要么插队的人等着等当前执行者完事后再说。Linux内核提供了从硬件指令到高层锁机制的一整套方案让驱动开发者、内核子系统维护者能有针对性地保护自己的数据。这些工具各有脾气用对了系统安稳流畅用错了要么死锁重启要么性能雪崩要么在负载最高的深夜里dump一屏幕的oops信息。2. 同步原语全家桶先认识你的工具2.1 原子操作最底层的同步基础内核同步的第一层地基是原子操作它不需要程序员显式加锁而是把读-改-写这一步在硬件层面变成一条不可分割的指令。x86上的lock; addl $1, %eaxARM上的LDREX/STREX都是干这个事的。内核给上层提供了一套与架构无关的API比如atomic_t类型的atomic_inc()、atomic_dec_and_test()、atomic_cmpxchg()。使用这些接口有个前提共享数据的访问必须全部走原子操作API不能有人偷偷用普通赋值去读写它。如果你在一个驱动里用atomic_t管理标志位却在另外一处直接写p-flag 1那原子保护就形同虚设了。原子操作是最快的同步手段因为它不涉及锁的获取和释放也没有等待队列。但它的能力上限很低——只能保护一个整数、一个指针或一个位图没法保护一段复杂的结构体操作。如果临界区牵涉到多个字段的协同修改原子操作就派不上用场了得上面一级的工具出手。2.2 自旋锁忙等待的适用边界自旋锁spinlock是内核里用得最频繁的锁之一。它的行为模式很好理解锁被占用时后来的竞争者就在原地死等不断循环检测锁是否释放整个过程CPU一直在跑所以叫自旋。为什么不用更省事的睡眠等待因为自旋锁面对的场景往往是不允许睡眠的。中断处理程序里拿锁你要是让它睡过去整个中断上下文就挂了在持有自旋锁的临界区里有时候还关着内核抢占睡下去就是给自己挖坑。而且临界区本身要求极短通常几十条指令就完事与其把当前线程挂起再唤醒这个过程要花掉好几微秒不如原地转几个圈等对方释放。自旋锁使用有一个铁律临界区里禁止调用任何可能睡眠的函数。kmalloc(..., GFP_KERNEL)不行copy_to_user()不行mutex_lock()更不行。一旦你在自旋锁里睡了持有锁的CPU可能被调度器换走而其他CPU上等待锁的人还在疯狂自旋系统直接卡死watchdog会毫不留情地报soft lockup。内核还提供了spin_lock_irqsave()这一族接口它在获取锁的同时会关闭本地中断并保存中断标志。为什么要这么干因为如果中断处理程序和进程上下文争用同一把自旋锁进程这边拿着锁的时候来了中断中断处理程序跑来抢同一把锁就形成死锁了。irqsave版本的接口把本地中断屏蔽掉从根上切断这种死锁路径。代价是关中断的这段时间里该CPU上的所有中断都被延迟处理对实时性有影响所以要尽量缩短持有锁的时间。2.3 互斥锁与信号量谁在睡觉谁在等如果说自旋锁是死磕派互斥锁mutex就是躺平派。struct mutex在锁被占用时后来的竞争者会被放入等待队列然后调度器把当前任务切换出去让出CPU给其他任务直到持有者释放锁时再唤醒等待者。互斥锁的核心优势是临界区可以很长也可以在其中睡眠。比如设备驱动里要操作一个慢速外设读写一个寄存器要等几十毫秒这种场景就是mutex的主场。它的使用约束有两类一是持有mutex时不能调用down_interruptible()之后又忽略返回值二是同一线程不能对同一个mutex二次加锁——mutex不像信号量那样支持随意增减计数它天然就是互斥的递归加锁会直接死锁。信号量semaphore在旧内核里是通用同步手段但现在地位大不如前。它的计数语义允许同时有N个持有者适合控制资源池的并发访问数量。不过大多数场景下mutex已经能覆盖需求而且mutex的实现针对互斥场景做了大量优化比如乐观自旋、手写汇编的快速路径性能比通用信号量好。所以内核开发社区的态度很明确新代码优先用mutex信号量留给那些确实需要计数语义的场合。2.4 读写锁与顺序锁分别照顾谁读写场景下读操作和写操作对锁的需求完全不同。多个读者可以同时进入临界区但读者和写者互斥。rwlock_t就为这种场景设计读者拿读锁时是共享的写者必须等所有读者退出拿到写锁后独占。但读写锁有个经典的性能陷阱读者太多会导致写者饥饿——写者一直等不到所有读者归零的时刻。而且rwlock_t在Linux内核里的实现并不区分读者优先级真实世界中频繁加读锁反而会引发大量缓存行竞争。因此新代码里很多地方已经从rwlock_t迁走改用RCU或者percpu-rwsem了。顺序锁seqlock换了一种思路它牺牲了读者之间的互斥性读者读数据时不加锁、不做同步读完了再检查一个序列号sequence如果发现读的过程中写者动过数据序列号变了偶数奇偶校验不过就重读一遍。写者拿锁时序列号变奇数释放时变成偶数。因为读者无需等待seqlock在写多读少、读者能容忍偶尔重读的场景下非常高效。内核里jiffies的读取就是经典例子——读一个64位的时间值偶发读到写了一半的中间态重读一次即可完全没问题。2.5 RCU读多写少场景的终极方案RCURead-Copy-Update是Linux内核里最复杂也最优雅的同步机制专门对付读操作极多、写操作极少的场景。它的核心思想读者读旧数据写者先复制一份副本在副本上做修改改完后用一次原子指针替换把新数据发布出去。整个过程中读者没有任何锁开销也不需要原子操作就是一次普通读指针。关键问题来了旧数据什么时候能释放写者必须等所有读者离开旧数据的读端临界区之后才能回收。RCU维护了每个CPU上处于读端临界区的状态通过一个宽限期grace period来判定安全时机。读者只需要rcu_read_lock()/rcu_read_unlock()包住指针访问这两个函数在非抢占内核里几乎什么都不做只是编译器屏障开销趋近于零。RCU的使用难点在于读者侧必须用rcu_dereference()读取指针写者侧必须用rcu_assign_pointer()发布而且被RCU保护的指针不能直接解引用后再跨临界区使用因为那时候对象可能已经被回收了。新手最容易犯的错就是从RCU临界区里把一个指针带出来放到另一个线程再用结果UAFuse-after-free崩溃。倒是不难理解的一句话RCU保护的是临界区内的那次访问不是保护指针指向的对象永生。2.6 内存屏障与per-CPU变量原子操作和锁解决了谁先谁后的互斥问题但现代CPU的乱序执行和缓存一致性还引出一个更隐蔽的问题——内存序。一个CPU写入A再写入B另一个CPU可能先看到B的新值再看到A的新值。锁本身自带内存屏障保证临界区内外的内存访问是有序的所以只要规范使用锁大多数人不直接碰内存屏障。但写无锁代码的人都必须精确理解smp_mb()、smp_rmb()、smp_wmb()的区别还有READ_ONCE/WRITE_ONCE这类标注性API——它们告诉编译器和CPU这个访问是共享的别给我优化掉也别乱排顺序。自我体验最深的一次是在调试一个无锁队列时明明两个核之间用原子操作交换了状态却还是出现了一方读到陈旧数据的情况。后来定位到是缺少读侧屏障插入一行smp_rmb()后问题消失。这类bug的排查成本极高因为它不是必现而是依赖CPU的负载、频率和微架构可能跑几十个小时才冒一次头。per-CPU变量则是另一个维度的同步思路——既然多个CPU访问同一块数据会竞争那干脆给每个CPU一份独立副本谁也不碰谁的数据天然就不需要加锁。内核里DEFINE_PER_CPU、get_cpu_var()、this_cpu_inc()都基于这个思想。每个CPU操作自己的副本完全无锁性能极好。代价是别的CPU读到的是自己那份旧副本数据一致性存在延迟所以per-CPU变量适合放置统计计数、运行状态这类最终一致就够用的数据。内核里的nr_irqs统计、各种per_cpu的调度统计数据都是这么处理的。3. 核心细节与原理剖析3.1 锁的底层实现从cmpxchg到futex很多人以为锁是一个高大上的黑魔法其实底层核心就是一个原子比较交换指令。x86上的cmpxchg、ARM上的LDREX/STREX循环共同特点是可以原子地完成读旧值、比较、写新值这一整个过程。自旋锁的获取本质上就是循环对锁变量执行如果值为0就换成1这个原子操作成功的人拿到锁失败的人继续循环。但互斥锁的实现要复杂得多因为它牵扯到睡眠和唤醒。现代内核的mutex实现分两条路径快速路径fastpath和慢速路径slowpath。快速路径里锁没被占用时加锁就是一次原子cmpxchg释放就是一次原子写整个过程不涉及系统调用也不触发调度性能很高。只有当锁被占用时竞争者才会走慢速路径把自己挂入等待队列、让出CPU。等锁释放时原子操作发现等待队列有人就通过wake_up()唤醒等待者。这里牵涉到进程调度、等待队列的维护成本一下子上来了。这里需要特别说明的是x86架构的实现细节比如CMPXCHG指令的使用是典型的架构相关设计但Linux内核特意抽象出一层通用API让驱动和子系统作者能写出跨平台代码。你写驱动时根本不需要关心这把锁在ARM和x86上底层有什么区别直接用spin_lock()就完了架构差异已经被内核消化掉了。3.2 睡眠与调度mutex为什么比spin_lock贵直接对比两个加锁操作的开销非常能说明问题。spin_lock()加锁在锁空闲时就是几十个纳秒的原子操作mutex_lock()在锁空闲时由于已经做了优化也能达到亚微秒级别。但一旦发生竞争mutex就要走完整套挂入等待队列、让出CPU、唤醒后重新调度流程这个开销量级是微秒甚至几十微秒比自旋锁的忙等高出几个数量级。但这不意味着mutex不如spin_lock两者的定位完全不同。自旋锁适合极短临界区允许竞争者在原地高速自转mutex适合长临界区让竞争者去睡把CPU让给其他有用的工作。用自旋锁去保护一个可能耗时几毫秒的临界区是灾难性的——四个核上三颗CPU都在空转系统吞吐直接归零用mutex去保护一个只有几条指令的临界区又有点大材小用因为每次加解锁的调度器开销完全覆盖了临界区本身的执行时间白亏性能。实际内核开发中还有个常见问题**你到底能不能睡**中断上下文里只能自旋锁进程上下文里两种都能用。所以写代码之前第一件事永远是搞清当前执行上下文是什么。驱动中一个函数可能既被进程上下文调用又被中断回调调用这种时候通常的解法是分两级保护进程上下文用mutex保护慢速操作中断上下文用自旋锁保护快速路径两者再通过一个标志位协调。3.3 lockdep内核自带的锁验证器Linux内核有一个几乎每个人刚开始都忽略、后来都爱不释手的调试利器——lockdepLock Dependency Validator。开启CONFIG_PROVE_LOCKING后内核会在每次加锁时记录锁的获取顺序和上下文信息构建一张锁依赖图然后实时检查这张图里有没有环。一旦出现环形依赖比如锁A后面锁B另一个路径又先锁B后锁Alockdep会立刻报出死锁可能打印完整的调用栈和锁的获取顺序。我建议所有内核开发者在开发阶段都开着lockdep再配合KASAN使用。虽然它会让系统变慢但换来的价值远超这点性能损耗。实际经验中lockdep抓到的死锁比人眼review出来的多得多。尤其是驱动代码里一个寄存器配置函数很可能在多个路径中被调用有些路径已经拿了锁A有些路径拿了锁B单看每个路径都没问题交叉起来就出环了。这种问题靠人肉review效率很低lockdep几秒内就能指认现场。lockdep还会检查在持有自旋锁的临界区里调用可能睡眠的函数这类问题因为睡眠本质上是获取调度器这把锁环形依赖同样会被捕捉。我遇到过不少BUG: sleeping function called from invalid context的报错追根溯源都是自旋锁里调了kmalloc(GFP_KERNEL)这个错误类型非常有辨识度——看到这个报错第一反应就是检查是不是在原子上下文里做了不该做的事。4. 工具选型与场景适配4.1 场景判断你的临界区有多长面对一个具体的同步需求怎么选工具我总结了一个判断顺序实际问题中差不多够用确认执行上下文中断处理、软中断、进程上下文估算临界区耗时几纳秒、几微秒、还是可能被阻塞统计访问模式读多写少、写多读少、还是读写均衡看数据特征是单个整数、复杂结构体还是每CPU独立的统计量把这些因素摆到桌面上选型基本就出来了。中断上下文只能用自旋锁或原子操作这个致命约束已经帮你排除了一大半选项进程上下文且临界区会长阻塞就选mutex读操作非常多、写极少且可以接受读者重试优先考虑RCU或seqlock每个CPU各自维护的统计计数直接用per-CPU变量连锁都不用碰。反馈到真实项目里我见过很多开发者一上来就无脑用自旋锁理由是它快。但快的前提是没人竞争。临界区稍微长一点竞争者一多整个CPU都在空转吞吐量反而比mutex差一个数量级。我的体会是锁的选择本质是在等待成本和唤醒成本之间做权衡。等锁的时间短自旋划算等锁时间长睡眠划算。理解这个本质选型就不再依赖于背规则了。4.2 性能视角锁竞争与缓存行伪共享刚刚开始思考锁的性能时很多人只关注锁自身的开销忽略了锁保护的共享数据才是真正的瓶颈。现代CPU的缓存系统以缓存行cache line为粒度同步数据一个缓存行通常64字节。当两个CPU频繁读写同一把锁保护的同一块数据时它们会反复争抢这行缓存的所有权互相使对方的缓存行失效导致大量的核间通信cache line bouncing性能损耗远超锁操作本身。经典的优化手段是让不同CPU访问的数据分散到不同缓存行——要么用per-CPU变量天然隔离要么在结构体字段间填充____cacheline_aligned之类的东西。另外在某些场景下使用atomic_t配合local_t也能减少锁的介入频率。再多说一点自旋锁的竞争其实是一场惊群式的抢夺。每个释放锁的瞬间多个等待者同时看到锁释放了同时发起原子操作但只有一个成功其余人继续自旋。这种反复的冲突也是性能杀手。所以内核里很多高级数据结构如qspinlock引入排队机制让等待者按照FIFO顺序获取锁减少无意义的冲突。x86和ARM上主流的自旋锁都是排队自旋锁queued spinlock。写驱动时如果调试发现锁竞争曲线异常高先别急着换锁类型看看是不是可以把共享数据拆细一点、把它们分散到多个锁粒度里通常效果比换锁明显得多。5. 常见问题与排查技巧实录5.1 死锁四条件与真实案例死锁是同步管理里最臭名昭著的问题内核死锁往往意味着系统挂死只能按电源键强制重启。死锁产生的四个必要条件互斥、持有并等待、不可剥夺、循环等待。四者同时满足才会死锁。所以排查一个疑似死锁问题时挨个去验证这四个条件往往能快速缩小范围。分享一个实际驱动调试中遇到的例子一个网卡驱动里发送路径持有锁A后调用发送完成处理函数这个函数里又去拿锁B与此同时中断处理路径持有锁B然后因为要更新某个统计字段又去拿锁A。暂停下来看就惊呆了——这是一条完美的环形依赖。但因为实际触发取决于特定时序和包速率平时测试根本跑不出来一上线流量一上来就死。后来就是靠lockdep在测试环境中复现抓到的打印出的依赖链非常直观修改方案就是消除一条路径上的锁获取改成用原子变量。死锁还有一个隐蔽变种中断导致的隐式死锁。前面提到的spin_lock_irqsave就是专门防这个的。如果一个进程在某CPU上持锁此时该CPU来了中断中断处理程序也请求同一把锁就构成了进程持有锁等待中断处理程序、中断处理程序等待锁的死锁。因为中断通常不能被进程主动让出这类死锁一旦发生CPU就无法处理任何中断了系统基本立即完蛋。5.2 锁顺序不一致重置后可能踩坑锁顺序问题是在多锁嵌套场景下最容易踩的坑。两个锁A和B代码路径1先锁A再锁B路径2先锁B再锁A一旦两者交叉执行就可能循环等待。和死锁不同锁顺序问题往往是概率性出现严重依赖时序和负载所以非常有欺骗性——你可能连续跑几天测试都过一上生产环境就翻车。内核社区对锁顺序有一套不成文的建议尽量给锁定义一套全局顺序比如按结构体地址排序所有路径都按这个顺序获取。实在做不到就尽量减少嵌套锁的数量把多个锁保护的资源统一合并成一个更粗粒度的锁。虽然粗粒度锁竞争率高一些但换来的是简单性对多数驱动和子系统来说都划算。一个值得介绍的辅助工具是CONFIG_PROVE_LOCKING之外的另一项配置CONFIG_DEBUG_ATOMIC_SLEEP它专门检测在原子上下文里睡眠的情况和lockdep配合起来能覆盖大部分同步错误。5.3 调试与排障的工具箱遇到同步问题先别急着改代码按这套顺序来排查能省很多时间确认dmesg里有没有BUG: scheduling while atomic、BUG: sleeping function called from invalid context、soft lockup等字样这些是线索最直接的报错开着lockdep重跑它会在死锁发生前就报出依赖环和调用栈用/proc/lock_stat看看锁的获取次数、等待时间、竞争分布找出热点锁配合ftrace追踪锁的获取函数和调用栈把等待链条理出来如果是KASAN或UAF问题让KASAN去报告具体的非法访问地址和行为。还有一个比较容易忽略的手段echo t /proc/sysrq-trigger会把所有任务的状态和栈打印到内核日志里。系统接近挂死时执行这个能抓到最后卡在哪把锁上配合LOCKDEP的打印基本能快速定位到是哪个文件、哪一行、哪把锁。5.4 一个排查实例从偶发卡顿到定位RCU使用错误实际调试中RCU使用错误引起的崩溃最有迷惑性。之前在一个文件系统过滤驱动里遇到偶发的空指针崩溃直接崩溃现场看指针明明在rcu_read_lock()保护范围内解引用的地址也没越界就是偶发NULL。最后发现问题是有个线程把从RCU临界区里拿到的指针存到全局变量里退出临界区之后另一个线程用它做解引用而那个对象恰好已经过了grace period被释放了。rcu_dereference拿到的指针是借来的用完即还不能长期保存——这个教训后来反复出现在我给同事做代码review的备注里。这类问题的排查心得RCU相关崩溃的特点是随机崩溃指针有效但内容垃圾而且通常在高负载、线程频繁创建销毁时更容易暴露。遇到这种情况先搜一下有没有指针被保存到长生命周期结构中。6. 写在最后的一句话水平总结内核同步管理这个主题单看每个原语都不复杂难的是把场景、上下文、性能意识、死锁风险这些维度在写代码时同时考虑进去。我自己做了这么多内核相关的工作最大的体会是同步方案不是越高级越好而是越匹配越好。如果你不确定该用哪种同步手段先把临界区画清楚把执行上下文列出来多数时候答案自己就浮出来了。最后分享一个小经验新写的内核代码建议裸奔跑一遍CONFIG_PROVE_LOCKINGCONFIG_DEBUG_ATOMIC_SLEEPCONFIG_KASAN的测试内核让它替你先把同步错误过一遍雷。这个组合虽然让系统慢上不少但它能在你上线前把最隐蔽的死锁和内存错误提前引爆。等到这一轮测试通过再把这三项关掉你会发现生产环境里同步相关的bug率直接降了一个量级——这是我在多个项目里实测下来最稳的开发习惯。