1. 前言
限于作者能力水平,本文可能存在谬误,因此而给读者带来的损失,作者不做任何承诺。
2. 案例
原来的故事是 这样 的,感兴趣的读者可以直接前往。我截取了一段重现故事中问题的代码(对原代码做过小小调整):
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
#define SLEEP_SCRIPT_PATH "/home/bill/Study/qemu-lab/app/issue/1/sleep.sh&"
int main(void)
{
int pid;
if ((pid = fork()) == 0) {
printf("children: %d\n", getpid());
/* /bin/bash -c /home/bill/Study/qemu-lab/app/issue/1/sleep.sh& */
execle("/bin/bash", "/bin/bash",
"-c", SLEEP_SCRIPT_PATH, (char *)0, NULL);
}
printf("parent: %d\n", getpid());
//printf("waitfing for children... ");
//wait(NULL);
//printf("done.\n");
while (1)
sleep(1);
return 0;
}
sleep.sh
的内容如下:
#!/bin/bash
sleep 3
编译并运行:
$ make zombie_issue
$ strace -f -t -e execve ./zombie_issue
16:28:33 execve("./zombie_issue", ["./zombie_issue"], [/* 69 vars */]) = 0
parent: 11128
strace: Process 11129 attached
children: 11129
[pid 11129] 16:28:33 execve("/bin/bash", ["/bin/bash", "-c", "/home/bill/Study/qemu-lab/app/is"...], NULL) = 0
strace: Process 11130 attached
[pid 11130] 16:28:33 execve("/home/bill/Study/qemu-lab/app/issue/1/sleep.sh", ["/home/bill/Study/qemu-lab/app/is"...], [/* 3 vars */] <unfinished ...>
[pid 11129] 16:28:33 +++ exited with 0 +++
[pid 11128] 16:28:33 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=11129, si_uid=1000, si_status=0, si_utime=0, si_stime=0} ---
[pid 11130] 16:28:33 <... execve resumed> ) = 0
strace: Process 11131 attached
[pid 11131] 16:28:33 execve("/bin/sleep", ["sleep", "3"], [/* 3 vars */]) = 0
[pid 11131] 16:28:36 --- SIGWINCH {si_signo=SIGWINCH, si_code=SI_KERNEL} ---
[pid 11128] 16:28:36 --- SIGWINCH {si_signo=SIGWINCH, si_code=SI_KERNEL} ---
[pid 11130] 16:28:36 --- SIGWINCH {si_signo=SIGWINCH, si_code=SI_KERNEL} ---
[pid 11131] 16:28:36 +++ exited with 0 +++
[pid 11130] 16:28:36 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=11131, si_uid=1000, si_status=0, si_utime=0, si_stime=0} ---
[pid 11130] 16:28:36 +++ exited with 0 +++
16:28:37 --- SIGWINCH {si_signo=SIGWINCH, si_code=SI_KERNEL} ---
$ ps -ef -o pid,ppid,comm
PID PPID COMMAND
9539 2774 bash
11133 9539 \_ ps
9439 2774 bash
11126 9439 \_ strace
11128 11126 \_ zombie_issue
11129 11128 \_ bash <defunct>
看看,进程 11129
进程变僵尸了:<defunct>
标注表示进程变僵尸了。用 top
可以观察到变 Z
了:
top - 16:51:36 up 5:39, 1 user, load average: 0.09, 0.04, 0.01
Tasks: 1 total, 0 running, 0 sleeping, 0 stopped, 1 zombie
%Cpu(s): 0.5 us, 2.1 sy, 0.0 ni, 97.4 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 4015908 total, 844272 free, 928832 used, 2242804 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 2735724 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
11129 bill 20 0 0 0 0 Z 0.0 0.0 0:00.02 bash
开始分析问题之前,我们先来了解 bash
是怎么处理 & 操作符
的:
If a command is terminated by the control operator &, the shell executes the
command in the background in a subshell. The shell does not wait for the command
to finish, and the return status is 0.
上面是摘自 bash手册 原文,翻译下它的意思:从 bash
启动的命令,如果尾接 & 操作符
,则 bash
启动 子 shell
来运行命令,而 bash
本身不等待(即不对命令程序发起 wait()
调用)命令的结束,直接以退出码 0 退出。
我们再来简单了解下,什么样的进程会变成 僵尸进程
:一个进程退出了,其存活的父进程
又不对其进行回收(没有对进程发起 wait()
调用),则该进程就会变成 僵尸进程
。
有了上述对 bash & 操作符
和 僵尸进程
的基础知识,我们就可以来理一理为什么会出现僵尸进程了。我们不关注用来调试的 strace
进程,直接从 zombie_issue
说起。结合 strace
的追踪记录,以及程序 zombie_issue
的输出信息,我们按 进程 PID
来小结一下出现的几个进程:
11128: zombie_issue 进程
11129: zombie_issue 进程 fork 的子进程,用来启动程序 /bin/bash
11130: /bin/bash 的子shell,用来启动脚本 sleep.sh
11131: 运行脚本 sleep.sh 中 sleep 3 语句的进程
上面说了,进程变僵尸,是因为无人对它进行回收。我们一步步来看,为什么 进程 11129
最后变成了僵尸:
1. 脚本 sleep.sh 中执行 sleep 3 语句的进程 11131 运行完成后,
子shell进程 11130 对其进行了回收,所以它不会变僵尸;
2. 子 shell 进程 11130 等到执行 sleep 3 语句的进程 11131 退出后,它自己也退出了。
此时因为启动它的父进程程序 /bin/bash 已经退出了,它变成了无人理的孤儿,那么谁来
回收它呢?针对这种父进程比子进程先结束的情形,Linux内核会将子进程托孤给 始祖进程
init,由 init进程 负责完成子进程的回收。于是,我们的孤儿进程 11130 也被回收了,
所以它不会变僵尸;
3. 而启动程序 /bin/bash 的进程 11129 ,自从它退出后,父程序 zombie_issue 进程 11128
对它不理睬,任其曝尸荒野,何其惨也,但由于父进程 zombie_issue 又没有退出,Linux内核
也不会将其托孤给 init 进程,所以只能变僵尸了。
通过上面的分析,我们知道了 进程 11129
为什么变僵尸的原因。
我们再来看一下,在父进程比子进程先退出的情况,Linux内核将子进程托孤给始祖进程 init
的实现细节。进程退出都会经过 exit()
调用,而 exit()
调用系统调用 sys_exit_group()
:
sys_exit_group(error_code)
do_group_exit((error_code & 0xff) << 8)
...
do_exit(exit_code)
...
exit_signals()
...
tsk->flags |= PF_EXITING; /* 标记进程正在退出 */
...
tsk->exit_code = code; /* 设置进程退出码 */
...
exit_notify(tsk, group_dead)
/* 将 @tsk 的子进程托孤给寻得的新父 */
forget_original_parent(tsk, &dead)
reaper = find_child_reaper(father, dead);
struct task_struct *reaper = pid_ns->child_reaper; /* pid_ns == init_pid_ns */
...
if (likely(reaper != father))
return reaper; /* 在我们的场景下, reaper == &init_task */
...
...
/* 将即将消亡进程 @father 的所有子进程重置新父为 @reaper */
list_for_each_entry(p, &father->children, sibling) {
/* 将 @father 当前子进程 @p 所在线程组的所有进程的新父重置为 @reaper */
for_each_thread(p, t) {
t->real_parent = reaper; /* 进程 @t 的新父重置为 @reaper */
if (likely(!t->ptrace))
t->parent = t->real_parent;
...
}
...
}
/* 将即将消亡进程 @father 的所有子孙进程树移交给新父 @reaper */
list_splice_tail_init(&father->children, &reaper->children);
...
...
/*
* 设置进程状态为最终态: task_struct::state = TASK_DEAD ,
* 进程彻底终结放弃 CPU 。
*/
do_task_dead();
从上面的代码分析,我们了解到另一个事实,即使父进程没对子进程调用 wait()
,但如果父进程退出,它的子进程仍然会被回收不会变僵尸,这是内核自动完成的。也就是说,如果去掉代码里的如下片段:
while (1)
sleep(1);
子进程 11129
会被内核托孤回收。
前面的测试代码单独拿出来,就是一个编程 BUG:存活的父进程
理应对子进程发起 wait()
。如果放开对代码中的 wait()
调用的注释,就不会出现僵尸进程。
这其实是一个简单的问题,但放在复杂的环境下,我们确实可能犯这样的错误。其实仅仅是要模拟出现僵尸进程的情形,上面的测试代码还可以简化:
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
int pid;
if ((pid = fork()) == 0) {
execle("/bin/bash", "/bin/bash",
"-c", "/bin/ls", (char *)0, NULL);
}
//wait(NULL);
while (1)
sleep(1);
return 0;
}