僵尸态是 linux 进程的一种状态,用 Z (zombie) 表示。处于 Z 状态的进程已经不在工作,进程的资源(内存,打开的文件) 都已经释放,只保留 struct task_struct 一个空壳子,用僵尸来表示这个状态非常形象。僵尸进程不能被信号杀死(因为僵尸进程已经死了,当然也不能响应信号),只能被父进程回收。进程处于僵尸态时保存的信息非常少,其中包括进程号,退出码,退出码是比较重要的,父进程回收僵尸进程的时候可以根据退出码确定子进程的退出原因。

D 状态全称是 disk sleep,是不可中断睡眠态,常常用在访问磁盘的场景,当线程被设置为 TASK_UNINTERRUPTIBLE 之后便会显示为 D 状态,处在这种状态下的进程只能被资源唤醒,不能被信号唤醒,kill -9 也杀不掉。

Z 状态和 D 状态没有直接的关系,两者的相同点是不能响应信号;区别是 Z 状态的进程已经死了,D 状态的进程还在工作。

1 Z 状态

1.1 僵尸进程(Z 状态)是如何产生的

当子进程退出之后,如果父进程没有通过 wait() 进行回收,那么这个进程就会变成僵尸进程。父进程通过 wait() 可以获取子进程退出的状态,比如是被某个信号(SIGTERM, SIGABRT 等)杀死的或者退出码(0, -1 等)是什么。

如下代码,创建了一个子进程,子进程打印一条日志之后就退出了,父进程睡眠了 20s,在子进程退出,父进程睡眠的这 20s 内,子进程的状态是 Z 状态。

#include

#include

#include

int main(void) {

pid_t pid = fork();

if (pid < 0) {

perror("fork failed.");

exit(1);

}

if (pid > 0) {

printf("this is the parent process, pid is %d.\n", getpid());

sleep(20);

} else if (pid == 0) {

printf("this is the child process. pid is: %d. ppid is: %d.\n", getpid(), getppid());

return 0;

}

return 0;

}

将上边的代码编译,运行,可以看到父进程的进程号是 3247, 子进程的进程号是 3248。子进程退出之后处于 Z 状态,处于 Z 状态的进程,进程名用中括号括起来(僵尸放到了棺材里),并且后边标记为 defunct。

僵尸进程是不再工作的进程,是已经死掉的进程,不能通过 kill -9 杀掉。

要想让僵尸进程消失,可以杀掉它的父进程。父进程被杀掉之后,僵尸进程会变成孤儿进程,孤儿进程会被系统的 1 号进程收留(托孤),1 号进程会检查该进程是不是僵尸进程,如果是的话则会将进程回收。

孤儿进程会托孤给 1 号进程,下边的代码显示了这个过程,父进程 sleep 5s 之后退出,子进程 sleep 8s,也就是说在子进程退出之前父进程已经退出了,这样父进程不会回收子进程。子进程在 sleep 前后分别打印 ppid(parent pid 父进程的 pid), 可以看到 sleep 之后打印的父进程 id 是 1。

#include

#include

#include

int main(void) {

pid_t pid = fork();

if (pid < 0) {

perror("fork failed.");

exit(1);

}

if (pid > 0) {

printf("\nthis is the parent process, pid: %d\n", getpid());

sleep(5);

printf("\nparent process after sleep\n");

} else if (pid == 0) {

printf("\nthis is the child process. pid: %d, ppid: %d\n", getpid(), getppid());

sleep(8);

printf("\nchild process after sleep, pid: %d, ppid: %d\n", getpid(), getppid());

sleep(100);

return 0;

}

return 0;

}

1.2 假僵尸进程

一开始的时候说过,僵尸进程是不在工作的进程,资源都已经释放,并且通过 kill -9 杀不掉。本节记录的假的僵尸进程,通过 ps -aux 看到进程的状态是 Z,但是这个进程还在工作,资源没有释放,并且可以通过 kill -9 杀掉。

正常情况下主进程的退出是通过主线程中 return 或者调用 exit() 退出的,这种退出方式,整个进程都会退出。如果主线程通过 pthread_exit() 进行退出,那么退出的只是这个主线程,如果进程中有子线程的话,子线程不会退出,并且资源也不会释放。

下边这段代码,在主线程中创建了一个子线程,子线程中是 while(1) 循环。创建完子线程之后,主线程调用 pthread_exit() 退出,构造了一个主线程退出,但是子线程没有退出的场景。

#include

#include

#include

#include

#include

void *thread_func(void *data) {

printf("thread entry\n");

while (1)

;

}

int main(void) {

printf("pid: %d\n");

pthread_t tid;

pthread_create(&tid, NULL, thread_func, NULL);

pthread_exit(NULL);

return 0;

}

上述代码运行之后,通过 top 查看进程的情况,发现进程中有两个线程,分别是 3391 和 3392,其中 3391 是主线程,处于 Z 状态,3392 是子线程,处在运行态。主线程处于 Z 状态,所以查看进程的状态也是处于 Z 状态。这种状态是假的僵尸态,父进程也不会回收假的僵尸进程。

1.3 父进程是 1 号进程的僵尸进程是怎么产生的

从上边的分析来看,如果进程先变成了僵尸进程,然后托孤给 1 号进程,那么托孤给 1 号进程之后,1 号进程就会回收这个僵尸进程;如果进程先变成孤儿进程,那么变为孤儿进程时,便会托孤给 1 号进程,之后进程状态转换为僵尸进程时,也会被 1 号进程回收。上边这两种情况下,如果僵尸进程的父进程是 1 号进程,那么僵尸进程很快就会被 1 号进程回收,僵尸进程存在的时间比较短。

另外还有一种情况,僵尸进程的父进程是 1 号进程的状态存在的时间比较长,僵尸进程并没有被回收。以下 3 中条件会出现这种情况:

(1)僵尸进程是假僵尸进程

(2)进程被使用,比如被 gdb 调试

(3)进程中有子线程处于 D 状态

下边分别用例子来构造出上边 3 种情况:

(1)僵尸进程是假僵尸进程

如下代码,fork 出一个子进程,之后父进程退出,父进程退出之后,子进程被过继给进程 1。子进程中创建了一个子线程之后,主线程通过 pthread_exit() 退出,所以子进程就成了假僵尸进程,假僵尸进程可以直接 kill 掉。这个时候假的僵尸进程的父进程是 1 号进程,并且这个进程还没有真正退出。

#include

#include

#include

#include

#include

void *thread_func(void *data) {

printf("thread entry\n");

while (1)

;

}

int main(void) {

printf("pid: %d\n");

if (fork() == 0) {

pthread_t tid;

pthread_create(&tid, NULL, thread_func, NULL);

pthread_exit(NULL);

}

return 0;

}

(2)进程被使用,比如被 gdb 调试

如下的代码,fork() 一个子进程之后,父子进程都执行 while(1) 循环,如果使用 gdb --pid 子进程 id 之后,然后再 kill 掉父进程和子进程之后,父进程会直接退出,子进程会成为父进程是 1 号进程的僵尸进程,这种进程待 gdb 退出之后才会被回收,还在被 gdb 跟踪的过程中,1 号进程不会回收这个僵尸进程。

#include

#include

#include

#include

#include

int main(void) {

int i = 0;

fork();

while (1) {

printf("i: %d\n", i++);

sleep(1);

}

return 0;

}

(3)进程中有子线程处于 D 状态

如果主线程已经退出,但是子线程中有 D 状态,这个时候跟假的僵尸进程是比较类似的。

2 D 状态

2.1 D 状态 TASK_UNINTERRUPTIBLE

为了模拟出 TASK_UNINTERRUPTIBLE 的状态,我们通过内核模块来实现。

下边这个内核模块中有一个 cb 参数,该参数可以通过命令行 echo xxx > xxx 进行修改,在修改这个参数的时候,notify_param 便会被调用,在该函数中将线程设置为 TASK_UNINTERRUPTIBLE 然后调用 schedule() 让出 cpu,这种情况下,echo 命令便会卡住。在设置状态之前打印了当前线程的 id,这样可以定位到是哪个线程。

之所以通过 cb 参数的方式来模拟出 D 状态,而不直接创建内核线程,再设置内核线程为 D 状态,是因为前者的参数设置函数可以在用户态调用到,也就是最终设置为 D 状态的线程是一个用户态的线程,而如果在内核线程里边设置 D 状态,那么这个线程只是个内核态的线程。而 kill 信号只能发送给用户态的进程,对内核态线程无效, 后边要说明处于 TASK_UNINTERRUPTIBLE 状态的进程,使用 kill 命令杀不掉,所以使用了 cb 参数的方式来模拟出 D 状态。

内核模块源码:

#include

#include

#include

#include

#include

int cb_value = 0;

int notify_param(const char *val, const struct kernel_param *kp)

{

int res = param_set_int(val, kp);

printk("pid %d\n", current->pid);

set_current_state(TASK_UNINTERRUPTIBLE);

schedule();

if (res == 0)

{

printk(KERN_INFO "call back function called, new value: %d\n", cb_value);

return 0;

}

return -1;

}

const struct kernel_param_ops test_param_ops =

{

.set = ¬ify_param,

.get = ¶m_get_int,

};

module_param_cb(cb_value, &test_param_ops, &cb_value, S_IRUGO | S_IWUSR);

static int __init test_init(void)

{

printk(KERN_INFO "cb_value = %d \n", cb_value);

printk(KERN_INFO "test module is installed\n");

return 0;

}

static void __exit test_exit(void)

{

printk(KERN_INFO "test module is removed\n");

}

module_init(test_init);

module_exit(test_exit);

MODULE_LICENSE("GPL");

MODULE_AUTHOR("w2x");

MODULE_DESCRIPTION("test disk sleep by module_param_cb");

MODULE_VERSION("1.0");

内核模块编译脚本:

obj-m += test.o

KDIR =/lib/modules/$(shell uname -r)/build

all:

make -C $(KDIR) M=$(shell pwd) modules

clean:

make -C $(KDIR) M=$(shell pwd) clean

 操作步骤:

(1)编译之后安装模块,通过如下命令设置 cb_value 的值,那么进程就会进入 D 状态。

echo 10 > /sys/module/test/parameters/cb_value

(2)通过 dmesg 查看到线程号是 2402

(3)通过 ps -aux |grep 2402 查看到该线程处于 D 状态

这样我们就构造除了一个处于 D 状态的用户态进程。

 (4)通过 cat /proc/2402/stack 看到线程的调用栈,该线程调用到了 notify_param

(5)2402 通过 kill -9 杀不掉

设置 TASK_UNINTERRUPTIBEL 之后,线程处于 D 状态,D 状态又叫 disk sleep,与访问磁盘有关,该状态通过 kill 信号杀不掉,只能等到条件满足之后才可从 D 状态中返回。

D 状态也不仅仅用在磁盘操作的场景,当内核出现异常的时候,有时也会将线程设置为 D 状态,如下代码所示,当进程在退出的时候,内核出现了 bug,会递归调用退出函数,这个时候内核就会停止该进程的退出过程而把它设置为 D 状态。本文后边讲的问题就是这个现象。

void __noreturn do_exit(long code)

{

/*

* We're taking recursive faults here in do_exit. Safest is to just

* leave this task alone and wait for reboot.

*/

if (unlikely(tsk->flags & PF_EXITING)) {

pr_alert("Fixing recursive fault but reboot is needed!\n");

futex_exit_recursive(tsk);

set_current_state(TASK_UNINTERRUPTIBLE);

schedule();

}

}

另外,内核中的互斥体 mutex,如果不能立即锁,在等待锁的过程中,也会进入 D 状态。

如下内核模块,创建了一个内核线程,在内核线程中连续调用两次 mutex_lock(),第二次调用会造成死锁,该线程就会设置成 TASK_UNINTERRUPTIBLE 状态。

#include

#include

#include

#include

#include

struct mutex test_mutex;

static int thread_func(void *data) {

printk("test_thread_pid: %d\n", current->pid);

mutex_init(&test_mutex);

printk("1\n");

mutex_lock(&test_mutex);

printk("2\n");

mutex_lock(&test_mutex);

printk("3\n");

return 0;

}

static int __init hello_init(void){

printk("Hello World enteri, tid: %d\n", current->pid);

kthread_run(thread_func, NULL, "test_thread");

return 0;

}

static void __exit hello_exit(void){

printk("Hello World exit\n");

}

module_init(hello_init);

module_exit(hello_exit);

MODULE_AUTHOR("wx2");

MODULE_LICENSE("GPL v2");

MODULE_DESCRIPTION("hello world kernel module");

MODULE_ALIAS("hello world kernel module");

如下是 mutex_lock 相关的代码,最终会调用 __mutex_lock,该函数的第二个入参是 TASK_UNINTERRUPTIBLE,在 __mutex_lock 中最终会将线程设置成不可中断睡眠状态。

static noinline void __sched

__mutex_lock_slowpath(struct mutex *lock)

{

__mutex_lock(lock, TASK_UNINTERRUPTIBLE, 0, NULL, _RET_IP_);

}

2.2 D 状态 TASK_KILLABLE

set_current_state(TASK_UNINTERRUPTIBLE);

将上一节(1.4 节) 中的 set_current_state(TASK_UNINTERRUPTIBLE) 中 TASK_UNINTERRUPTIBLE 改成 TASK_KILLABLE,进程仍然显示为 D 状态。

两者的区别是,TASK_KILLABLE 可以使用 kill -9 杀掉,处于TASK_UNINTERRUPTIBLE 状态的线程不能被杀掉。

2.3 hung task 检测

内核 hung task 检测功能可以检测出处于 TASK_UNINTERRUPTIBLE 的进程,有以下参数可以设置。

参数 含义 kernel.hung_task_timeout_secs = 120 线程处在 TASK_UNINTERRUPTIBLE 的超时时间,超时之后打印线程栈或者内核 panic kernel.hung_task_panic = 0 如果设置为 1,那么超时之后内核 panic, 否则超时之后只打印线程栈,可以在 dmesg 中看到 kernel.hung_task_check_interval_secs = 0 检测周期 kernel.hung_task_warnings = 10 打印告警日志的次数 kernel.hung_task_check_count = 4194304 hung task 功能一次检测的线程的个数 kernel.hung_task_all_cpu_backtrace = 0 打印每个核的调用栈

告警信息:

[ 242.809728] INFO: task bash:2413 blocked for more than 120 seconds.

[ 242.809755] Tainted: G OE 5.15.0-67-generic #74~20.04.1-Ubuntu

[ 242.809757] "echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message.

[ 242.809758] task:bash state:D stack: 0 pid: 2413 ppid: 2245 flags:0x00000000

[ 242.809761] Call Trace:

[ 242.809763]

[ 242.809766] __schedule+0x2cd/0x890

[ 242.809777] schedule+0x69/0x110

[ 242.809782] notify_param+0x3c/0x57 [test]

[ 242.809784] param_attr_store+0xa0/0x100

[ 242.809815] module_attr_store+0x20/0x40

[ 242.809821] sysfs_kf_write+0x3e/0x50

[ 242.809824] kernfs_fop_write_iter+0x13c/0x1d0

[ 242.809826] new_sync_write+0x117/0x1b0

[ 242.809829] vfs_write+0x189/0x270

[ 242.809830] ksys_write+0x67/0xf0

[ 242.809832] __x64_sys_write+0x1a/0x20

[ 242.809834] do_syscall_64+0x5c/0xc0

[ 242.809836] ? handle_mm_fault+0xd8/0x2c0

[ 242.809839] ? exit_to_user_mode_prepare+0x3d/0x1c0

[ 242.809844] ? do_user_addr_fault+0x1e0/0x660

[ 242.809847] ? irqentry_exit_to_user_mode+0x9/0x20

[ 242.809848] ? irqentry_exit+0x1d/0x30

[ 242.809849] ? exc_page_fault+0x89/0x170

[ 242.809852] entry_SYSCALL_64_after_hwframe+0x61/0xcb

[ 242.809854] RIP: 0033:0x7fe349068077

[ 242.809856] RSP: 002b:00007ffef8d504b8 EFLAGS: 00000246 ORIG_RAX: 0000000000000001

[ 242.809858] RAX: ffffffffffffffda RBX: 0000000000000002 RCX: 00007fe349068077

[ 242.809860] RDX: 0000000000000002 RSI: 000055568ea38090 RDI: 0000000000000001

[ 242.809860] RBP: 000055568ea38090 R08: 000000000000000a R09: 0000000000000001

[ 242.809861] R10: 000055568c9d5017 R11: 0000000000000246 R12: 0000000000000002

[ 242.809862] R13: 00007fe3491476a0 R14: 00007fe3491434a0 R15: 00007fe3491428a0

hung task 检测的入口函数是 hung_task.c 中的 watchdog():

./linux-5.15.50/linux-5.15.50/kernel/hung_task.c

/*

* kthread which checks for tasks stuck in D state

*/

static int watchdog(void *dummy)

{

unsigned long hung_last_checked = jiffies;

set_user_nice(current, 0);

for ( ; ; ) {

unsigned long timeout = sysctl_hung_task_timeout_secs;

unsigned long interval = sysctl_hung_task_check_interval_secs;

long t;

if (interval == 0)

interval = timeout;

interval = min_t(unsigned long, interval, timeout);

t = hung_timeout_jiffies(hung_last_checked, interval);

if (t <= 0) {

if (!atomic_xchg(&reset_hung_task, 0) &&

!hung_detector_suspended)

check_hung_uninterruptible_tasks(timeout);

hung_last_checked = jiffies;

continue;

}

schedule_timeout_interruptible(t);

}

return 0;

}

推荐文章

评论可见,请评论后查看内容,谢谢!!!
 您阅读本篇文章共花了: