内核线程与互斥锁死锁实战:Linux内核模块调试技巧详解
死锁是操作系统和并发编程中最常见的同步问题之一。虽然教科书中经常介绍这个概念,但通过观察 Linux 内核内部的死锁,可以更深入地理解为什么正确的锁顺序至关重要——在内核态,任何锁的误用都可能导致整个系统挂起或崩溃,其后果远比用户态程序严重。
在这个项目中,我构建了一个Linux 内核可加载模块 (LKM) ,它故意使用两个内核线程 和两个互斥锁 来制造死锁。该模块还包含一个修正后的实现,它通过强制执行一致的锁获取顺序来消除死锁。整个模块设计为教学工具,不仅演示死锁的发生机制,还展示如何利用内核日志(dmesg)进行同步问题排查,以及如何正确管理内核线程生命周期。
术语解释:互斥锁(mutex) 是内核中一种睡眠锁,当线程无法获取锁时会主动让出CPU,进入睡眠状态,直到锁被释放。与自旋锁(spinlock)不同,互斥锁适用于可能长时间持有的临界区,但不可在中断上下文使用。
项目概述
该模块支持两种执行模式,通过模块参数 mode 进行切换:
| 模式 | 描述 |
|---|---|
mode=1 | 故意制造死锁 |
mode=2 | 演示了修正后的实现 |
两种模式使用相同的同步原语(两个全局互斥锁 lock1 和 lock2),但互斥锁的获取顺序不同。在死锁模式下,线程1先取 lock1 再取 lock2,线程2先取 lock2 再取 lock1;修正模式下,两个线程均按 lock1 → lock2 的顺序获取。
使用的技术
- Linux 内核模块 (LKM) 开发框架
- 内核线程(
kthread)创建与管理 - 互斥锁同步机制(
mutex_lock/mutex_unlock) - Linux 内核日志记录(
pr_info,pr_err,pr_debug) - 模块参数(
module_param)实现运行时行为控制 dmesg命令行工具实时监控内核环缓冲消息- 内核构建系统(Kbuild / Makefile)编译外部模块
理解死锁的本质
该演示创建了两个工作线程,每个线程模拟一项需要两次加锁的任务。
第一个线程获取锁的顺序为:
LockA ↓ LockB
第二个线程获取锁的顺序为:
LockB ↓ LockA
每个线程一旦获得第一个互斥锁,就会尝试去获取第二个互斥锁。如果两个线程同时运行,线程1持有 lock1 等待 lock2,线程2持有 lock2 等待 lock1,于是双方永远等待对方释放锁,形成典型的循环等待(circular wait) 条件,最终导致死锁。此时,两个线程都无法继续执行,模块也无法正常卸载,除非重启系统。
死锁四个必要条件(Coffman条件):
1. 互斥(Mutual exclusion)——资源独占
2. 持有并等待(Hold and wait)——已持有资源仍可申请新资源
3. 不可抢占(No preemption)——资源只能自愿释放
4. 循环等待(Circular wait)——进程间形成资源等待环
本演示恰好满足全部四个条件,因此死锁必然发生。
死锁演示:一步步观察
工作线程获取第一个互斥锁后,会故意等待一小段时间(使用 msleep 或 udelay)以增加两个线程交错执行的概率,然后尝试获取第二个互斥锁。
示例代码逻辑:
mutex_lock(&lockA); msleep(2000); mutex_lock(&lockB);
(线程1代码片段:锁定 lock1 → 延时 → 锁定 lock2 → 执行临界区 → 解锁)
第二个工作线程执行相反的顺序:
mutex_lock(&lockB); msleep(2000); mutex_lock(&lockA);
(线程2代码片段:锁定 lock2 → 延时 → 锁定 lock1 → 执行临界区 → 解锁)
同时运行两个线程会导致每个线程在尝试获取第二个锁时无限期阻塞,内核日志中会看到两个线程分别卡在 mutex_lock 调用处,但不会产生任何异常或崩溃——这正是死锁的隐蔽之处:系统不会主动报错,只是相关任务永远停滞。
调试提示:若怀疑死锁,可使用
cat /proc/[pid]/stack查看线程内核栈,或使用watch -n1 'cat /proc/lockdep_chains'(需开启CONFIG_LOCKDEP)分析锁依赖图。
避免死锁:一致的锁顺序
解决方法出乎意料地简单——两个线程获取互斥锁的顺序完全相同。
mutex_lock(&lockA); mutex_lock(&lockB);
(修正后代码:线程1和线程2均先 lock1 后 lock2)
由于每个线程都遵循相同的锁定顺序,循环等待被打破。即使两个线程同时运行,一个线程持有 lock1 等待 lock2,而另一个线程也会先尝试 lock1,此时 lock1 已被占用,第二个线程会阻塞在 lock1 上,而不会提前占用 lock2。因此,第一个线程最终能获取 lock2 并完成工作,释放锁后第二个线程继续执行。这种方式保证了系统永远畅通。
专家建议:在设计多锁同步时,应尽可能定义全局的锁层级(lock hierarchy),所有代码路径必须严格遵守相同的获取顺序。如果无法统一(例如跨子系统),可考虑使用
mutex_trylock配合回退策略,但会增加复杂度。
内核线程创建与管理
该模块使用 kthread_run() 宏来创建并运行内核线程。该宏封装了 kthread_create() 和 wake_up_process(),一次性完成创建和唤醒。
示例:
thread1 = kthread_run(worker1, NULL, "thread1");
(代码片段:worker1 = kthread_run(thread_func1, NULL, "worker1"))
每个工作线程在完成一次任务(即执行临界区代码)后便自行退出(调用 return 0 或 do_exit(0))。由于线程是一次性的,模块在卸载时不需要显式停止它们。
知识普及:内核线程与用户线程的区别:
- 内核线程运行在内核态,没有用户空间地址映射。
- 它们可通过ps -ef或top查看,名称以[kthreadd]为父进程。
- 使用kthread_should_stop()可检查是否收到停止信号,用于循环工作线程的退出控制。
模块参数控制行为
模块行为由模块参数 mode 控制,允许用户在加载时选择死锁模式或修正模式。
加载时设置参数:
sudo insmod deadlock_demo.ko mode=1
或者:
sudo insmod deadlock_demo.ko mode=2
这样无需修改源代码即可快速对比错误实现和正确实现,非常适合教学演示。模块参数通过 module_param(mode, int, 0644) 声明,并可在 /sys/module/deadlock/parameters/mode 下动态查看(但不能动态修改,需重新加载)。
构建模块
使用内核构建系统编译模块。通常编写一个简单的 Makefile:
make
如果启用了安全启动(Secure Boot),加载模块前需要对其进行签名,否则会被内核拒绝:
sudo /usr/src/linux-headers-$(uname -r)/scripts/sign-file \ sha256 \ ~/kernel_keys/MOK.key \ ~/kernel_keys/MOK.crt \ deadlock_demo.ko
注意事项:开发环境需安装对应内核版本的
linux-headers包,且内核配置需启用CONFIG_MODULES和CONFIG_KALLSYMS等选项。
运行演示
加载死锁版本(mode=0):
sudo insmod deadlock_demo.ko mode=1
(insmod deadlock.ko mode=0)
监控内核消息(另开终端):
dmesg -w
(dmesg -w 或 journalctl -k -f)
加载修正后的实现(mode=1):
sudo insmod deadlock_demo.ko mode=2
(insmod deadlock.ko mode=1)
卸载模块(无论哪种模式):
sudo rmmod deadlock_demo
(rmmod deadlock)
注意:在死锁模式下,由于工作线程永久阻塞,rmmod 可能会卡住(因为模块引用计数被线程持有),此时只能强制重启或使用 sysrq 紧急同步。
使用 dmesg 进行调试
Linux 内核通过 dmesg 提供详细的诊断信息。常用命令包括:
dmesg | tail -100
dmesg | grep deadlock_demo
(例如:dmesg | grep "deadlock",dmesg -T 显示时间戳,dmesg -c 清空日志等)
内核日志能够清晰展示线程执行顺序、锁获取/释放事件、模块初始化和退出流程。通过在关键点插入 pr_info("Thread %d acquired lock1\n", current->pid),我们可以精确还原每一时刻的锁状态,从而验证死锁是否发生。
同类对比:与用户态 GDB 调试不同,内核调试更多依赖日志和静态 tracepoint。对于同步问题,
lockdep工具(内核锁验证器)可以自动检测潜在的死锁风险,并输出详细报告,建议生产环境开启CONFIG_LOCKDEP以提前发现隐患。
实现中遇到的两个重要漏洞
在实现此演示的过程中,我遇到了两个问题,这些问题并非由死锁本身引起,而是内核线程编程中的常见陷阱。
1. 错误使用 kthread_stop()
修正后的实现中,工作线程在完成其工作后自然终止(返回0)。此时线程结构体仍存在于内核中,但已处于 TASK_DEAD 状态。尝试在模块移除期间调用 kthread_stop() 来停止这些已完成的线程,会导致内核发出警告并可能触发 oops 崩溃。
解决方法:对于一次性工作线程,无需调用 kthread_stop()。正确做法是在 kthread_run() 后保存任务结构体指针,但仅在确定线程仍在运行时才调用停止函数。若线程自行退出,则应当忽略该指针。
最佳实践:设计循环工作线程时,应在线程函数内部定期检查
kthread_should_stop(),并提供干净的退出路径。模块卸载时,先设置停止标志,再唤醒线程,最后调用kthread_stop()等待其结束。
2. 缺少 IS_ERR() 验证
最初,kthread_run() 的返回值未经验证就直接使用。实际上,kthread_run() 返回的是 struct task_struct *,但若创建失败(如内存不足),返回的是错误指针(如 ERR_PTR(-ENOMEM))。直接使用该指针会导致无效内存访问。
修正示例:
if (IS_ERR(thread1))
(代码:if (IS_ERR(worker)) { pr_err("Failed to create thread\n"); return PTR_ERR(worker); })
添加此验证使模块更加健壮,避免在低内存或系统繁忙时产生内核崩溃。
延伸知识:内核中使用
ERR_PTR()/PTR_ERR()/IS_ERR()范式来返回错误码,这是一种轻量级的错误处理方式,广泛用于资源分配函数。后续文章中我会详细分析这三个宏的实现原理。
对这两次调试过程的详细分析将在后续文章中阐述,包括调用栈回溯和内核内存分配机制。
涵盖的关键概念
- Linux 内核模块开发流程
- 内核线程(
kthread)的创建与终止 - 互斥锁(
mutex)的锁定与解锁 - 互斥(Mutual exclusion)与临界区保护
- 死锁(Deadlock)及其四个必要条件
- 死锁避免——锁顺序一致性
- 模块参数(
module_param)的声明与使用 - 内核日志(
pr_*系列)的分级输出 - 线程生命周期管理(运行中、退出、僵尸态)
- 内核调试基础(
dmesg,/proc,sysrq)
我学到了什么
通过研究这个项目,我获得了以下方面的实践经验:
- 编写并编译可加载 Linux 内核模块,理解
init和exit函数的作用 - 创建内核线程并传递自定义数据,使用
kthread_run和kthread_create - 使用互斥锁同步共享资源,避免数据竞争
- 理解死锁发生的动态过程,并通过日志逐帧追踪
- 通过调整锁顺序防止死锁,掌握“锁层级”设计原则
- 熟练使用
dmesg -w实时监控内核事件,定位阻塞点 - 调试线程退出时机,正确处理
kthread_stop与自动退出的冲突 - 验证内核 API 返回值,养成
IS_ERR检查习惯
结论
如果能在受控环境中重现死锁问题,就更容易理解死锁现象的本质。这个 Linux 内核模块演示了一种错误的同步策略及其修正后的实现方式,仅使用了两个内核线程和两个互斥锁便清晰展示了循环等待的致命后果。虽然该项目规模较小,但它阐明了操作系统设计和 Linux 内核开发中经常出现的几个重要概念:锁顺序、线程生命周期、错误处理及日志调试。
理解这一层面的同步原语,可以为探索更高级的主题奠定坚实的基础,例如信号量(semaphore)、读写锁(rwlock)、完成量(completion)、等待队列(wait queue)、工作队列(workqueue)、RCU(Read-Copy-Update)和无锁编程(lock-free programming)。掌握内核同步不仅对驱动开发者至关重要,对于理解整个操作系统的并发模型也大有裨益。
专家提醒:在内核编程中,任何锁的使用都需谨慎设计,并尽可能利用静态分析工具(如
sparse)和动态检查(如lockdep)提前发现问题。切勿在生产环境中直接加载未经充分测试的内核模块,尤其是涉及死锁演示的代码。
本文由主机测评网发布,不代表主机测评网立场,转载联系作者并注明出处:https://zhuji.jb51.net/linux/9579.html
