Skip to content

僵尸进程是什么

程序的状态

为了方便管理程序,操作系统为程序设置了不同的状态。这些状态代表了程序的运行情况。在Linux中,用户态程序有R S D T t Z X七种状态。每个状态代表的意义如下。

字符含义
R正在运行,程序当前正在占用CPU。在topps中,该进程自己就是本状态。
S可中断的睡眠,程序正在等待某些事件发生,如流量到达、锁状态变更、sleep结束等。
这种状态可以被信号中断,进而从睡眠中恢复运行或结束程序。
D不可中断的睡眠,通常是正在等待磁盘IO,任何信号(包括kill -9)都无法让该线程恢复,只能等待预期的事件到来。
T中止(stopped),行为上像是是暂停。程序没有结束,包括内存在内的所有资源均未释放,但操作系统不会再为线程分配CPU时间,直到用户手动恢复。此时常规的信号都会被挂起(pending),直到程序恢复运行。但可以被kill -9杀死,也可以被SIGCONT (kill -17)恢复运行。该状态作用于线程组(一个进程的所有线程),所有线程都会同时进入或退出T状态。
t调试导致的中止。类似于T,但这种中止是由调试程序(如GDB)引发的。包括kill -9在内的信号都会被挂起,直到调试程序恢复其运行。和T一样,该状态也作用于线程组。
Z僵尸进程,程序已经结束,其持有的资源也已经释放,但由于父进程编码错误,导致其占用的进程ID无法被回收。今天本文的重点。
X已死亡,进程已经结束,但可能PID暂未回收,通常不应该看到该状态。

为什么会产生僵尸进程

当我们在ps -auxtop中看到了Z状态的进程,就代表这个进程是一个僵尸进程。与影视作品中经典的僵尸形象类似,僵尸进程本质上是已经死亡(退出)的进程。它们可以是正常退出,也以是因为异常而退出,进程的退出方式并不是其成为僵尸进程的原因,成为僵尸进程的唯一原因就是父进程没有正确处理它们的退出状态

在Linux操作系统中,一个进程可以通过fork()创建子进程,然后使用wait()waitpid()等待子进程执行结束。

每个正在运行的进程都有对应的进程控制块(PCB),若父进程正确等待子进程运行结束,那么子进程结束后,其PCB也会被系统回收。

然而,如果一直到子进程运行结束,父进程也依然没有等待子进程,就会导致子进程变为僵尸状态。由于子进程的任务已经完成,所以内存、文件等绝大部分资源都被系统回收,但由于操作系统不知道父进程何时会尝试通过waitwaitpid收集子进程的退出状态,所以子进程的PID和返回值依然被操作系统保留,直到父进程收集退出状态。

在Unix设计中,系统资源通常以int类型来表示,例如文件描述符是整型,信号量、共享内存ID、进程PID等也是整型。程序就是通过这些整型值来与操作系统进行交互。但PID与文件描述符不同,是全系统共享的,这就导致操作系统不能像保留被删除的文件一样,仅为父进程保留PID,操作系统必须全局保留该PID。

与之相对的,Windows并不使用这套设计规范。Windows中使用句柄(handle)来管理子进程。当子进程退出时,虽然其返回值仍会占用一定系统资源,但PID会被释放,以供其他程序复用。父进程需要通过句柄而非PID来获取子进程的退出状态。所以Windows上是不存在僵尸进程的。

如何处理僵尸进程

  1. 只需要杀死它们的父进程,就可以让这些僵尸进程被1号进程init接管。init会自动收集所有子进程的退出状态。
  2. 若父进程很重要,不能立刻杀死,但又迫切希望解决问题,可以通过gdb等手段在父进程内手动等待这些僵尸进程。
  3. 重启可以解决99%的问题,而僵尸进程就在这99%的范围内。

如何预防僵尸进程产生

比起亡羊补牢,我们当然更希望从一开始就避免僵尸进程的产生。下面介绍几种避免产生僵尸进程的方法。

总是使用waitpid等待子进程结束

这是最正常也是最普遍的方式了。大部分语言都提供了对子进程的进一步包装,可以让使用者直接调用p.wait()来等待子进程结束。

在C语言中,可以使用waitpid来阻塞地等待子进程结束。

C
int pid = fork();
if (pid == 0) {
    execve(...);
    _exit(1);
}
int status;
(void)waitpid(pid, &status, 0);

显式告知操作系统不会等待子进程退出

如果只想执行程序,但不关心程序的退出情况,那么可以通过信号动作来显式通知操作系统父进程不会等待子进程退出,这时即使父进程不等待,内核也会忽略子进程的退出状态,不会产生僵尸进程。

但这种方式会影响所有该进程产生的子进程,一旦子进程在wait前退出,父进程就无法拿到子进程的退出状态,除非你完全了解程序会启动哪些子进程,否则这可能会造成未知的影响。

使用SIG_IGN忽略

使用SIG_IGN是告知操作系统不等待子进程退出的常见方式,但该方式在小部分内核下可能不起作用。

C
(void)signal(SIGCHLD, SIG_IGN);

这种方式会让程序无法收到SIGCHLD信号,也无法自定义处理。

使用SA_NOCLDWAIT忽略

更现代一些的方式是使用SA_NOCLDWAIT告知操作系统不等待子进程退出,需要使用更现代的sigaction系统调用。

C
struct sigaction act = {
    .sa_handler = SIG_DFL,
    .sa_flags = SA_NOCLDWAIT,
};
(void)sigaction(SIGCHLD, &act, NULL);

这种方式程序仍然可以接收SIGCHLD信号,或者自定义处理程序,但是内核不会为程序保留已退出的子进程状态。

同时这种方式语义明确,不会出现不起作用的情况。

等待与SIGCHLD的关系

在Linux系统中,当子进程结束时,内核会向其父进程发送SIGCHLD信号。父进程可以通过注册SIGCHLD信号处理函数来及时得知子进程的退出,从而调用wait()系列函数进行处理。

网络上流传着一种说法,认为僵尸进程是没有正确处理SIGCHLD信号导致的,这是典型的错误说法。事实上,无论父进程是否处理SIGCHLD,都不会影响僵尸进程的产生。即便父进程没有注册SIGCHLD信号处理函数,甚至干脆屏蔽该信号,也可以通过wait()系列函数正确回收子进程。相反,即使父进程处理了SIGCHLD信号,却不调用wait(),子进程依然会成为僵尸进程。SIGCHLD信号只是一种异步通知机制。想通过信号处理避免僵尸进程,必须要通过上文的SIG_IGNSA_NOCLDWAIT来通知内核忽略。

一次SIGCHLD不代表只有一个子进程退出

SIGCHLD是普通信号,并非实时信号。若多个子进程“几乎同时”退出,父进程有可能只收到一个SIGCHLD信号。所以若只在信号处理中等待子进程退出,那么一定要循环等待,直到没有子进程等待,才能彻底避免僵尸进程的产生。

下面的函数示例来自这个网页

c
// from: <https://man7.org/tlpi/code/online/dist/procexec/multi_SIGCHLD.c.html>

static volatile int numLiveChildren = 0;
                /* Number of children started but not yet waited on */
static void
sigchldHandler(int sig)
{
    int status, savedErrno;
    pid_t childPid;

    /* UNSAFE: This handler uses non-async-signal-safe functions
       (printf(), printWaitStatus(), currTime(); see Section 21.1.2) */

    savedErrno = errno;         /* In case we modify 'errno' */

    printf("%s handler: Caught SIGCHLD\\n", currTime("%T"));

    /* Do nonblocking waits until no more dead children are found */

    while ((childPid = waitpid(-1, &status, WNOHANG)) > 0) {
        printf("%s handler: Reaped child %ld - ", currTime("%T"),
                (long) childPid);
        printWaitStatus(NULL, status);
        numLiveChildren--;
    }

    if (childPid == -1 && errno != ECHILD)
        errMsg("waitpid");

    sleep(5);           /* Artificially lengthen execution of handler */
    printf("%s handler: returning\\n", currTime("%T"));

    errno = savedErrno;
}

现代编程语言实践

脱离了C语言的环境,在现代语言中处理子进程问题则简单得多。语言存在多种机制可以让你无需担心忘记等待,记得不要裸调用fork()即可。

python
with subprocess.Popen(...) as proc:
    ...
# 此处会自动等待该进程,无需担心
go
func RunSubprocess() {
    cmd := exec.Command{...}
    // 函数本身就直接等待其运行完成
    cmd.Run()
    // 函数本身不等待,但是可以手动调用Wait
    cmd.Start()
    defer cmd.Wait()
}

最后更新: