Linux内核同步机制详解:从原子操作到RCU的并发基石
1. 为什么说同步机制是Linux内核的“地基”1.1 从一次并发事故说起同步机制到底解决什么问题先讲一个我早年做嵌入式驱动时的真实案例。当时在双核ARM平台上写一个中断处理与内核线程共享的计数器逻辑非常简单中断里对全局变量做加一操作内核线程读取并清空。我在代码里写得很“自然”甚至觉得这玩意儿还需要什么锁结果设备跑起来后隔三差五出现计数器漏计、偶发读到半新半旧的值整机日志乱成一团。查了整整一个下午才意识到这就是经典的“竞态条件”问题——两个执行流同时访问同一个共享数据而访问序列被打乱导致结果不可预知。从那以后我就明白Linux内核同步机制不是学院派的概念而是直接在跟硬件、中断、抢占和SMP较劲的生存技能。同步机制到底干了什么事用大白话说就是在多核处理器上多个CPU可能同时执行内核代码中断随时抢CPU内核线程也可能被调度器换下去如果没有同步手段两个执行流会在同一时刻读写同一个变量产生数据竞争。Linux内核为了解决这个问题提供了一套从原子操作、自旋锁、信号量、互斥锁到RCU的完整“军火库”。这篇文章我会把自己看源码、写驱动和排查问题时的经验全部摊开适合刚接触内核源码的读者也适合已经在写驱动但被死锁折磨过的人。1.2 内核态与用户态的差异为什么内核同步格外难在用户态写过多线程程序的人通常习惯用pthread_mutex解决问题线程竞争锁失败就睡眠等待内核调度唤醒。这个模型在内核态并不完全适用。内核态是系统所有进程共享的“特权空间”执行流不仅包括进程上下文还包括中断上下文、软中断、底半部、甚至CPU热插拔时的idle线程。中断上下文没有进程概念不能调用会睡眠的函数如果此时拿一把“睡眠型”锁整个系统可能直接卡死。更麻烦的是内核态还面临“抢占”这个变量。Linux从2.6起支持内核抢占也就是说一个低优先级进程在内核态运行期间调度器可以把它踢出去换成高优先级进程运行。这相当于在你的临界区中间插了一脚如果临界区没有防护另一个执行流同样能进来碰共享数据。再加上SMP多核的并行执行问题从“时序交错”变成了“真正同时”。所以内核同步首先要回答三个问题访问是否原子数据是否可见执行顺序是否保证原子性问题靠原子操作可见性和有序性问题靠内存屏障互斥问题靠各种锁。2. 五大核心同步原语拆解2.1 原子操作最轻量的“锁”原子操作是整个同步体系的“砖头”。它的本质是一条硬件指令完成读-改-写期间不会被中断也不会被其他CPU的核心打断。x86上有带lock前缀的指令ARM上有LDREX/STREX指令对。内核把硬件能力封装成API常见如atomic_inc、atomic_add、atomic_cmpxchg。使用原子操作不需要关中断也不需要拿锁开销极小适合做引用计数、简单计数器这类场景。我在驱动里最常用的就是atomic_t做资源释放标记。需要注意atomic_t是个32位有符号数赋值时用atomic_set读取时用atomic_read千万别直接 0或者。内核里还有一个refcount_t专门用于引用计数带溢出保护和use-after-free检测新的代码推荐用它替代atomic_t做object生命周期管理。原子操作虽然轻但它只能解决“单变量”的读改写问题如果临界区需要操作多个变量保证一致性原子操作就不够用了得上锁。2.2 自旋锁忙等待的代价与适用场景自旋锁spinlock是内核里最简单的互斥手段。线程拿不到锁不会睡眠而是在原地“自旋”不断循环检测锁状态直到持有者释放。它的优势是上下文不切换适合临界区非常短的场景代价是CPU空转浪费算力而且绝对不能睡眠。为什么不能睡眠因为自旋锁会关闭内核抢占preempt_disable同时禁止睡眠。如果你在持有自旋锁的临界区里调用kmalloc(GFP_KERNEL)、copy_to_user这类可能睡眠的函数当前CPU拿着锁睡下去了。其他CPU想拿这把锁只能死等可拿锁的CPU又需要那些CPU配合才能醒来这就是典型的死锁。内核里为了应付这类场景又搞出了raw_spinlock和spinlock的区别在RT内核上spinlock会被转换成可睡眠的互斥锁而raw_spinlock保持纯自旋留给真正不允许调度的核心路径。使用自旋锁时有两个地方容易踩坑第一临界区代码要尽量短我在实际项目里坚持“十行以内”原则超过十行就重新审视锁粒度第二注意中断上下文和进程上下文对同一把锁的竞争必须用spin_lock_irqsave保存中断状态否则中断进来后发现锁被持有整个系统直接死锁。spin_lock_irqsave比spin_lock多了保存和恢复中断状态的步骤虽然慢一点但在不知道中断是否开启的路径上它是最稳的选择。2.3 信号量与互斥锁让线程睡一会儿与自旋锁的“硬扛”相反信号量和互斥锁都是睡眠型同步原语。拿到不到锁时当前进程把自己放进等待队列调用调度器让出CPU等锁释放后由唤醒机制把进程重新拉起来。这样CPU不会空转适合临界区较长或竞争激烈的场景。信号量在内核里用struct semaphore表示支持任意数量通过down和up加减计数。但实际内核代码中裸信号量很少用绝大多数场景用互斥锁struct mutex。mutex比信号量多了几个关键优势它记录owner支持调试它有优先级继承机制防止优先级反转它不允许递归加锁强制你思考锁的使用方式。使用mutex的临界区可以调用较重的函数但同样不能用在中断上下文里因为睡眠型锁在中断上下文会直接触发BUG: scheduling while atomic。一个容易被忽略的细节mutex_lock返回后要立刻检查返回值吗mutex_lock不会失败调用后当前任务要么拿到锁要么一路睡到拿到为止。如果想避免睡眠等待用mutex_trylock。我写驱动时遇到“挂了但不能等”的场景会优先考虑trylock加失败重试而不是裸lock这样可以避免把高优先级任务阻塞在锁上。2.4 读写锁读多写少的优化有些数据结构的特性是“读多写少”比如路由表、文件系统超级块的部分字段。如果每次读到这些数据都要拿一把互斥锁大量读操作会互相阻塞白白损失并发度。读写锁rwlock解决的就是这个问题可以多个读者同时持有读锁但写者必须独占。rwlock的API分read_lock和write_lock两条路径。读写锁看起来很美好实际上在SMP环境里性能并不总是占优。原因在于读锁获取时也要做原子操作和内存屏障多核之间还会因为缓存一致性协议导致“锁抖动”。更要命的是写者优先还是读者优先的策略因架构而异如果读者一直来写者可能被饿死。在我的实战经验里如果“读多写少”极端明显而且数据更新频率极低读写锁往往不如RCU。RCU把读侧开销压到几乎为零真正做到了“读操作无锁”这是下一节要重点说的。2.5 RCU读多写少场景的终极方案RCURead-Copy-Update是Linux内核同步机制里最精巧的设计之一也是许多内核子系统如路由表、文件系统dcache的基石。它的核心思路非常反直觉读操作完全不拿锁只依赖内存屏障保证读到“新”或“旧”的完整数据写操作不直接修改原数据而是先复制一份修改副本然后把指针原子地切换到新副本旧副本不能立刻释放要等到所有在读的CPU都经过一个“宽限期grace period”之后才能回收。读侧代码长这样rcu_read_lock(); ptr rcu_dereference(g_ptr); if (ptr) { // 使用ptr此刻不允许睡眠 } rcu_read_unlock();rcu_read_lock在非抢占内核上基本上只是关闭抢占开销极低。写侧需要rcu_assign_pointer切换到新指针然后用synchronize_rcu()等待宽限期结束或者用call_rcu注册回调异步等待。初次看RCU的人通常会被“宽限期”这个概念绕晕。我自己的理解方式是一个国家在更换领导人时并不会把全国所有正在开会的人全部打断而是发布公告等到所有人都至少散会过一次确认没人还在使用旧政策再执行旧政策清理。这个“所有人散会过一次”就是宽限期。RCU的适用场景非常明确读侧极其频繁写侧极其稀少且读侧临界区不能睡眠。如果你的数据更新频率也比较高或者读侧需要长时间持有RCU不见得比读写锁好甚至可能因为延迟释放内存导致内存压力。3. 从锁到唤醒eventfd与等待队列的联动机制3.1 等待队列让进程“挂起”的底层结构说到睡眠型锁就绕不开等待队列。struct wait_queue_head是所有睡眠等待机制的“床位”它本质上是链表加自旋锁链表里挂着所有睡眠在此的进程自旋锁保护链表的插入和删除。wait_event_interruptible这类宏做的事情是这样的先把自己加到等待队列然后检查条件不满足就调用schedule()让出CPU被唤醒后重新检查条件满足则退出等待队列。很多人看wait_event源码时会疑惑为什么要用“循环检查条件”而不是“一唤醒就直接跑”道理很简单Linux是抢占式多任务系统可能出现“虚假唤醒”——多个进程同时被唤醒但只有一个能拿到资源。条件检查循环保证了只有条件真正满足时才继续往下走这是同步设计里经典的“计划条件变量”模式。在网卡驱动里收包线程经常挂在NAPI的等待队列上在字符设备驱动里用户态阻塞读依赖wait_event_interruptible。我见过不少新手在自定义驱动中直接写死循环等待资源把CPU烧到100%正确答案永远是等待队列加条件检查而不是忙等。3.2 eventfd唤醒机制与epoll协同eventfd是一个轻量级的事件通知机制本质是一个内核维护的64位计数器用户态或内核态都可以向它写入数值触发等待者的唤醒。它最大的价值在于和epoll配合把“内核事件”无缝桥接到用户态的IO多路复用上。比如一个异步驱动希望通知用户态“数据准备好了”可以通过eventfd的写操作唤醒正在epoll_wait的进程用户态代码无需自己轮询。在内核侧eventfd_signal和eventfd_ctx是核心接口。当驱动完成一次异步IO后调用eventfd_signal递增计数等待在eventfd文件上的进程会被唤醒。这个唤醒路径需要和整个等待队列细粒度同步机制配合本身不持重锁因此可以在中断上下文或软中断里调用这是它被广泛应用于io_uring、vhost等子系统的原因。我在调试一个网络加速模块时曾经遇到“进程已唤醒但读不到数据”的诡异现象最后排查发现是eventfd_signal调用之后数据还没来得及写进共享内存用户态就抢跑了。这说明一个极其重要的经验唤醒只是给了你运行的机会不等于条件已经完备。任何基于eventfd的用户态逻辑依然要等待真正的数据条件就绪。3.3 内存屏障为什么CPU重排会“坑”你同步机制里最容易被忽视、也最让人头疼的是内存屏障。CPU为了提升性能会乱序执行指令编译器也会做指令重排。在单核时代这很少出问题多核时代CPU A写入一个变量CPU B不一定能立即看到最新值哪怕没有锁竞争。这就是“可见性”问题。如果把同步比作一次多人协作的交接班锁只是限制“谁能进房间”内存屏障则是确保“你前脚写的记录后脚接班的人一定能看到”。否则没有屏障保护时即使你把锁释放了另一个CPU仍可能读到旧数据。常见的内存屏障API包括smp_mb()、smp_rmb()、smp_wmb()以及配合具体数据结构的READ_ONCE和WRITE_ONCE。我推荐的实操习惯是不要试图“聪明地”省掉屏障直接跟随内核文档的规则。比如RCU的rcu_dereference和rcu_assign_pointer已经内置了必要的屏障你只需要用它们而不是自己拼裸指针加屏障。判断是否需要屏障有一条很粗的经验法则检查你的共享变量是否被多个CPU的异步执行流中断、软中断、多核线程访问以及访问之间是否存在“先写后读”或“先读后写”的先后依赖。如果存在而且你没用锁那几乎一定需要明确的屏障或原子操作否则结果在编译器和CPU的双重“重排魔法”下完全不可预测。4. 实战踩坑spinlock睡眠死锁与调试实录4.1 一个经典的arm64 spinlock死锁案例把理论讲完之后我想复盘一次真实死锁这是我在arm64平台上排查过的经典案例。模块的功能很简单一个内核线程定期读取硬件寄存器把结果写入一块共享内存另一个字符设备接口让用户态通过read读取这块内存。线程那边用了自旋锁保护共享内存用户态读取路径上也做了同样的加锁。问题出在用户态读取路径上read函数在持有自旋锁时调用了copy_to_user而copy_to_user在缺页时可能睡眠等待物理页分配。于是在某些内存压力大的场景下拿着自旋锁的进程睡过去了内核线程在另一个CPU上转圈拿锁整个系统“半死”状态top能看到一个CPU %100转圈其他任务全部卡死。这个案例的教训非常典型很多人以为“自旋锁里不能调用copy_to_user”是背概念实际上这个错误在内核开发中并不罕见。正确的做法应该是先把数据拷贝到临时缓冲区释放自旋锁再调用copy_to_user或者像很多驱动那样把硬件数据更新写到原子变量里让用户态读取路径走锁外快照。理想情况下自旋锁临界区里只做“修改指针、更新状态、操作寄存器寄存器”之类的微秒级操作。4.2 死锁排查手段从ftrace到lockdep死锁一旦发生现场往往非常难看。但Linux内核提供了几个非常趁手的工具。lockdep是内核自带的锁依赖校验器它能在锁获取和释放时记录依赖关系建立“锁的顺序图”一旦发现循环等待会直接在日志里打印死锁警告。很多发行版内核默认开了部分lockdep但如果你在交叉编译定制内核一定要确保CONFIG_PROVE_LOCKINGy。有一次我们抓一个内核崩溃的现场日志中直接出现了possible circular locking dependency detected顺着lockdep给出的调用链几分钟就锁定了两个模块加锁顺序相反的问题省了整天时间。ftrace的function_graph和irqsoff追踪器可以反映临界区耗时和关中断时长。当怀疑“某把锁持有时间过长”时我通常用trace_printk在临界区入口和出口打点配合trace-cmd record抓取完整上下文。注意在生产环境上不要开ftrace和lockdep同时全开性能开销极大。如果问题已经导致系统完全hang住可以提前配置/proc/sys/kernel/hung_task_timeout_secs和panic_on_oops让内核在检测到长时间阻塞任务后自动输出堆栈并重启。我在远程设备上做测试时还会用nmi_watchdog配合硬件看门狗确保即使NMI都无法正常处理时也能留下蛛丝马迹。4.3 常见问题速查表症状可能原因排查方向解决方案系统卡死某CPU占用100%自旋锁死锁或临界区死循环top看CPU/proc/interrupts看中断状态检查锁内是否有睡眠函数缩小临界区dmesg报“scheduling while atomic”原子上下文调用睡眠函数看堆栈定位睡眠函数名改用GFP_ATOMIC分配内存调整锁类型随机panic数据错乱缺锁或内存屏障缺失打开KASANlockdep给共享数据加锁、加READ_ONCE/WRITE_ONCE读锁把写者饿死读者过密导致写者无法进入观察写者延迟改RCU或加优先级策略唤醒后读不到新数据内存可见性顺序问题检查是否缺屏障使用smp_wmb/rcu_assign_pointer中断路径拿锁死锁持有锁时遇中断且中断再拿同一把锁看中断栈中断路径使用spin_lock_irqsave以上表格里的每一行我都在实际调试中碰到过至少一次。尤其是“随机panic数据错乱”那种在低负载下怎么跑都没事、一上高并发就崩的问题大概率就是缺锁或缺屏障而不是硬件随机故障。5. 同步机制在内核工程中的选型与优化5.1 锁粒度与性能权衡从“大锁”到“细锁”同步机制选型本质上是在做“性能”和“复杂度”的博弈。最粗暴的方案是全局大锁——整个驱动只有一把锁所有路径进来先加锁简单、不易错但高并发下性能极其难看。随着压力测试跑起来你会发现CPU时间全部耗在锁竞争和缓存一致性协议上了负载越高吞吐量反而下降。这时候就要拆锁粒度。拆锁的思路可以自顶向下先分辨哪些共享数据是“读多写少”哪些是“写多读少”然后把锁的范围从“整个数据结构”缩小到“单个字段”。比如哈希表可以对每个桶各维护一把锁而不是整个表加一把锁再比如引用计数直接换成refcount_t原子操作根本不需要锁。锁粒度细化之后代码复杂度会上升但并发度显著提高。我常用的衡量指标是perf lock和bpftrace可以统计锁竞争次数、持有时间和等待时间。如果观察到某把锁的等待时间占总运行时间的比例超过5%就需要重新审视它的设计。另一个经验是不要把“细锁化”贯彻到底粒度太细会让代码像羽毛一样散而且很容易引入AB-BA死锁。取舍标准是“真实瓶颈在哪里”而不是“看起来更并发”。5.2 在内核驱动开发中的实操建议驱动开发是最能体会同步机制“含金量”的场所因为驱动直接面向硬件中断和底半部机制无处不在。我写驱动的几条铁律第一中断上下文只使用原子操作、自旋锁和IO内存访问永远不使用互斥锁、信号量或任何可能睡眠的接口。如果中断里必须执行重操作把工作推迟到workqueue或tasklet中。第二凡是“在中断里读、在进程里写”的共享变量统一用READ_ONCE和WRITE_ONCE保护并在写后加wmb()在读后加rmb()。这不是洁癖而是arm64和x86的内存模型差异太大靠人脑记忆太危险。第三使用container_of访问对象时要特别注意对象可能被并发释放。保护对象生命周期使用kref、get/put机制配合锁来保证“拿到引用后对象不消失”。我在一个卸载竞态bug上吃过亏模块卸载路径释放设备对象的同时另一个CPU的中断还在访问该对象导致use-after-free和oops。解决思路是在中断路径里先kref_get确认成功后再使用对象释放路径置标志避免新的get。5.3 面试与源码阅读视角的同步机制考点写这篇文章的由头之一是我常帮团队做Linux内核方向的面试评估。同步机制是面试题的重灾区面试官几乎必问底层概念自旋锁能不能睡眠互斥锁和信号量的区别是什么RCU的宽限期原理是什么这些问题的答案都是“好背的”但真正拉开差距的是你有没有从源码层面理解过它们。我的建议是想深度理解同步机制可以直接读这几处源码include/linux/spinlock.h、kernel/locking/mutex.c、kernel/rcu/tree.c。读完这些你对“为什么自旋锁要关闭抢占”“为什么互斥锁要有乐观自旋”会有远超背八股的理解。比如看到mutex的乐观自旋逻辑后你才会明白一个看似睡眠型锁在竞争不激烈时根本不会立即睡眠而是在原地忙等一会儿因为“权衡下来睡眠唤醒的开销比自旋更高”。面试中还经常问“内核态和用户态同步方式的区别”回答要点是用户态锁依赖内核调度器实现唤醒内核态同步直接操控CPU状态、抢占开关和内存屏障代价更小但更危险。如果能结合一个实际的驱动死锁案例讲面试官基本会觉得你是有真经验的人。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →