孤儿进程、僵尸进程和守护进程是 Linux 进程中的三种特殊进程,也是 Linux 常见的面试题,这里整理一下这三种进程的概念和区别。

1. Linux 进程生命周期

Linux 进程的生命周期主要包括五个状态,分别是 创建(Create)就绪(Ready)运行(Running)阻塞(Blocked)终止(Terminate)。每个阶段都有对应的状态和系统调用来管理资源、调度和终止。

1.1. 进程的创建

Linux 进程的创建通常采用 fork-exec 模型,这也是 Unix/Linux 系统中创建新进程并执行新程序的标准方法。它将进程的创建与程序的加载分为两个独立的步骤,从而既保证了灵活性,也便于资源管理。

顾名思义,进程创建分为 fork(或其变体,如 vfork()clone())和 exec(或其变体,如 execl()execv() )两个步骤,其中 fork 用于创建新进程,exec 用于加载新程序。详细来说:

  • fork 系统调用:当父进程需要创建一个新的子进程时,它调用 fork 系统调用,内核会复制父进程的内存映像、文件描述符、信号处理等信息,然后将这些信息复制到子进程中,最后将子进程加入到调度队列中,等待调度执行。但是新的子进程拥有自己的进程 ID(PID),并且父进程和子进程的 PID 不同,这样就可以区分父进程和子进程。另外,Linux 使用写时复制(Copy-On-Write)机制,子进程初期与父进程共享内存,只有当父进程或子进程需要修改内存时,才会真正复制数据,这样可以减少内存的浪费。

  • exec 系统调用:在多数情况下,子进程创建后不会直接运行父进程的代码,而是调用 exec() 来载入一个新的程序映像,替换掉原来的代码段、数据段和堆栈,但不修改进程 ID。当子进程调用 exec 系统调用时,内核会将子进程的内存映像替换为新程序的内存映像,然后开始执行新程序。这样就实现了进程的创建和程序的加载。

下面是一个使用 fork-exec 模型创建子进程并加载新程序(这里以执行 “ls -l” 命令为例)的完整 C 语言示例程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
pid_t pid = fork();

if (pid < 0) {
// fork 出错
perror("fork failed");
exit(EXIT_FAILURE);
} else if (pid == 0) {
// 子进程:调用 execv 加载新程序
// 准备参数数组,注意以 NULL 结尾
char *args[] = {"ls", "-l", NULL};
// 使用 execv 加载 /bin/ls 程序
execv("/bin/ls", args);
// 如果 execv 成功,下面的代码不会执行
// 如果 execv 失败,则输出错误信息并退出
perror("execv failed");
exit(EXIT_FAILURE);
} else {
// 父进程:等待子进程结束
int status;
if (waitpid(pid, &status, 0) < 0) {
perror("waitpid failed");
exit(EXIT_FAILURE);
}
if (WIFEXITED(status)) {
printf("Child process exited with status %d\n", WEXITSTATUS(status));
} else {
printf("Child process terminated abnormally\n");
}
}
return 0;
}

上面的程序中,父进程调用 fork 创建子进程,然后父进程等待子进程结束,子进程调用 execv 加载新程序 /bin/ls,并执行 ls -l 命令。这样就实现了进程的创建和程序的加载。并且这里使用了 waitpid 函数来等待子进程结束,以避免子进程成为僵尸进程。

fork 的变体

  1. vfork
    目的与特点: vfork 主要用于当子进程在创建后马上调用 exec(或直接 exit)来载入新程序时,避免不必要的内存复制开销。与 fork 不同,vfork 不会立即复制父进程的整个地址空间,而是让父子进程共享同一地址空间。在 vfork 调用后,父进程会被挂起,直到子进程调用 exec()exit() 后,父进程才恢复执行。这种方式降低了内存复制和资源分配的开销,但要求子进程在调用 execexit 之前不要对共享数据进行修改,否则可能影响父进程的状态。
    使用场景: 当确定子进程创建后马上需要替换为新程序时,vfork 是一个高效的选择。它适合那些仅用于加载新程序的场景。

  2. clone
    目的与特点: cloneLinux 特有的系统调用,它提供了比 fork 更加灵活的进程(或线程)创建方式。通过传递一组标志参数,clone 可以精细地控制父进程和子进程之间哪些资源是共享的,哪些是独立的。

例如:使用 CLONE_VM 标志时,父子进程共享同一虚拟内存空间,从而实现轻量级线程(即线程间共享内存)。如果不设置该标志,则子进程拥有独立的内存空间,类似于传统的 fork
优点:clone 能够根据需要实现线程和进程不同的行为,提供了更高的灵活性,因此是 Linux 内核实现多线程(例如 pthread 库)的基础。
使用场景: 当需要创建线程、共享资源或精细控制进程间关系时,使用 clone 可以根据需求设置共享的标志,既可以实现传统进程的行为,也可以创建轻量级线程。

exec 的变体
exec 的变体有 execlexecv,主要用于将当前进程的映像替换为另一个程序,exec 系列函数在传参方式上有所不同:

  1. execl
    特点:execl 使用可变参数列表(variadic arguments)来传递命令行参数,参数列表以 NULL 结束。
    int execl(const char *path, const char *arg, ..., NULL);
    使用方式:当参数个数较少且已知时,可以直接将各个参数作为参数列表传入 execl
    例如:execl("/bin/ls", "ls", "-l", NULL);
    优点:语法直观,适合参数固定、数量较少的情况。
  2. execv
    特点:execv 接受一个字符串数组(argv 数组)作为参数,这个数组以 NULL 结尾。
    int execv(const char *path, char *const argv[]);
    使用方式:当参数数量不确定或已存放在数组中时,execv 更为方便。
    例如:
    char *args[] = {"ls", "-l", NULL};
    execv("/bin/ls", args);
    优点:参数可以动态构造或存储在数组中,适合参数数量可能变化的情况。

1.2. 进程的运行与调度

新创建(或被唤醒)的进程首先进入就绪队列,等待 CPU 分配时间片。调度器根据进程优先级和调度算法(如 CFS、轮转、实时调度等)选择一个进程进入运行状态开始执行。
在运行过程中,进程可能会被抢占(例如因为时间片耗尽或有更高优先级的进程进入就绪队列),此时它会重新返回就绪状态。

除了“就绪(Ready)”和“运行(Running)”外,进程还可能处于以下状态:

  • 可中断睡眠(Interruptible Sleep):进程在等待某些事件(如 I/O 操作、信号等)时会进入这种状态,此时它可以被信号中断而唤醒。
  • 不可中断睡眠(Uninterruptible Sleep):通常在等待硬件响应等关键操作时进入,此状态下进程不会响应信号(例如磁盘 I/O)。
  • 停止(Stopped):进程被暂停(例如通过 SIGSTOPCtrl+Z),暂时不参与调度,等待用户或信号通知恢复。
  • 僵尸(Zombie):进程已经执行完毕(调用了 exit()),但父进程尚未调用 wait()/waitpid() 收集其退出状态,此时进程的 PCB(进程控制块)仍保留在系统中。

1.3. 阻塞与唤醒

当进程需要等待外部事件(如等待 I/O 数据、等待信号、等待资源释放等),它会进入阻塞(睡眠)状态。一旦等待条件满足(例如 I/O 完成或信号到来),内核会将进程从阻塞状态唤醒,并放回就绪队列中等待调度。

1.4. 进程的终止

当进程完成任务或遇到错误时,会调用 exit() 系统调用终止自身。这时,进程会释放大部分资源,但其退出状态(返回值)会保留在内核中,直到其父进程调用 wait()waitpid() 将其收回。如果父进程不及时收回,其子进程就会长时间处于僵尸状态,占用进程表的条目。

2. 僵尸进程

僵尸进程是指子进程终止后,其 PCB 仍保留,等待父进程回收,但父进程尚未调用 wait()waitpid() 系统调用来获取子进程的终止状态,导致子进程的进程描述符一只存在内核中,这时候的这个子进程就叫僵尸进程。而如果大量父进程忽略或未处理 SIGCHLD 信号,僵尸进程可能会积累,最终影响系统运行性能。

Linux 进程的状态中,僵尸进程已经释放了几乎所有内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位置,记载该进程的退出状态等信息供父进程收集。它需要父进程来为它“收尸”,如果他的父进程没安装 SIGCHLD 信号处理函数调用 wait()waitpid() 等待子进程结束,又没有显式忽略该信号,那么它就一直保持僵尸状态。

当然如果这时父进程结束了,那么 init 进程自动会接手这个子进程,为它收尸,它还是能被清除的。但是如果父进程是一个循环,不会结束,那么子进程就会一直保持僵尸状态。而系统所能使用的进程号是有限的(在标准Linux内核中一般进程数为32768个),如果僵尸进程过多,将会耗尽系统的进程号,导致系统不能产生新的进程。此即为僵尸进程的危害,应当避免。

2.1. 僵尸进程是每个进程都会有的吗?

是的,每个进程都会有僵尸进程,只是时间长短不同。当一个进程结束时,内核会保留其 PCB 直到父进程调用 wait()waitpid() 来回收其资源,这个过程中进程处于僵尸状态。如果父进程不及时回收,僵尸进程会一直保留在系统中,直到父进程结束或回收。

2.2. 如何查看僵尸进程?

运行 ps -el 命令,其中,有标记为 Z 的进程就是僵尸进程 S 代表休眠状态;D 代表不可中断的休眠状态;R 代表运行状态;Z 代表僵死状态;T 代表停止或跟踪状态。

1
ps -el | grep Z

2.3. 如何避免僵尸进程?

fork() / execve() 过程中,假设子进程结束时父进程仍存在,而父进程 fork() 之前既没安装 SIGCHLD 信号处理函数调用 waitpid() 等待子进程结束,又没有显式忽略该信号,则子进程成为僵尸进程,无法正常结束,此时即使是 root 身份去执行 kill -9 也不能杀死僵尸进程。因为僵尸进程已经结束,只是父进程没有回收,所以 kill -9 也无法杀死僵尸进程。所以僵尸进程的危害较大,关于如何避免僵尸进程,可以通过以下几种方式:

  • 父进程调用 wait()waitpid() 等待子进程结束,回收其资源。
  • 父进程忽略 SIGCHLD 信号,由 init 进程接管子进程。
  • 父进程在 fork 之前设置 SIGCHLD 信号处理函数,处理子进程的退出状态。

至于父进程退出而由 init() 接管子进程的情况,则是下面要介绍的孤儿进程。

3. 孤儿进程

孤儿进程是指如果父进程先于子进程结束,子进程会被 init(PID 1) 进程收养,从而保证其后续能够正常结束并由 init 回收资源。init 进程会完成所有孤儿进程的状态收集工作。即每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为 init,而 init 进程会循环 wait() 所有托管给它的、已经退出的子进程。这样,当一个孤儿进程结束其生命周期时,init 进程就会处理它的一切善后工作。因此孤儿进程并不会有什么危害。唯一的问题就是可能无法捕捉到子进程的退出状态,因为子进程的退出状态是由 init 进程回收的。

4. 守护进程

守护进程(daemon) 是指在后台运行,没有控制终端与之相连的进程。它独立于控制终端,通常周期性地执行某种任务。守护进程脱离于终端是为了避免进程在执行过程中的信息在任何终端上显示,并且进程也不会被任何终端所产生的终端信息所打断,更不会因为终端的退出而退出。