Linux内核死锁实战:从原理到调试与预防

Linux内核死锁实战:从原理到调试与预防 1. 项目概述从一次线上服务卡死说起那天凌晨监控告警突然炸了一个核心数据处理服务完全失去响应CPU使用率几乎为零但进程还在就是不做任何事。登录服务器一看ps命令能列出进程strace命令挂上去也看不到任何系统调用整个进程就像被冻住了一样。经验告诉我这大概率不是应用层死循环而是更深层的问题——内核态死锁。最终通过分析内核转储core dump和/proc/locks等信息我们定位到问题根源一个驱动模块在持有自旋锁spinlock的同时错误地调用了可能睡眠的函数kmalloc(GFP_KERNEL)而当时内存紧张触发了直接内存回收进程尝试去获取另一个已经被其他进程持有的锁形成了一个典型的AB-BA死锁。这次经历让我深刻意识到理解Linux内核中的死锁尤其是它在实际项目中的真实样貌对于构建稳定系统至关重要。这不仅仅是教科书上的概念而是随时可能引爆生产环境的“暗雷”。死锁简单说就是两个或多个执行单元进程、线程、中断处理程序等互相等待对方持有的资源导致所有相关方都无法继续推进的状态。在用户态编程中我们熟悉的是互斥锁、读写锁的顺序问题。但在内核态情况要复杂得多锁的种类更多自旋锁、信号量、RCU等、可抢占性不同、中断上下文会介入、还有内存分配这种隐含着可能触发调度的操作。很多在用户态不会导致死锁的代码模式放到内核驱动或者内核模块里就可能成为死锁的温床。这篇文章我就结合自己踩过的坑和解决过的案例拆解Linux内核实际项目中的死锁问题聊聊它怎么发生、如何调试、以及最重要的——怎么从设计和编码上避免。2. 内核死锁的独特土壤与核心诱因内核环境与用户态程序有着本质区别正是这些区别孕育了特定类型的死锁。2.1 内核并发模型的复杂性用户态程序的并发源主要是多线程。内核的并发源则要多得多多处理器SMP真正的并行执行多个CPU核心可能同时执行内核代码访问共享数据。内核抢占即使单CPU高优先级任务也可以抢占正在执行的内核路径除非显式禁用抢占。中断IRQ硬件中断随时可能发生中断处理程序ISR会打断当前正在执行的任何内核或用户代码。软中断Softirq和任务队列tasklet为了缩短中断禁用时间大部分中断处理工作会推迟到软中断或任务队列中执行它们也构成了异步并发。内核线程执行后台任务的内核线程如kswapd内存回收、kworker等。这意味着一段内核代码可能被同CPU上的另一个进程内核抢占、被其他CPU上的进程SMP、被中断处理程序、被软中断等多种方式并发访问。你在编写一个简单的open()函数时必须考虑所有这些可能性。2.2 内核锁机制的多样性及其陷阱内核提供了多种同步原语各有适用场景和死锁风险自旋锁spinlock_t行为忙等待不会让出CPU。适用于持有时间极短的临界区特别是在中断上下文或禁止抢占的上下文中。死锁风险递归获取同一个CPU上重复获取同一个自旋锁会导致死锁自身等待自身释放。顺序获取CPU1持有锁A申请锁BCPU2持有锁B申请锁A。这是经典的AB-BA死锁。中断上下文与进程上下文进程上下文持有锁A时如果被中断打断而中断处理程序也尝试获取锁A就会死锁因为中断处理程序会一直自旋等待而持有锁的进程上下文被中断抢占无法继续执行释放锁。这时必须用spin_lock_irqsave()在加锁时同时禁用本地中断。在持有自旋锁时调用可能睡眠的函数这是致命错误。自旋锁持有期间当前CPU的抢占是被隐式或显式禁止的。如果调用kmalloc(GFP_KERNEL)、copy_from_user()等可能触发直接或间接调度睡眠的函数一旦睡眠其他进程就无法运行可能永远无法释放当前进程等待的资源导致系统僵死。这正是我开头遇到的那个案例。信号量semaphore与互斥锁mutex行为当无法获取锁时任务会睡眠让出CPU直到锁被释放后被唤醒。死锁风险睡眠锁的顺序死锁和用户态类似不按固定顺序获取多个互斥锁会导致死锁。在中断上下文中使用中断上下文不能睡眠所以绝对不能在中断处理程序中使用互斥锁或信号量down()操作否则会导致内核崩溃oops。锁的生命周期管理忘记释放锁尤其是在错误处理路径上会导致资源永久被占用其他任务无限期睡眠这本质也是一种死锁。读写锁rwlock_t, rw_semaphore行为区分读和写允许多个读者并发但写者独占。死锁风险写者饿死如果读者源源不断写者可能永远无法获取锁。内核的读写锁实现通常偏向读者在某些场景下需要注意。锁升级一个持有读锁的任务试图再获取写锁升级。如果此时还有其他读者就会死锁因为写锁需要等待所有读者包括它自己释放读锁。内核通常不支持锁升级。RCURead-Copy-Update行为通过延迟释放来保证读者无锁访问写者负责更新和回收旧数据。死锁风险RCU本身设计避免了锁顺序死锁。但风险在于在RCU读侧临界区内睡眠或阻塞这是不允许的因为这会阻止RCU宽限期grace period结束导致内存无法被回收最终可能耗尽内存。rcu_read_lock()保护的区域应该尽可能快且不能调用可能调度睡眠的函数。注意一个非常隐蔽的坑是内存分配。GFP_KERNEL标志允许睡眠等待内存而GFP_ATOMIC标志则不允许。在中断上下文、持有自旋锁、或禁止抢占的上下文中必须使用GFP_ATOMIC。错误地使用GFP_KERNEL是引发死锁的常见原因。2.3 实际项目中死锁的典型发生场景结合上面的机制实际项目中死锁往往出现在这些混合场景驱动中的中断处理驱动probe函数进程上下文初始化时获取了设备锁A然后注册了中断处理函数。当中断到来时ISR中断上下文也需要操作设备尝试获取锁A。如果probe函数用的是普通自旋锁spin_lock而没有禁用中断就会死锁。必须使用spin_lock_irqsave()。文件系统与内存管理的交互进程P1在执行文件写操作持有文件系统的inode锁然后触发缺页异常需要分配内存。如果此时系统内存不足内存回收线程kswapd被唤醒它可能需要回写脏页到磁盘而回写又需要访问同一个文件系统的inode锁……这就形成了复杂的依赖链可能死锁。内核通过GFP_NOFS等内存分配标志来避免此类递归调用文件系统。多个子系统间的锁依赖例如网络子系统持有socket锁时调用了某个虚拟文件系统VFS操作而VFS操作又需要获取另一个全局锁。如果另一个路径如定时器回调以相反的顺序获取这些锁就可能死锁。这要求开发者对内核各子系统的锁依赖有全局了解而这非常困难。定时器回调中的锁操作定时器回调可能在软中断上下文中执行。如果它尝试获取一个在进程上下文中被普通自旋锁保护的资源而该进程上下文在持有锁时被同一CPU上的这个定时器软中断抢占就会死锁。需要使用spin_lock_bh()来禁用软中断。3. 死锁的探测、调试与取证实战当系统出现疑似死锁如进程D状态、无响应但进程存在时盲目重启会丢失现场。我们需要一套系统的调试方法。3.1 在线诊断利用内核内置工具现代Linux内核内置了死锁检测工具最重要的是lockdep锁依赖跟踪器。Lockdep的工作原理 Lockdep在内核运行时动态跟踪每一个锁的获取和释放顺序为每个锁建立一个“类”class。当代码尝试获取锁时lockdep会检查当前的锁获取顺序是否与之前记录的任何顺序矛盾即是否可能形成环形等待。如果检测到潜在的死锁可能它会立即在内核日志dmesg中打印出详细的警告包括调用栈、锁的依赖图明确指出哪两把锁可能以相反的顺序被获取。如何启用和使用 在编译内核时需要开启CONFIG_PROVE_LOCKING选项。对于发行版内核通常已经作为调试选项编译进模块。在运行时可以通过/sys/kernel/debug/lockdep下的文件动态控制lockdep的详细程度。实战案例 有一次我在开发一个字符设备驱动时insmod后dmesg里立刻出现了如下警告[ 12.345678] [ 12.345679] WARNING: possible circular locking dependency detected [ 12.345680] 5.4.0-rc6 #1 Not tainted [ 12.345681] ------------------------------------------------------ [ 12.345682] insmod/1234 is trying to acquire lock: [ 12.345683] (dev-mutex){..}, at: [ffffffffa0123456] my_device_open0x46/0x100 [my_driver] [ 12.345685] [ 12.345686] but task is already holding lock: [ 12.345687] (fs_lock){..}, at: [ffffffff811abcde] __fdget0x4e/0x60 [ 12.345689] [ 12.345690] which lock already depends on the new lock. ... [ 12.345700] Possible unsafe locking scenario: [ 12.345701] [ 12.345702] CPU0 CPU1 [ 12.345703] ---- ---- [ 12.345704] lock(fs_lock); [ 12.345705] lock(dev-mutex); [ 12.345706] lock(fs_lock); [ 12.345707] lock(dev-mutex); [ 12.345708] [ 12.345709] *** DEADLOCK ***Lockdep清晰地告诉我在my_device_open函数中在已经持有fs_lock的情况下又去获取dev-mutex。而在内核的其他地方存在一条路径先持有dev-mutex再获取fs_lock。这就构成了死锁风险。我通过调整my_device_open中的锁顺序或者重新审视全局的锁设计解决了这个问题。其他在线命令cat /proc/locks查看当前系统被持有的文件锁和内核锁部分。可以看哪些进程持有哪些锁。ps aux | grep “D”查找处于D状态不可中断睡眠的进程它们很可能在等待某个资源如锁。strace -p pid如果进程卡在用户态与内核态的边界如某个系统调用内部strace可能能捕获到它卡在哪个调用。但对于纯粹的内核态死锁如自旋锁死循环strace也会卡住。3.2 离线分析死锁发生后的现场保存与解剖当死锁导致系统完全无响应硬死锁时在线工具可能无法使用。我们需要获取系统快照。触发内核转储Kdump这是最强大的工具。配置好Kdump后当内核崩溃包括某些死锁导致的硬件看门狗超时时第一个内核被挂起的内存映像会被保存到转储文件vmcore中。然后第二个内核启动我们可以用crash工具分析这个vmcore。使用Magic SysRq键在系统响应极其缓慢但未完全死透时可以尝试通过Magic SysRq键获取信息。按下AltSysRqt可能需要先启用/proc/sys/kernel/sysrq可以打印所有CPU的调用栈到内核日志。这能告诉你每个CPU正在执行什么代码可能发现卡在自旋锁循环的CPU。手工保存信息如果系统还有部分响应可以尝试通过串口控制台或IPMI等带外管理工具执行echo l /proc/sysrq-trigger来打印栈回溯或者echo m来打印内存信息。使用Crash工具分析vmcore 假设我们已经获取了vmcore和对应的内核调试符号文件vmlinux。crash vmlinux vmcore在crash环境中bt查看当前任务的回溯。对每个CPU使用bt找到卡住的那个。ps查看进程状态找到D状态进程。log查看内核日志寻找lockdep警告或其它错误。struct -o task_struct pid查看特定进程的详细信息。对于自旋锁死锁可以查看spinlock结构体的owner等字段取决于内核版本和配置但通常更直接的是看调用栈——如果栈显示卡在spin_lock或_raw_spin_lock函数里并且长时间没有变化那很可能就是在自旋等待。一个真实的分析片段 在crash中对卡住的CPU执行bt可能看到PID: 1234 TASK: ffff888112345678 CPU: 1 COMMAND: my_app #0 [ffffc9000123bc78] __schedule at ffffffff81234567 #1 [ffffc9000123bce0] schedule at ffffffff81234678 #2 [ffffc9000123bcf0] schedule_timeout at ffffffff81234789 #3 [ffffc9000123bd80] wait_for_completion at ffffffff8123489a #4 [ffffc9000123bd90] my_driver_ioctl at ffffffffa0123456 [my_driver] #5 [ffffc9000123bde0] vfs_ioctl at ffffffff811abcde #6 [ffffc9000123be10] do_vfs_ioctl at ffffffff811abdef ...同时另一个CPU的栈显示它卡在#4 [ffffc9000223bd90] _raw_spin_lock at ffffffff812349ab #5 [ffffc9000223bda0] my_driver_release at ffffffffa01234cd [my_driver] ...结合代码分析我们发现my_driver_ioctl在等待一个完成量completion而这个完成量预计由my_driver_release来触发。但my_driver_release在触发完成量前需要获取一把自旋锁而这把锁正被my_driver_ioctl所在的执行路径在等待前持有。这就构成了一个通过完成量连接的隐藏死锁。4. 设计规避与编码防御将死锁扼杀在摇篮里调试死锁是痛苦的最好的方法是预防。以下是一些经过实践检验的准则。4.1 锁的设计原则锁的粒度锁保护的数据范围要恰到好处。太粗如一个全局大锁会严重降低并发性太细会引入大量锁增加复杂性和死锁风险。一个好的原则是一个锁只保护一个逻辑上紧密相关的数据结构集合。锁的层次Lock Hierarchy为系统中所有的锁定义一个全局的、严格的获取顺序。例如规定必须先获取锁A才能获取锁B。并在整个子系统或模块中严格遵守这个顺序。内核中一些复杂的子系统如VFS内部就有明确的锁顺序规则。在代码审查时锁顺序是重点检查项。避免嵌套锁尽可能减少同时持有锁的数量。如果必须持有多个锁确保所有代码路径都以相同的全局顺序获取它们。使用lockdep可以帮助验证顺序。使用更安全的同步机制RCU对于读多写少的数据优先考虑RCU。它几乎为读者消除了锁。顺序锁seqlock适用于写很少但写必须很快的场景读者可能需要重试。读写信号量当确实需要读写锁且读者可能持有锁较长时间时使用。4.2 针对具体锁类型的编码纪律对于自旋锁持有时间极短理想情况是几十到几百条指令。如果临界区很长考虑换用互斥锁。中断安全问自己持有这个锁的代码会被中断处理程序访问吗如果会必须使用spin_lock_irqsave()。如果不确定用这个更安全的版本。绝对禁止睡眠在spin_lock和spin_unlock之间像对待瘟疫一样避免任何可能引起调度或阻塞的函数。这包括直接调用schedule(),msleep(),wait_event()。调用kmalloc(GFP_KERNEL),get_user_pages()等可能触发直接内存回收的函数。调用copy_from_user(),copy_to_user()因为它们可能因缺页而睡眠。调用其他内核函数你必须清楚这些函数内部是否会睡眠。如果不确定查源码或文档。使用本地锁变体如果数据仅被特定CPU访问如每CPU数据使用local_lock相关的机制可以避免SMP开销和死锁。对于互斥锁和信号量明确错误处理在所有错误退出路径上都必须释放已获取的锁。使用goto语句跳转到统一的清理标签是一个清晰的做法。static int my_function(void) { int ret 0; mutex_lock(dev-lock); ret do_something_A(); if (ret) goto out_unlock; // 错误时跳转到释放锁 ret do_something_B(); if (ret) goto out_unlock; // ... 正常流程 out_unlock: mutex_unlock(dev-lock); return ret; }避免在持有锁时调用用户空间copy_to_user/copy_from_user可能睡眠虽然持有互斥锁时允许睡眠但这会长时间阻塞其他等待该锁的进程降低性能并可能引发用户感知的卡顿。应尽量缩短锁的持有时间。4.3 利用内核基础设施进行验证在开发环境中始终开启Lockdep即使会带来一些性能开销在开发和测试阶段也必须开启CONFIG_PROVE_LOCKING。它能捕捉到绝大多数锁顺序相关的死锁风险。让Lockdep成为你的第一道防线。使用静态分析工具如sparse可以检测一些明显的锁使用问题比如在中断上下文中使用可能睡眠的函数。压力测试与并发测试编写或利用现有的内核测试模块如lib/test_lockup.c在高并发、高负载下反复运行你的代码。使用stress-ng等工具制造内存压力、IO压力可以暴露那些在低负载下隐藏很深的死锁条件比如因内存回收触发的死锁。5. 疑难案例剖析与排查心法5.1 案例一“睡眠函数”引发的血案——内存分配中的GFP标志这是最经典也最容易犯的错误。一个设备驱动的中断处理程序ISR中需要分配一小块内存来暂存数据。开发者写了buf kmalloc(size, GFP_KERNEL);。在大多数测试中系统内存充足分配立即成功没有问题。一旦系统运行长时间内存碎片化或压力增大GFP_KERNEL分配可能触发直接内存回收__alloc_pages_slowpath这个路径会尝试回收内存可能涉及文件系统回写、网络清理等而这些操作可能需要获取各种各样的锁。如果其中某个锁正好被当前CPU上被中断打断的那个进程上下文持有死锁就发生了。排查与解决排查系统完全死锁watchdog超时触发panic。分析vmcore发现ISR的调用栈卡在__alloc_pages_slowpath里而另一个进程的栈显示它持有一把锁比如inode锁并且也在等待内存回收完成。解决在中断上下文、软中断、持有自旋锁或禁止抢占的上下文中内存分配必须使用GFP_ATOMIC标志。GFP_ATOMIC表示不允许睡眠如果无法立即分配到内存它会失败返回NULL。代码必须处理这种分配失败的情况。正确的写法是buf kmalloc(size, GFP_ATOMIC); if (!buf) { /* 处理错误如丢弃本次中断数据 */ }5.2 案例二定时器回调中的锁顺序陷阱一个网络驱动模块在发送完成回调中可能在软中断上下文需要更新一些统计信息这些信息被一个自旋锁stats_lock保护。同时模块还有一个定时器定期打印这些统计信息打印前也需要获取stats_lock。如果定时器回调也在同一个CPU的软中断上下文中执行就可能发生死锁发送完成回调持有stats_lock然后被定时器软中断抢占定时器回调又尝试获取stats_lock导致自旋等待。排查与解决排查系统没有完全死锁但该网络设备的吞吐量急剧下降top看到对应CPU的软中断si占用率很高。使用watch -n 1 ‘cat /proc/softirqs’观察发现TIMER或NET_TX软中断计数在某CPU上停滞增长。抓取该CPU的栈回溯通过SysRq发现卡在_raw_spin_lock。解决在定时器回调中使用spin_lock_bh()来获取锁。spin_lock_bh()在加锁的同时会禁用软中断这样就防止了同一个CPU上软中断的嵌套从而避免了上述死锁。需要评估禁用软中断对性能的影响但通常对于短暂的统计更新是可以接受的。5.3 案例三隐藏的递归锁依赖——通过条件变量或完成量死锁不一定直接通过两把锁形成可能通过中间的状态同步机制间接形成。如前面调试章节提到的例子进程A持有锁L然后等待完成量C。进程B需要触发完成量C但在触发前需要先获取锁L。这就形成了A等CC等BB等LL被A持有的环形等待。排查与解决排查这类死锁比较隐蔽因为锁和完成量或等待队列在代码中可能距离较远。lockdep可能无法直接检测出这种通过同步对象连接的依赖。需要仔细分析代码的数据流和控制流。在调试时查看所有D状态进程的调用栈寻找它们正在等待什么wait_for_completion,wait_event等然后回溯是谁应该唤醒它唤醒路径上又需要什么锁。解决重新设计同步流程。避免在持有锁的情况下等待一个需要其他进程获取同一把锁才能触发的条件。通常的解决方案是先触发条件或确保条件已满足再获取锁。或者使用无锁的同步机制或者重新设计数据流消除这种循环依赖。5.4 通用排查心法当面对一个疑似死锁的线上问题时我通常会遵循以下步骤稳住别慌别急着重启重启会丢失一切现场。先尝试通过带外管理IPMI、iDRAC、ILO或串口控制台连接系统。收集基本信息尝试Magic SysRqt获取所有CPU的栈回溯。尝试cat /proc/locks查看锁持有情况。尝试ps aux查看D状态进程。如果还有shell响应用perf record -g -a sleep 10抓取一下系统层面的性能剖面但死锁时可能不灵。分析栈回溯这是最关键的一步。找出所有看起来卡住长时间停留在同一位置的线程/进程。看它们卡在哪个函数里。如果是_raw_spin_lock、mutex_lock、down_interruptible等基本锁定是锁问题。对比多个卡住线程的栈寻找交叉点——即线程A持有锁L1等待L2线程B持有锁L2等待L1。关联代码根据栈回溯中的函数名和模块名找到对应的内核代码或驱动代码。仔细阅读相关代码段特别是锁获取和释放的逻辑以及其中调用的函数尤其是那些可能睡眠或触发调度的函数。复现与验证如果可能在测试环境尝试复现。尝试增加系统负载、内存压力、并发操作以触发死锁条件。开启lockdep、slub_debug等所有调试选项。修复与测试根据分析结果修改代码。修复后不仅要在原场景测试还要进行压力测试和并发测试确保没有引入新问题并且真正解决了死锁。内核死锁的调试就像侦探破案需要耐心、细致的观察和对系统整体运行的深刻理解。每一次解决死锁问题都是对内核并发机制理解的一次深化。最重要的经验是敬畏并发锁的设计要简单清晰并充分利用lockdep等工具进行预防性检测。在编写内核代码时多问自己一句“如果此时被中断打断会怎样”“如果另一个CPU同时执行这段代码会怎样”“这个内存分配会睡眠吗”。这种思维习惯是写出健壮内核代码的关键。