
在 Linux 内核高并发运行场景中,各类互斥锁、自旋锁、信号量等同步机制被广泛使用,用以保障共享资源访问的原子性与数据一致性。但多进程、多线程频繁抢占资源的过程里,循环等待、资源互斥、不可剥夺、请求保持四大条件一旦同时满足,死锁便会悄然发生。死锁会造成进程僵死、服务挂起、系统负载异常等严重线上问题,常规日志与排查手段难以快速定位锁竞争根源,而内核原生的死锁检测机制,正是解决这类疑难问题的核心关键。
内核同步是 Linux 底层开发、驱动开发与服务稳定性维护的核心知识点,只掌握锁的基础使用远远不够,必须理解死锁的产生逻辑、检测原理与规避方案。Linux 内核内置完善的死锁检测框架,能够自动监控锁的申请与释放流程,及时捕获异常锁依赖关系。本文结合内核底层原理,系统拆解死锁检测的工作机制、配置开启方式与实战排查方法,帮助开发者从根源认识锁问题,筑牢内核同步技术体系。
一、认识 Linux 死锁
面试题写作模版在深入探究死锁检测之前,我们得先搞清楚死锁到底是什么。在 Linux 系统里,死锁指的是两个或多个进程在执行过程中,因为争夺系统资源,比如内存、文件句柄、设备等 ,或是由于进程间通信的问题,陷入一种互相等待的阻塞状态。如果没有外部干预,这些进程将永远无法继续推进。
打个比方,就像在图书馆里,有两个人 A 和 B,A 手里拿着 B 想要查阅的《Linux 内核深度解析》,B 手里则握着 A 急需的《高级 C 语言编程》。A 必须拿到 B 手中的书才能完成当前的研究任务,B 也只有拿到 A 的书才能继续工作,可两人都不愿意先把自己手中的书给对方,于是就这么僵持着,谁都无法继续。这就是典型的死锁场景,在 Linux 系统中,进程就如同这两个人,资源就是他们手中的书,一旦陷入这种互相等待资源的困境,系统就会出现问题。
死锁的产生并非偶然,它需要同时满足四个必要条件 :
(1)互斥条件:系统中的某些资源在同一时刻只能被一个进程使用,其他进程若想使用该资源,必须等待资源被释放。例如,打印机在打印一份文档时,其他进程无法同时使用这台打印机进行打印,这就是资源的互斥性。从代码角度来看,在 C 语言中使用 pthread_mutex_t 互斥锁时,当一个线程成功加锁(pthread_mutex_lock)后,其他线程在该锁被解锁(pthread_mutex_unlock)前无法获取同一把锁。
#include <stdio.h>#include <pthread.h>pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;void* thread_function(void* arg) {// 尝试获取互斥锁 pthread_mutex_lock(&mutex);// 临界区代码,同一时刻只有一个线程能进入 printf("线程进入临界区\n");// 模拟一些操作 sleep(1);// 释放互斥锁 pthread_mutex_unlock(&mutex);return NULL;}intmain(){ pthread_t thread;// 创建一个新线程 pthread_create(&thread, NULL, thread_function, NULL);// 主线程也尝试获取互斥锁 pthread_mutex_lock(&mutex); printf("主线程进入临界区\n"); pthread_mutex_unlock(&mutex);// 等待子线程结束 pthread_join(thread, NULL);return0;}在这段代码中,互斥锁 mutex 保证了临界区代码同一时间只有一个线程可以访问,体现了互斥条件。
(2)请求与保持条件:进程已经持有了至少一个资源,但在申请其他资源时,并不释放已持有的资源。比如,一个进程已经打开了一个文件(持有文件资源),在尝试打开另一个文件时(请求新资源),不会先关闭已打开的文件。假设我们有两个互斥锁 mutex1 和 mutex2,以下代码片段展示了请求与保持条件:
#include <stdio.h>#include <pthread.h>#include <unistd.h>pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER;pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER;void* thread_function(void* arg) {// 持有 mutex1 pthread_mutex_lock(&mutex1); printf("线程持有 mutex1\n"); sleep(1);// 请求 mutex2,同时不释放 mutex1 pthread_mutex_lock(&mutex2); printf("线程获取到 mutex2\n"); pthread_mutex_unlock(&mutex2); pthread_mutex_unlock(&mutex1);return NULL;}intmain(){ pthread_t thread; pthread_create(&thread, NULL, thread_function, NULL); pthread_join(thread, NULL);return0;}线程先获取了 mutex1,在请求 mutex2 时没有释放 mutex1,符合请求与保持条件。
(3)不剥夺条件:进程已获得的资源,在未使用完之前,不能被其他进程强行剥夺,只能由持有资源的进程主动释放。比如一个进程申请到了内存空间,其他进程不能直接把这块内存抢走,只能等该进程释放内存。在多线程编程中,锁一旦被某个线程获取,其他线程不能强制剥夺该线程对锁的持有权,这是保证数据一致性和线程安全的基础。
(4)循环等待条件:多个进程之间形成一种头尾相接的循环等待资源关系。假设有三个进程 P1、P2、P3,P1 等待 P2 持有的资源,P2 等待 P3 持有的资源,P3 又等待 P1 持有的资源,这样就形成了循环等待,死锁就会发生。从代码层面,假设有三个互斥锁 mutexA、mutexB、mutexC,三个线程分别持有并请求不同的锁,形成循环等待:
#include <stdio.h>#include <pthread.h>#include <unistd.h>pthread_mutex_t mutexA = PTHREAD_MUTEX_INITIALIZER;pthread_mutex_t mutexB = PTHREAD_MUTEX_INITIALIZER;pthread_mutex_t mutexC = PTHREAD_MUTEX_INITIALIZER;void* thread1_function(void* arg) { pthread_mutex_lock(&mutexA); printf("线程 1 持有 mutexA\n"); sleep(1); pthread_mutex_lock(&mutexB); printf("线程 1 获取到 mutexB\n"); pthread_mutex_unlock(&mutexB); pthread_mutex_unlock(&mutexA);return NULL;}void* thread2_function(void* arg) { pthread_mutex_lock(&mutexB); printf("线程 2 持有 mutexB\n"); sleep(1); pthread_mutex_lock(&mutexC); printf("线程 2 获取到 mutexC\n"); pthread_mutex_unlock(&mutexC); pthread_mutex_unlock(&mutexB);return NULL;}void* thread3_function(void* arg) { pthread_mutex_lock(&mutexC); printf("线程 3 持有 mutexC\n"); sleep(1); pthread_mutex_lock(&mutexA); printf("线程 3 获取到 mutexA\n"); pthread_mutex_unlock(&mutexA); pthread_mutex_unlock(&mutexC);return NULL;}intmain(){ pthread_t t1, t2, t3; pthread_create(&t1, NULL, thread1_function, NULL); pthread_create(&t2, NULL, thread2_function, NULL); pthread_create(&t3, NULL, thread3_function, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); pthread_join(t3, NULL);return0;}当这三个线程并发执行时,很容易出现循环等待的情况,满足循环等待条件,进而导致死锁;这四个条件必须同时满足,死锁才会发生。只要破坏其中任何一个条件,就可以避免死锁的产生 。
死锁一旦发生,对 Linux 系统的影响是多方面的,而且非常严重:
二、内核死锁定位原理
面试题写作模版在 Linux 内核这个庞大而复杂的系统中,锁就像是一道道坚固的防线,守护着共享资源的安全,防止多个进程(线程)在并发访问时引发混乱。内核中存在多种类型的锁,它们各具特色,适用于不同的场景 :
①自旋锁(Spinlock):就像一个急性子的守卫,当一个线程试图获取自旋锁时,如果锁已经被其他线程持有,它不会乖乖地去睡觉等待,而是在原地不断地循环检查锁是否被释放,就像一个人在门口不停地敲门,问里面的人是否已经用完资源。这种锁适用于临界区代码执行时间极短的场景,因为它避免了线程上下文切换的开销。例如,在多处理器系统中,对于一些对时间敏感的硬件资源访问,自旋锁可以快速地实现资源的互斥访问 。但是,如果临界区执行时间过长,自旋的线程会一直占用 CPU 资源,导致 CPU 利用率飙升,就像一个人一直在门口敲门,让其他人无法进入房间,造成资源浪费。不理解自旋锁的,请参考这篇《一文吃透 Spinlock,和内核死锁说再见!》
②互斥锁(Mutex):是一种更 “温和” 的锁机制,当一个线程获取互斥锁后,其他线程如果试图获取该锁,会被阻塞并进入睡眠状态,直到持有锁的线程释放锁,就像在图书馆借书,当一本书被一位读者借走后,其他读者只能在借阅处等待,直到这本书被归还。互斥锁适用于临界区执行时间较长,可能会导致线程睡眠的场景,它能有效地避免 CPU 资源的浪费 。在文件系统的读写操作中,互斥锁可以保证同一时间只有一个线程对文件进行写入,防止数据冲突。不理解互斥锁的,请参考《一文吃透 mutex 互斥锁,彻底解决 Linux 内核同步难题》
③读写锁(Read - Write Lock):它将对资源的访问分为读操作和写操作,允许多个线程同时进行读操作,因为读操作不会修改资源的状态,所以不会产生冲突。但是,当有一个线程进行写操作时,会独占锁,其他读线程和写线程都必须等待,直到写操作完成。这就好比一场讲座,很多人可以同时在台下听讲(读操作),但如果有人要上台修改讲座内容(写操作),就必须等他修改完,其他人才能继续听讲或上台修改。读写锁适用于读多写少的场景,能够提高系统的并发性能 ,比如在数据库的查询操作中,大量的读请求可以同时进行,而写操作则需要严格控制。
这些锁机制在 Linux 内核中协同工作,确保了共享资源在多线程、多进程环境下的安全访问。然而,正是由于锁的使用场景复杂多样,如果使用不当,就容易引发死锁问题 。
(1)Lockdep——内核级死锁检测利器。Lockdep 是 Linux 内核提供的一个强大的死锁检测工具 ,它就像是一位隐藏在内核深处的侦探,默默地监视着锁的使用情况。Lockdep 的主要功能是跟踪每个锁的自身状态以及各个锁之间的依赖关系,通过一系列精心设计的验证规则,来确保锁之间的依赖关系是正确的,从而及时发现潜在的死锁问题。
从原理上讲,Lockdep 基于锁类(lock class)来工作。这里的锁类是一种 “逻辑” 锁的概念,与锁的实际物理实例有所不同 。举个例子,对于内核中的打开文件数据结构 struct file,它包含一个互斥锁(mutex)和一个自旋锁(spinlock),Lockdep 会将它们视为一个锁类。即便在运行时内存中存在成千上万个 struct file 实例,Lockdep 也仅仅把它们当作一个类来进行跟踪。
在实际运行过程中,Lockdep 会维护一个锁的依赖关系图,每个锁类就是图中的一个节点,而锁的获取顺序则构成了图中的边 。它会持续不断地检查这个图,一旦发现通过新增的边可能会形成一个环,就意味着有死锁的风险,这时 Lockdep 就会立即拉响警报,输出详细的死锁信息,包括当前的调用栈、锁的依赖链等,帮助开发者迅速定位问题所在。
要使用 Lockdep,首先需要在内核中使能它 。这需要在编译内核时进行相关配置,进入内核源码目录,使用 make menuconfig(或 make nconfig/make xconfig)打开配置界面,找到 Kernel hacking -> Lock Debugging (spinlocks, mutexes, etc...)这一项,然后敲回车进去,在这里可以看到一长串选项 。其中,CONFIG_PROVE_LOCKING 是 Lockdep 的核心开关,必须设为 y,只有打开它,Lockdep 才会真正去执行死锁可能性验证的工作 。CONFIG_DEBUG_LOCKDEP 这个选项也很重要,它能让 Lockdep 在检测到死锁可能性时,输出详细的警告信息,同时,在内核发生死锁时提供全面的报告,通常它会和 CONFIG_PROVE_LOCKING 一起开启
此外,CONFIG_LOCK_STAT 是一个非常实用的性能分析工具,它不仅能检测死锁,还会统计锁的竞争情况,比如一个锁被争用了多少次、任务等待这个锁平均需要多长时间等,这些信息对于找出系统中的性能瓶颈(也就是那些 “热点锁”)至关重要 。打开这个选项后,系统会新增/proc/lock_stat 和/proc/sys/kernel/lock_stat 这两个接口,方便我们获取相关的统计数据 。需要注意的是,这些调试选项会显著增加内核的内存占用和运行时开销,因为 Lockdep 需要维护大量的状态信息 。所以,在生产环境的内核中,务必关闭它们,这些选项主要适用于测试机或开发板等开发调试场景。
当 Lockdep 检测到死锁问题时,会输出一大段详细的信息 。例如,假设我们的系统出现了一个递归死锁的情况,Lockdep 的输出可能如下:
[ 1404.517142] ============================================[ 1404.517826] WARNING: possible recursive locking detected[ 1404.518250] --------------------------------------------[ 1404.518432] insmod/1348is trying to acquire lock:[ 1404.519375] 000000001759de9e (\u0026(\u0026p-\u003ealloc_lock)-\u003erlock){+.+.}, at: __get_task_comm+0x38/0x88[ 1404.521885][ 1404.521885] but task is already holding lock:[ 1404.522282] 000000001759de9e (\u0026(\u0026p-\u003ealloc_lock)-\u003erlock){+.+.}, at: showthrds_buggy+0x9c/0x504 [thrd_showall_buggy]第一行 WARNING: possible recursive locking detected 明确告诉我们发现了可能的递归锁问题,也就是同一个执行路径试图第二次获取已经持有的锁 。接着,insmod/1348 is trying to acquire lock:表明当前进程(insmod,进程 ID 为 1348)正试图获取一把锁 。000000001759de9e (\u0026(\u0026p-\u003ealloc_lock)-\u003erlock){+.+.}, at: __get_task_comm+0x38/0x88 这部分详细描述了要获取的锁的相关信息,包括一个 64 位轻量级哈希值(用于标识特定的锁序列)、锁的状态符号({+.+.},其中+表示在启用中断请求(IRQs)时获取锁,.表示在禁用中断请求且不在中断上下文时获取锁等,具体含义可参考内核文档 )以及获取锁的位置(在__get_task_comm 函数的 0x38 偏移处,函数总大小为 0x88) 。but task is already holding lock:则指出任务已经持有了同一把锁,后面同样跟着锁的详细信息以及持有锁的位置(在 showthrds_buggy 函数的 0x9c 偏移处,函数总大小为 0x504,模块名为 thrd_showall_buggy) 。通过这些信息,我们就能够清晰地了解死锁发生的具体情况,进而针对性地进行问题排查和解决 。
(2)gdb——深入进程内部的调试工具。gdb(GNU Debugger)是 GNU 开源组织发布的一个功能强大的程序调试工具,在 Linux 系统下,它可以帮助我们深入进程内部,查看线程的调用栈,从而判断是否发生死锁 。使用 gdb 排查死锁,首先要连接到目标进程 。假设我们已经知道了目标进程的 ID 为 1234,那么可以通过以下命令启动 gdb 并连接到该进程:
gdb -p 1234连接成功后,进入 gdb 的交互界面 。接下来,我们需要查看进程中各个线程的状态 。在 gdb 中,可以使用 info threads 命令来获取线程列表,该命令会列出所有线程的 ID、状态以及当前所在的函数等信息 。例如,执行 info threads 后,可能会得到如下输出:
Id Target Id Frame1 Thread 0x7ffff7fe7700 (LWP 1234) "my_program"0x00007ffff7a0d42fin pthread_join() from /lib64/libpthread.so.0 2 Thread 0x7ffff77e6700(LWP 1235) "thread_function" 0x00007ffff7a0c63f in pthread_mutex_lock() from /lib64/libpthread.so.0从这个输出中,我们可以看到有两个线程,线程 ID 分别为 1 和 2 。线程 1 的目标 ID 是 0x7ffff7fe7700,它对应的 Linux 线程 ID(LWP)是 1234,正在执行 pthread_join 函数;线程 2 的目标 ID 是 0x7ffff77e6700,LWP 是 1235,正在执行 pthread_mutex_lock 函数 。
判断死锁状态的关键在于分析线程的调用栈 。如果多个线程互相等待对方释放锁,并且调用栈显示它们都在等待某个锁的获取,那么很有可能发生了死锁 。我们可以使用 thread 命令切换到具体的线程,然后使用 bt(backtrace 的缩写)命令查看该线程的调用栈 。例如,要查看线程 2 的调用栈,可以先执行 thread 2 切换到线程 2,然后执行 bt,得到的调用栈信息可能如下:
#00x00007ffff7a0c63fin pthread_mutex_lock() from /lib64/libpthread.so.0#1 0x0000000000401234 in my_function() at my_source_file.c:45#2 0x0000000000401356 in another_function() at my_source_file.c:67#3 0x00007ffff77e6890 in start_thread() from /lib64/libpthread.so.0从这个调用栈中,我们可以看到线程 2 在 my_function 函数中调用了 pthread_mutex_lock 试图获取锁,而该锁可能被其他线程持有,并且从调用栈的其他层级可以进一步分析线程的执行路径和可能的死锁原因 。如果同时查看多个线程的调用栈,发现它们之间存在互相等待的情况,比如线程 A 等待线程 B 持有的锁,而线程 B 又等待线程 A 持有的锁,那么就可以确定发生了死锁 。通过这样的方式,我们能够利用 gdb 深入分析进程内部的线程状态,准确判断死锁的发生,并定位到死锁相关的代码位置,为解决死锁问题提供有力的支持 。
(3)pstack——快速抓取线程调用栈。pstack 是一个基于 gdb 的工具,它的主要功能是快速打印出指定进程内所有线程的调用栈 ,在检测死锁时,它能为我们提供直观的线程执行信息。使用 pstack 非常简单,只需要知道目标进程的 ID 即可 。假设目标进程的 ID 是 5678,那么执行以下命令就能获取该进程所有线程的调用栈:
pstack 5678执行命令后,pstack 会输出该进程中每个线程当前的函数调用栈信息 。例如,输出可能如下:
Thread 1 (Thread 0x7ffff7fe7700 (LWP 5678)):#00x00007ffff7a0d42fin pthread_join() from /lib64/libpthread.so.0#1 0x0000000000401567 in main() at main.c:78Thread 2 (Thread 0x7ffff77e6700 (LWP 5679)):#0 0x00007ffff7a0c63f in pthread_mutex_lock() from /lib64/libpthread.so.0#1 0x0000000000401234 in my_function() at my_module.c:34#2 0x0000000000401356 in another_function() at my_module.c:56#3 0x00007ffff77e6890 in start_thread() from /lib64/libpthread.so.0从这个输出中,我们可以清晰地看到每个线程正在执行的函数以及函数调用的层级关系 。在判断死锁时,我们可以多次执行 pstack 命令,观察线程的调用栈是否有变化 。如果发现某些线程的调用栈一直停留在等待锁的函数上,并且这些线程之间存在资源竞争关系,那么就有可能发生了死锁 。
比如,若线程 2 一直停留在 pthread_mutex_lock 函数处,且其他线程持有它所需要的锁,同时线程 2 又持有其他线程需要的锁,这就符合死锁的特征 。通过 pstack 提供的这些信息,我们能够快速定位到可能发生死锁的线程,进而深入分析死锁的原因,它为我们在排查死锁问题时提供了一种高效、便捷的手段 。
(4)Ftrace 动态追踪——性能分析神器。Ftrace 是 Linux 内核中的一个强大的动态追踪工具,它就像一个高精度的摄像机,能够记录下内核函数的调用过程,为我们提供锁操作的详细时序信息,帮助我们清晰地定位死锁发生的路径和具体的代码位置 。
Ftrace 通过函数调用图来实现对锁操作的追踪。当一个线程获取或释放锁时,Ftrace 会记录下这个操作发生的时间、调用函数以及相关的参数信息。通过分析这些记录,我们可以构建出完整的锁操作时序图,就像观看一场比赛的录像回放,能够清楚地看到每个动作的先后顺序 。
例如,在一个多线程的网络服务器程序中,可能存在多个线程同时访问网络连接资源的情况。如果发生了死锁,我们可以使用 Ftrace 来追踪锁的操作。Ftrace 会记录下每个线程在何时获取了哪个锁,以及在获取锁之前和之后执行了哪些函数。通过查看这些记录,我们可以发现,线程 A 在获取锁 A 后,调用了一个函数去处理网络请求,在这个函数中又试图获取锁 B,而此时线程 B 已经持有锁 B 并且正在等待锁 A,这样就找到了死锁发生的路径 。
要使用 Ftrace 进行锁操作追踪,首先需要确保内核编译时启用了 CONFIG_FUNCTION_TRACER 和 CONFIG_DEBUG_FS 这两个配置选项。然后,通过挂载 debugfs 文件系统,我们可以访问 /sys/kernel/debug/tracing 目录下的相关文件,通过这些文件,我们可以配置 Ftrace 的追踪选项,比如选择要追踪的函数、设置追踪的深度等 。例如,要启用函数图追踪器(function_graph),我们可以执行以下命令:
echo function_graph > /sys/kernel/debug/tracing/current_tracerecho 1 > /sys/kernel/debug/tracing/tracing_on执行完这些命令后,Ftrace 就会开始记录函数的调用信息,包括锁的操作。当我们触发了可能导致死锁的操作后,可以通过查看 /sys/kernel/debug/tracing/trace 文件,来获取详细的追踪结果。这个文件中会以缩进的格式显示函数的调用层级关系,以及每个函数的执行时间,让我们能够一目了然地看到锁操作的时序和相关的函数调用路径 。
在死锁检测和定位的领域里,数学模型和算法就像是精密的仪器,为我们提供了科学、高效的分析方法。其中,银行家算法(Banker’s Algorithm)是一种经典的用于避免死锁的资源分配策略,它的思想源于银行的借贷业务 。
想象一下,银行家手中有一定数量的资金(资源),有多个客户(进程)前来贷款(请求资源)。银行家在发放贷款之前,会仔细评估每个客户的还款能力(资源需求和释放情况),以确保所有客户都能按时还款,银行不会因为资金周转问题而倒闭(系统不会发生死锁)。在操作系统中,银行家算法通过预先检查系统资源的分配情况,判断是否存在安全序列,即是否存在一种资源分配顺序,使得所有进程都能顺利完成任务并释放资源 。银行家算法涉及几个关键概念:
算法的核心步骤包括:
在 Linux 内核死锁定位中,银行家算法可以用于分析系统资源的分配情况,提前发现可能导致死锁的资源分配模式。虽然内核中实际的死锁检测机制可能更为复杂,但银行家算法的思想为我们理解和解决死锁问题提供了重要的理论基础 。
三、内核死锁排查原理
面试题写作模版当系统出现疑似死锁症状,如进程长时间无响应、系统负载飙升且 CPU 使用率异常等,我们就需要迅速展开排查工作。首先,要充分利用系统日志这一宝贵资源。系统日志(如 /var/log/messages、/var/log/syslog 等)中可能会记录下死锁发生前后的关键信息,像是进程的异常行为、资源分配失败的提示等 。通过仔细查阅这些日志,我们可以初步判断问题的大致方向,比如是否有某个特定的服务或模块出现了异常。
接着,使用监控工具对系统状态进行实时监测。top 和 htop 是常用的系统监控工具,它们能够实时展示系统负载、CPU 使用率、内存占用以及所有运行中的进程信息。在死锁情况下,某些进程可能会长时间占据 CPU 资源,但却没有实际的工作进展,或者 CPU 使用率呈现出异常的波动 。通过观察这些指标,我们可以筛选出可能与死锁相关的进程。例如,当发现某个进程的 CPU 使用率一直保持在 100%,但系统的整体响应却变得极其缓慢,这就很可能是死锁的一个迹象。
在初步定位到可疑进程后,我们需要深入到内核层面进行分析。此时,可以借助 ps 命令查看进程的详细状态信息,包括进程的 PID、状态、PPID 等 。特别要关注处于 D 状态(不可中断睡眠状态)的进程,因为它们通常是在等待某些资源的释放,而这很可能与死锁有关。例如,使用命令 ps -eo pid,comm,wchan:20,state,ppid | awk '$4=="D" {print}',可以筛选出所有处于 D 状态的进程及其等待的内核函数(wchan) 。如果发现多个进程都处于 D 状态,并且它们之间存在资源依赖关系,那么死锁的可能性就大大增加了。
下面我们通过一个具体案例来展示如何使用各种工具和方法进行内核死锁排查。假设有一个基于 Linux 系统的网络服务器,突然出现响应迟缓,大量用户请求超时的情况。
首先,我们使用 top 命令查看系统状态,发现有几个进程的 CPU 使用率异常高,其中一个名为 network_service 的进程尤为突出 。接着,使用 ps 命令查看该进程的详细信息:
ps -ef | grep network_service得到如下输出:
root 123410009910:20 ? 00:05:10 /usr/sbin/network_service从输出中,我们看到该进程的 PID 为 1234,CPU 使用率高达 99%。进一步查看其状态,发现它处于 D 状态,这表明它可能在等待某个资源而陷入了死锁。为了获取更多关于锁的信息,我们使用 lslocks 命令,它可以显示系统上的活动锁信息,包括哪些进程持有锁以及锁的类型 :
lslocks输出结果显示:
USER PID ACCESS TYPE DEVICE SIZE MODE NAMEroot 1234 F... flock /dev/sda1 40960644 /var/log/network_service.log从这里我们得知,network_service 进程持有了 /var/log/network_service.log 文件的锁 。这可能是问题的关键所在,也许有其他进程也在尝试获取这个锁,从而导致了死锁。
为了深入分析,我们借助内核调试工具 ftrace。首先,启用 ftrace 的函数图追踪器:
echo function_graph > /sys/kernel/debug/tracing/current_tracerecho 1 > /sys/kernel/debug/tracing/tracing_on然后,触发一些网络请求,模拟死锁场景。之后,查看 ftrace 的追踪结果:
cat /sys/kernel/debug/tracing/trace在追踪结果中,我们发现 network_service 进程在获取锁的过程中,调用了一系列函数,并且与另一个进程 logging_service 产生了资源竞争 。logging_service 进程也试图获取 /var/log/network_service.log 文件的锁,从而形成了死锁。通过这些详细的追踪信息,我们可以准确地定位到死锁发生的代码位置和相关的函数调用路径,为解决死锁问题提供了有力的支持。
驱动程序场景主要负责与硬件设备进行交互,过程中常需要使用锁来保护共享资源,死锁多发生在多个驱动同时访问同一硬件资源,或单个驱动在中断上下文、进程上下文等不同上下文错误使用锁的情况,例如驱动在中断处理函数中获取自旋锁后,进程上下文也尝试获取同一把自旋锁,由于中断处理函数无法被抢占,进程上下文会持续等待,最终形成死锁;排查这类死锁时,需重点检查锁的获取与释放顺序,尤其关注不同上下文间的锁操作,核对驱动代码逻辑是否存在同一锁重复获取、锁获取顺序不一致等问题,同时留意驱动与硬件设备的交互流程,确认是否因硬件资源竞争导致锁被长时间持有。
中断上下文场景中,中断是硬件向 CPU 发送的事件通知信号,触发时 CPU 会暂停当前任务转而执行中断处理程序,若中断处理程序与进程上下文同时访问共享资源且锁机制使用不当,极易引发死锁,典型场景为进程持有共享资源锁执行临界区代码时发生中断,中断处理程序尝试获取同一把锁,而进程持有锁期间无法被抢占,中断处理程序又不能被进程抢占,最终双方相互等待形成死锁;排查这类死锁时,需重点关注中断处理程序与进程上下文的资源访问冲突,检查中断处理程序中中断禁用与启用操作是否规范、获取锁前是否完成必要的中断状态检查,同时可结合内核日志与内核调试工具,分析中断触发时机和锁的获取释放流程,精准定位死锁根源。
内存管理场景下,Linux 内核的内存管理模块复杂且关键,多个进程同时申请内存资源、或内存回收机制与进程内存使用产生冲突时都可能引发死锁,例如内存紧张时,一个进程持有内存分配锁并触发内存回收操作,而内存回收又需要等待其他进程持有的内存资源,进而形成循环等待导致死锁;排查这类死锁需重点关注内存分配与回收的完整流程,检查 kmalloc、vmalloc 等内存分配函数和 kfree、vfree 等内存释放函数的调用逻辑,确认是否存在资源竞争与锁滥用问题,同时可通过 /proc/meminfo 查看内核内存状态、借助 memleak、slabtop 等内存调试工具检测内存泄漏与异常使用行为,以此定位死锁线索。
四、实战案例:Linux 死锁排查全记录
面试题写作模版在一个基于 Linux 系统搭建的分布式文件存储集群中,每台服务器运行着 CentOS 7 操作系统,内核版本为 3.10.0-1160.83.1.el7.x86_64 。该集群主要负责为企业内部的业务系统提供文件存储和读取服务,每天处理大量的文件上传、下载和修改操作。
某天,运维团队突然接到业务部门反馈,称多个业务系统在进行文件读取操作时,长时间无响应,页面一直处于加载状态 。运维人员立即登录到集群的管理节点,通过监控系统查看集群状态,发现多台存储服务器的负载极高,CPU 使用率接近 100%,但磁盘 I/O 却非常低,几乎处于停滞状态。进一步查看系统日志,在 /var/log/messages 文件中发现大量与文件系统操作相关的错误信息,提示某些文件操作超时,如:
Nov 514:23:45 storage01 kernel: EXT4-fs (sda1): I/O error while writing to inode 12345Nov 514:23:45 storage01 kernel: Aborting journal on device sda1-8.同时,通过 ping 命令测试各服务器之间的网络连接,发现网络延迟正常,不存在网络故障 。这一系列异常现象表明,集群很可能出现了死锁问题,导致文件系统操作无法正常进行。
面对这一复杂的死锁问题,我们迅速展开了全面而细致的排查工作。首先,利用 top 命令对集群中各服务器的系统状态进行实时监控 。在多台出现问题的存储服务器上,top 命令的输出显示,有几个与文件系统相关的进程,如 kworker/0:1H 和 jbd2/sda1-8,长时间占据着较高的 CPU 使用率,且处于不可中断睡眠状态(D 状态) 。这是一个关键的线索,因为处于 D 状态的进程通常是在等待某些资源的释放,很可能与死锁密切相关。例如,在其中一台服务器上执行 top 命令后,得到如下关键信息:
top - 14:30:00 up 10 days, 2:15, 1 user, load average: 9.87, 9.95, 9.98Tasks: 201 total, 1 running, 200 sleeping, 0 stopped, 0 zombie%Cpu(s): 99.7 us, 0.1 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.2 si, 0.0 stKiB Mem : 16384480 total, 1024000 free, 14096480 used, 1264000 buff/cacheKiB Swap: 0 total, 0 free, 0 used. 1764000 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND1234 root 2002048001234010240 D 99.00.15:12.34 kworker/0:1H2345 root 2003072002048015360 D 98.50.14:56.23 jbd2/sda1-8从这些信息可以看出,kworker/0:1H 和 jbd2/sda1-8 进程的 CPU 使用率极高,且处于 D 状态,这表明它们可能在等待某个资源而陷入了死锁。
为了深入了解这些进程的详细情况,我们使用 ps 命令查看它们的进程状态和相关信息 。通过执行 ps -ef | grep 1234 和 ps -ef | grep 2345(其中 1234 和 2345 分别为 kworker/0:1H 和 jbd2/sda1-8 的 PID),发现 jbd2/sda1-8 进程是 ext4 文件系统的日志守护进程,负责管理文件系统的日志操作;而 kworker/0:1H 进程则是内核工作线程,通常用于处理一些异步的 I/O 操作 。这进一步暗示了死锁问题可能与文件系统的日志和 I/O 操作有关。
接着,我们使用 lsof 命令来查看系统中所有打开的文件和网络连接,以确定是否存在文件资源的竞争 。执行 lsof /dev/sda1(sda1 为出现问题的文件系统分区)后,发现 kworker/0:1H 和 jbd2/sda1-8 进程都对一些关键的文件系统元数据文件持有锁,如超级块文件和日志文件 。这表明两个进程在争夺文件系统资源时,可能出现了死锁。具体输出如下:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAMEkworker/0:1H 1234 root mem REG 8,1409616777216 /dev/sda1 (inode=1)jbd2/sda1-82345 root mem REG 8,1409616777217 /dev/sda1 (inode=2)从这里可以看出,两个进程都在访问文件系统的关键元数据文件,且可能因为锁的争夺而陷入死锁。为了更准确地定位死锁的根源,我们决定启用 ftrace 动态追踪工具 。首先,通过修改/sys/kernel/debug/tracing/trace_options 文件,启用函数图追踪器,并设置追踪的函数为与文件系统锁操作相关的函数,如 ext4_journal_get_write_access 和 ext4_journal_start 。
然后,触发一些文件读取操作,模拟死锁场景。在操作完成后,查看/sys/kernel/debug/tracing/trace 文件,发现 kworker/0:1H 进程在执行文件写入操作时,获取了文件数据块的锁,然后试图获取日志提交锁;而 jbd2/sda1-8 进程在处理日志时,已经持有日志提交锁,并且正在等待文件数据块的锁 。这就形成了一个典型的循环等待局面,导致死锁的发生。ftrace 的追踪结果如下:
# tracer: function_graph## _------=> CPU#0# / _-----=> irqs-off# | / _----=> need-resched# || / _---=> hardirq/softirq# ||| / _--=> preempt-depth# |||| / delay# TASK-PID CPU# ||||| TIMESTAMP FUNCTION# | | | ||||| | | kworker/0:1H-1234 [000] d...1234567.891234: ext4_journal_get_write_access: (entry) kworker/0:1H-1234 [000] d...1234567.891240: ext4_journal_start: (entry) kworker/0:1H-1234 [000] d...1234567.891245: ext4_journal_get_write_access: (exit) kworker/0:1H-1234 [000] d...1234567.891250: ext4_journal_start: (exit) jbd2/sda1-8-2345 [001] d...1234567.891255: ext4_journal_start: (entry) jbd2/sda1-8-2345 [001] d...1234567.891260: ext4_journal_get_write_access: (entry) jbd2/sda1-8-2345 [001] d...1234567.891265: ext4_journal_start: (exit) jbd2/sda1-8-2345 [001] d...1234567.891270: ext4_journal_get_write_access: (exit)针对上述排查出的死锁原因,我们采取了以下具体的解决方案:
(1)调整锁获取顺序:修改文件系统相关的代码逻辑,确保在进行文件操作时,所有进程按照统一的顺序获取文件数据块锁和日志提交锁 。例如,规定先获取日志提交锁,再获取文件数据块锁,避免出现循环等待的情况。这一调整可以从根本上破坏死锁产生的循环等待条件,从而解决死锁问题。
(2)增加锁超时机制:在文件系统锁操作函数中,引入锁超时机制 。当一个进程尝试获取锁时,如果在一定时间内(如 10 秒)未能成功获取,就放弃当前操作,释放已持有的锁,并记录相关的错误信息 。这样可以避免进程因为长时间等待锁而陷入死锁,提高系统的容错性。例如,使用 pthread_mutex_timedlock 函数代替 pthread_mutex_lock 函数,实现锁的超时获取:
#include <pthread.h>#include <stdio.h>#include <stdlib.h>#include <time.h>pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;intmain(){ struct timespec timeout; clock_gettime(CLOCK_REALTIME, &timeout); timeout.tv_sec += 10; // 设置 10 秒超时int ret = pthread_mutex_timedlock(&mutex, &timeout);if (ret == 0) {// 获取锁成功,执行业务逻辑 printf("成功获取锁\n"); pthread_mutex_unlock(&mutex); } elseif (ret == ETIMEDOUT) {// 获取锁超时,处理超时情况 printf("获取锁超时,放弃操作\n"); } else {// 其他错误情况 perror("pthread_mutex_timedlock"); exit(EXIT_FAILURE); }return0;}(3)优化文件系统操作流程:对文件系统的操作流程进行优化,减少不必要的锁持有时间 。例如,将一些可以并发执行的文件操作进行分离,避免多个操作同时竞争同一把锁 。同时,增加对文件系统操作的缓存机制,减少对磁盘的直接 I/O 操作,提高系统的性能和稳定性。
在完成上述修复措施后,我们对分布式文件存储集群进行了全面的测试 。通过模拟大量的文件上传、下载和修改操作,观察系统的运行状态。经过长时间的测试,发现系统的响应速度恢复正常,文件操作不再出现超时和无响应的情况,CPU 使用率也保持在合理的范围内 。这表明我们成功地解决了死锁问题,修复后的系统稳定性得到了有效验证。
end
如果这篇文章对你有所启发,欢迎点赞、在看,转发三连。星标⭐账号,还可以第一时间收到推送,感谢你的收看,我们下期再见~
往期干货推荐