父进程、子进程、孤儿进程和僵尸进程
1. 核心概念
- 进程:一个正在执行的程序的实例。它是操作系统进行资源分配和调度的基本单位。
- 父进程:创建了另一个进程的进程。任何一个新进程(除系统启动的第一个进程外)都是由一个已存在的进程创建的。
- 子进程:被创建的那个新进程。
一个生动的比喻:
想象一下进程的家族树。
- init 或 systemd 进程 (PID=1):就像家族的始祖,是所有进程的祖先,由内核在启动时直接创建。
- 父进程:就像是父母。
- 子进程:就像是孩子。
父母可以生下多个孩子(创建多个子进程),孩子长大后也可以成为父母(子进程再创建自己的子进程),从而形成一个庞大的家族树(进程树)。
2. 父子进程的关系
父子进程之间的关系非常特殊,主要体现在以下几个方面:
a) 创建关系
- 子进程由父进程通过
fork()系统调用创建。 fork()的独特之处在于:它只被调用一次,但返回两次。- 在父进程中,
fork()返回新创建子进程的进程ID (PID)(一个大于0的数)。 - 在子进程中,
fork()返回 0。 - 如果创建失败(如系统资源不足),则在父进程中返回 -1。
- 在父进程中,
b) 资源共享与复制
这是理解 fork() 的关键,称为 “写时复制” (Copy-On-Write, COW)。
- 刚
fork()之后:子进程获得父进程的所有资源的副本。这包括:- 代码段(Text segment)
- 数据段(Data segment)、堆(Heap)、栈(Stack)
- 文件描述符表(打开的文件)
- 环境变量、信号处理方式等
- “写时复制”机制:为了提高效率,Linux并不会立即复制全部内存空间。而是先将父进程的内存页标记为“只读”,父子进程共享同一份物理内存。
- 只有当任何一个进程(父或子)试图修改某一块内存时,内核才会为修改者单独复制那一页内存。这样避免了不必要的复制,极大提升了
fork()的效率。
- 只有当任何一个进程(父或子)试图修改某一块内存时,内核才会为修改者单独复制那一页内存。这样避免了不必要的复制,极大提升了
c) 执行与独立性
fork()之后,父子进程并发执行。调度器来决定谁先谁后,这是一个不确定的顺序。- 子进程是一个独立的进程。它有自己独立的进程ID(PID)、有自己的进程控制块(PCB)。子进程的死亡不会直接影响父进程(除非父进程特意等待子进程)。
- 父进程的死亡通常会导致子进程被 init 进程收养,成为孤儿进程。
d) 通信
由于它们是两个独立的进程,拥有不同的地址空间,所以不能直接通过变量共享数据。必须使用进程间通信(IPC) 机制,如管道、消息队列、共享内存等。
好的,这是一个关于操作系统进程管理的核心概念。我们来详细、清晰地解释这四种进程状态。
3. 父进程 (Parent Process)
- 定义:父进程是一个创建了一个或多个新进程的现有进程。
- 工作原理:在 Unix/Linux 系统中,一个进程通过
fork()系统调用来创建一个新进程。调用fork()的进程就是父进程。 - 职责:
- 创建子进程:使用
fork()。 - 管理子进程:父进程通常负责监视和管理其子进程的状态。
- 回收资源:父进程应该使用
wait()或waitpid()系统调用来等待子进程结束,并获取其退出状态,从而彻底释放子进程占用的系统资源。如果父进程没有这么做,就可能导致僵尸进程的产生。
- 创建子进程:使用
- 示例:在终端中运行
bashshell,然后在其中输入ls命令。这个bashshell 进程就是ls进程的父进程。
4. 子进程 (Child Process)
- 定义:由父进程通过
fork()系统调用创建的进程。 - 工作原理:
fork()调用会创建一个与父进程几乎完全相同的副本,包括代码、数据段、堆栈等。子进程从fork()调用之后的位置开始执行。 - 关键特性:
- 独立的进程:虽然它最初是父进程的副本,但它是操作系统调度的一个独立实体,拥有自己唯一的进程ID(PID)。
- 继承资源:子进程会继承父进程的许多属性,如打开的文件描述符、环境变量等。
- 常见的后续操作:创建子进程通常是为了执行一个新的、不同的任务。子进程通常会紧接着调用
exec()系列函数来加载并执行一个新的程序(如ls,grep等),从而替换掉从父进程复制来的原有代码。
父进程与子进程的关系总结:fork() 创建子进程(复制),exec() 加载新程序(替换),wait() 等待回收。
5. 僵尸进程 (Zombie Process / Defunct Process)
- 定义:一个已经终止运行但其父进程尚未对其进行“清理”(调用
wait()读取其退出状态)的子进程。 - 产生原因:
- 子进程先于父进程结束。
- 父进程没有调用
wait()或waitpid()来回收子进程。
- 状态:处于“Z”(Zombie)状态。
- 特点:
- 进程已死:僵尸进程已经不是可执行的进程了,它不占用任何CPU或内存资源。
- 占用PID:它仍然在系统的进程表中占有一个条目(Entry),保留着它的进程ID(PID)和退出状态等信息,等待父进程查询。如果产生大量僵尸进程,会耗尽可用的PID,导致系统无法创建新进程。
- 无法用
kill杀死:因为进程已经终止,kill命令对它无效。
- 清理方法:
- 等待父进程回收:父进程调用
wait()后,僵尸进程会立即消失。 - 杀死父进程:如果父进程异常且不回收子进程,可以终止父进程。这时僵尸进程会变成孤儿进程,并被 init 进程(PID=1)收养,init 进程会定期调用
wait()来清理它们。
- 等待父进程回收:父进程调用
6. 孤儿进程 (Orphan Process)
- 定义:一个仍在运行,但其父进程已经终止或死亡的子进程。
- 产生原因:父进程由于某种原因(如崩溃、被杀死)先于其子进程结束。
- 系统处理:操作系统为了解决孤儿进程问题,设计了一个机制:init 进程(现代Linux中是 systemd,PID=1)会成为所有孤儿进程的新父进程。这个过程被称为“收养”(reparenting)。
- 特点:
- 无害:孤儿进程本身不是一个问题,它只是一个正常运行的进程。
- 由 init 管理:被 init 进程收养后,当孤儿进程结束时,init 进程会自动调用
wait()来回收它,因此孤儿进程不会变成僵尸进程。这是 init 进程的一个重要职责。
7.总结与对比
| 进程类型 | 核心特征 | 对系统的影响 | 如何处理 |
|---|---|---|---|
| 父进程 | 创建了其他进程的进程 | 无负面影响,是进程树的正常部分 | 应负责任地使用 wait() 回收子进程 |
| 子进程 | 被另一个进程创建的进程 | 无负面影响,是执行任务的实体 | 正常执行或调用 exec() |
| 僵尸进程 | 已终止,但未被父进程回收 | 有害:占用系统进程表资源(PID) | 1. 让父进程调用 wait()2. 终止父进程(使其被init回收) |
| 孤儿进程 | 仍在运行,但父进程已死亡 | 无害:系统会自动处理 | 被 init 进程收养,init 会负责其回收 |
8.一个生命周期示例
- 进程A(父进程)调用
fork(),创建进程B(子进程)。 - 进程B 运行。
- 场景一(正常):
- 进程A 调用
wait()等待 进程B。 - 进程B 结束 -> 进程A 读取其状态 -> 进程B 被完全回收,没有僵尸。
- 进程A 调用
- 场景二(僵尸):
- 进程B 先结束,但进程A 忙于其他事情,从未调用
wait()。 - 进程B 进入僵尸(Z)状态,等待父进程回收。
- 进程B 先结束,但进程A 忙于其他事情,从未调用
- 场景三(孤儿):
- 进程A 在进程B 结束前,自己先崩溃或被杀死了。
- 进程B 变成孤儿进程。
- init 进程(PID=1)收养进程B。
- 进程B 继续运行直到结束 -> init 进程自动调用
wait()-> 进程B 被完全回收。
9.如何防止僵尸进程和孤儿进程的产生?
防止僵尸进程和孤儿进程是编写稳健的Linux C/C++程序(尤其是服务器daemon)的必备技能。
首先,我们要明确目标:
- 防止僵尸进程:是必须的。这是程序员的责任,是代码缺陷,会导致资源泄漏。
- 防止孤儿进程:通常不是必须的。孤儿进程是系统正常机制的一部分,系统会自动处理。我们更多是需要正确处理子进程成为孤儿的情况(比如让子进程按预期继续工作)。
核心策略总览
| 进程类型 | 产生原因 | 防止策略 |
|---|---|---|
| 僵尸进程 | 父进程不回收已终止的子进程 | 1. 父进程调用 wait()/waitpid() 2. 捕获 SIGCHLD 信号 3. 忽略 SIGCHLD 信号 4. fork() 两次(双叉技巧) |
| 孤儿进程 | 父进程先于子进程终止 | 1. 父进程等待子进程结束再退出 2. 让子进程主动终止 3. 设计子进程为守护进程(daemon) |
一、如何防止僵尸进程 (4种方法)
僵尸进程产生的根本原因是父进程没有调用 wait()。因此,所有方法都围绕如何让父进程成功回收子进程展开。
方法 1: 主动等待 - wait() / waitpid() (最简单)
在父进程中,明确调用 wait() 阻塞等待子进程结束,或使用 waitpid() 非阻塞地轮询。
适用场景:子进程数量少,且父进程可以阻塞或方便地轮询。
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("Child process (PID: %d) is working...\n", getpid());
sleep(2);
printf("Child process done.\n");
exit(0);
} else {
// 父进程
printf("Parent process (PID: %d) created child %d.\n", getpid(), pid);
int status;
pid_t child_pid = wait(&status); // 阻塞等待,直到一个子进程结束
printf("Parent process: reclaimed child %d, it exited with status %d.\n", child_pid, WEXITSTATUS(status));
}
return 0;
}
方法 2: 捕获 SIGCHLD 信号 (最常用、最有效)
当子进程状态改变(终止、暂停等)时,内核会向父进程发送 SIGCHLD 信号。父进程可以捕获这个信号,并在信号处理函数中调用 waitpid() 来回收子进程。
关键:使用 waitpid(-1, &status, WNOHANG) 和 while 循环,以确保回收所有已终止的子进程,并且是非阻塞的。
适用场景:网络服务器、高并发程序的黄金标准。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <errno.h>
// SIGCHLD 信号处理函数
void sigchld_handler(int sig) {
int saved_errno = errno; // 保存原来的errno,防止被waitpid修改
int status;
pid_t pid;
// 用WNOHANG参数循环回收所有已终止的子进程,避免多个信号被合并处理时漏掉
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
printf("Handler: reclaimed child %d, status %d\n", pid, WEXITSTATUS(status));
}
errno = saved_errno;
}
int main() {
// 设置信号处理函数
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; // SA_RESTART:重启被信号中断的系统调用
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
exit(1);
}
// 创建多个子进程
for (int i = 0; i < 5; i++) {
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("Child %d (PID: %d) started.\n", i, getpid());
sleep(i + 1); // 每个子进程睡眠不同时间
printf("Child %d (PID: %d) exiting.\n", i, getpid());
exit(0);
}
}
// 父进程继续做自己的事,而不是阻塞等待
printf("Parent (PID: %d) is doing its own work...\n", getpid());
while(1) {
pause(); // 或者做其他工作,信号处理是异步的
}
return 0;
}
方法 3: 忽略 SIGCHLD 信号 (最省事)
直接告诉内核:“我不关心子进程的退出状态,请直接帮我回收它。” 通过将 SIGCHLD 的处理方式设置为 SIG_IGN 来实现。
注意:虽然省事,但你也永远无法知道子进程的退出状态码了。
// ... 在父进程的main函数开头添加 ...
int main() {
// 忽略SIGCHLD信号,内核会自动回收子进程,不会产生僵尸
signal(SIGCHLD, SIG_IGN);
pid_t pid = fork();
if (pid == 0) {
// 子进程
exit(0);
} else {
// 父进程
sleep(10); // 在此期间用ps查看,不会有僵尸进程
}
return 0;
}
方法 4: fork() 两次 (双叉技巧)
让子进程再fork一个孙进程,然后子进程立刻退出。这样孙进程就变成了孤儿进程,由init进程自动回收,从而免去了原始父进程等待的麻烦。
适用场景:比较古老的技巧,现在有了 SIG_IGN,此法已不常用。
pid_t pid = fork();
if (pid == 0) { // 第一子进程
pid_t grand_pid = fork();
if (grand_pid == 0) { // 孙进程(实际的工作进程)
// 实际的工作代码在这里
sleep(10);
} else {
exit(0); // 第一子进程立即退出,孙进程被init收养
}
} else {
waitpid(pid, NULL, 0); // 父进程等待第一子进程结束,回收它(它很快结束,不会僵死)
// 孙进程由init回收
}
二、如何防止孤儿进程 (或者说,如何正确管理)
防止孤儿进程通常不是为了避免危害,而是为了满足程序逻辑。
策略 1: 父进程等待所有子进程结束再退出
这是最直接的方法。确保父进程在自身的退出逻辑中,先等待所有子进程结束。
// 父进程代码逻辑
// ...
// 创建了多个子进程后...
// 通知所有子进程优雅退出(例如通过管道、信号等)
// ...
// 然后等待所有子进程
int status;
pid_t pid;
while ((pid = wait(&status)) > 0) { // 循环wait,直到没有子进程
printf("Reclaimed child %d\n", pid);
}
printf("All children exited. Parent can exit now.\n");
exit(0);
策略 2: 让子进程成为守护进程 (Daemon)
如果设计意图就是让子进程脱离父进程独立、长期运行(如各种系统服务nginx、mysqld),那么就应该按照守护进程的标准步骤来编写子进程代码,使其能正确地在成为“孤儿”后继续运行。
守护进程步骤包括:fork()后退出父进程、调用 setsid() 创建新会话、改变工作目录、重设文件权限掩码、关闭/重定向文件描述符等。
总结与建议
| 场景 | 推荐策略 |
|---|---|
| 通用多进程程序 | 捕获 SIGCHLD 信号,在处理函数中使用 waitpid(-1, ..., WNOHANG)。这是最稳健、最通用的方法。 |
| 简单的父子进程 | 父进程直接调用 wait() 或 waitpid()。 |
| 不关心子进程状态 | 直接 signal(SIGCHLD, SIG_IGN)。简单粗暴有效。 |
| 需要子进程独立运行 | 将子进程设计为守护进程,并确保父进程能正常退出。 |
| 需要控制子进程生命周期 | 父进程在退出前,通过信号等方式通知子进程,并等待它们结束。 |
最佳实践:在大多数网络服务和并发程序中,方法2(捕获SIGCHLD信号) 是事实上的标准,因为它既不影响父进程的工作,又能及时、可靠地回收所有子进程资源,彻底避免僵尸进程的产生。
882

被折叠的 条评论
为什么被折叠?



