文章目录
- 父子进程与信号
-
- 子进程退出与僵尸进程
- 父进程等待子进程的两种方式
-
- [利用 SIGCHLD进行被动回收](#利用 SIGCHLD进行被动回收)
-
- [利用 `SIGCHLD` 进行被动回收的意义](#利用
SIGCHLD进行被动回收的意义) - [为什么在 `handler` 中不能只调用一次 `waitpid`](#为什么在
handler中不能只调用一次waitpid) -
- [循环调用 `waitpid`](#循环调用
waitpid) - 为什么要循环到失败或无可回收对象
- [为什么在 `SIGCHLD` 处理函数中必须使用 `WNOHANG`](#为什么在
SIGCHLD处理函数中必须使用WNOHANG)
- [循环调用 `waitpid`](#循环调用
- [利用 `SIGCHLD` 进行被动回收的意义](#利用
- [不显式回收的另一种方式:忽略 SIGCHLD](#不显式回收的另一种方式:忽略 SIGCHLD)
-
- [`SIGCHLD` 的默认忽略与显式忽略并不等价](#
SIGCHLD的默认忽略与显式忽略并不等价) -
- [`SIGCHLD` 的默认处置:`SIG_DFL`](#
SIGCHLD的默认处置:SIG_DFL)
- [`SIGCHLD` 的默认处置:`SIG_DFL`](#
- [显式设置为 `SIG_IGN`](#显式设置为
SIG_IGN) - [两者看起来都叫 ignore,但效果不同的原因](#两者看起来都叫 ignore,但效果不同的原因)
- [`SIGCHLD` 的默认忽略与显式忽略并不等价](#
父子进程与信号
子进程退出与僵尸进程
当一个子进程终止时,它不会立即从系统中彻底消失,而是会先进入僵尸状态(zombie state) 。处于僵尸状态的子进程仍然保留最基本的进程控制块信息,以便其父进程后续通过 wait 或 waitpid 读取该子进程的退出状态、终止信号等信息。只有在父进程完成回收之后,内核才会真正释放该子进程的相关资源。
父进程在等待子进程时,可以采用阻塞式等待 ,也可以采用非阻塞式等待 。如果目标子进程尚未退出,那么阻塞式等待会使父进程挂起,直到子进程状态发生变化;而非阻塞式等待则会立即返回,由父进程自行决定后续处理逻辑。当子进程终止后,wait 或 waitpid 才会成功返回,并向父进程提供该子进程的退出码或终止原因。
需要补充的一个重要事实是:**子进程终止时并不是静默进入僵尸状态的。**在子进程结束运行时,内核会主动向其父进程发送一个 SIGCHLD 信号,用于通知父进程"某个子进程发生了状态变化",最常见的情况就是子进程退出。也就是说,父进程不仅可以主动通过 wait / waitpid 轮询子进程状态,还可以被动地通过捕捉 SIGCHLD 感知子进程已经终止。
当子进程终止时,内核会先将其置为僵尸状态,并同时向父进程发送 SIGCHLD 信号,以通知父进程对子进程进行状态读取和资源回收。
SIGCHLD 在很多 Linux 环境中的信号编号通常是 17,但编号依赖具体平台,不应在程序设计中直接写死,通常应使用符号常量 SIGCHLD。另外,信号表中常将其默认处置显示为 Ign,但这并不等价于"父进程自动完成了对子进程的回收",两者在语义上需要区分。
子进程退出时,内核不仅会保留其退出信息使其暂时处于僵尸状态,还会通过向父进程发送 SIGCHLD 的方式通知父进程及时完成回收。
父进程等待子进程的两种方式
利用 SIGCHLD进行被动回收
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("pid: %d, %d 号信号,正在被捕捉!\n", getpid(), signo);
}
void Count(int cnt)
{
while (cnt)
{
printf("cnt: %2d\r", cnt);
fflush(stdout);
cnt--;
sleep(1);
}
printf("\n");
}
int main()
{
signal(SIGCHLD, handler);
printf("我是父进程, %d, ppid: %d\n", getpid(), getppid());
pid_t id = fork();
if (id == 0)
{
printf("我是子进程, %d, ppid: %d,我要退出啦\n", getpid(), getppid());
Count(5);
exit(1);
}
while (1)
sleep(1);
// signal(2, handler);
// while(!quit);
// printf("注意, 我是正常退出的!\n");
return 0;
}
该程序的目的是验证这样一个结论:当子进程终止时,内核会向其父进程发送 SIGCHLD 信号。
先看主流程。在 main.c 里,父进程一开始执行:
c
signal(SIGCHLD, handler);
这表示:父进程把 SIGCHLD 的处理方式设置为 handler。也就是说,只要父进程后面收到了 SIGCHLD,就会进入这个函数:
c
void handler(int signo)
{
printf("pid: %d, %d 号信号,正在被捕捉!\n", getpid(), signo);
}
然后程序调用 fork()。fork() 之后会产生两个执行分支:
id == 0:当前是子进程id > 0:当前是父进程
子进程会执行这段代码:
c
printf("我是子进程, %d, ppid: %d,我要退出啦\n", getpid(), getppid());
Count(5);
exit(1);
它的意思是:子进程先打印自己的 PID 和父进程 PID,再存活 5 秒,最后调用 exit(1) 退出。
父进程则不会退出,而是一直停留在:
c
while (1)
sleep(1);
这段代码的作用只是让父进程持续活着 ,方便我们观察子进程退出之后会不会触发 handler。
所以整个运行过程是这样的:
- 父进程启动,并注册
SIGCHLD的处理函数。 fork()创建一个子进程。- 子进程倒计时 5 秒后执行
exit(1)。 - 子进程退出时,内核向父进程发送
SIGCHLD。 - 父进程收到
SIGCHLD后,进入handler。 handler里打印的getpid()是父进程自己的 PID ,这就说明收到信号的是父进程,而不是子进程。
所以,这段代码证明的是:
子进程终止并不是静默发生的,内核会通过SIGCHLD异步通知父进程。
还有一个很重要的点:这段代码只验证了通知机制,没有完成回收机制 。因为你的handler里没有调用wait()或waitpid(),所以子进程退出后,父进程虽然收到了SIGCHLD,但没有把子进程回收掉。只要父进程还活着,这个子进程就可能暂时处于僵尸状态。
如果把这段代码的意义压成一句话,就是:
父进程先捕捉SIGCHLD,子进程 5 秒后退出;如果父进程随后执行了handler,就说明子进程退出时确实向父进程发送了SIGCHLD。
可以整理为下面这版更规范的专业表述:
利用 SIGCHLD 进行被动回收的意义
在不借助信号通知的情况下,父进程通常无法事先准确知道子进程何时退出,因此只能主动调用 wait 或 waitpid 去检测子进程状态。无论采用阻塞式等待还是非阻塞式轮询,其本质都是由父进程主动发起检查:如果子进程尚未退出,父进程就只能继续等待,或者在后续时刻再次发起检测。**
而引入 SIGCHLD 后,处理方式就发生了变化。由于子进程在终止时会主动向父进程发送 SIGCHLD,因此父进程可以不再持续主动探测,而是采用一种被动通知机制 :父进程只需预先为 SIGCHLD 注册处理函数,等子进程退出时由内核发出通知,父进程在收到该信号后再执行回收逻辑即可。
也就是说,相比于"父进程不断主动询问子进程是否结束",SIGCHLD 提供了一种"子进程结束时主动通知父进程"的机制。这样一来,父进程只有在真正收到子进程状态变化通知时,才需要调用 wait / waitpid,这在设计思路上更加自然。
为什么在 handler 中不能只调用一次 waitpid
虽然父进程可以在 SIGCHLD 的处理函数中调用 wait 或 waitpid 来回收子进程,但如果只调用一次,这种实现并不健壮。原因在于:在多子进程场景下,可能有多个子进程几乎在同一时刻退出。
例如,父进程如果通过循环创建了多个子进程,而这些子进程又在某个时间点几乎同时终止,那么它们都会触发 SIGCHLD。但是,SIGCHLD 属于标准信号,标准信号通常不是按次数排队保存的。更准确地说:
- 当父进程正在处理某个
SIGCHLD时,同类型信号会被暂时屏蔽; SIGCHLD的未决状态通常只由一个比特位表示;- 如果多个
SIGCHLD几乎同时到达,未决位最多只会被置位一次,而不会记录"来了多少次"。
因此,可能出现这样一种情况:实际上已经有多个子进程退出了,但父进程的信号处理函数只被触发了一次。如果此时在handler中只简单调用一次waitpid,那么通常只能回收一个子进程,其余已经退出的子进程仍可能继续保留为僵尸进程。
这说明:一次SIGCHLD的到来,并不意味着只有一个子进程退出;它只表示"至少有一个子进程状态发生了变化"。
循环调用 waitpid
因此,在 SIGCHLD 的处理函数中,更合理的做法不是"收到一次信号,回收一个子进程",而是"收到一次信号后,持续回收所有当前已经退出的子进程",直到没有可回收对象为止。
通常做法是循环调用:
c
waitpid(-1, NULL, WNOHANG);
其中:
-1表示"等待任意一个子进程",而不是指定某个固定 PID;WNOHANG表示采用非阻塞方式 ,如果当前没有已经退出的子进程,则立即返回,而不会阻塞父进程。
其典型逻辑可以写成:
c
while (1)
{
pid_t ret = waitpid(-1, NULL, WNOHANG);
if (ret > 0)
{
// 成功回收了一个子进程,继续尝试回收其他已退出子进程
continue;
}
else if (ret == 0)
{
// 当前没有已经退出的子进程,但可能仍有子进程存活
break;
}
else
{
// 出错;通常表示当前已没有可回收的子进程
break;
}
}
也可以简写为:
c
while (waitpid(-1, NULL, WNOHANG) > 0)
{
}
为什么要循环到失败或无可回收对象
这是因为父进程在收到一次 SIGCHLD 后,并不知道究竟有多少个子进程已经退出。唯一稳妥的方法,就是不断调用 waitpid(-1, NULL, WNOHANG),反复尝试回收,直到:
- 返回
0:表示当前仍有子进程存在,但此刻没有已经退出的子进程; - 返回
-1:通常表示当前没有可回收的子进程,或发生错误。
在这个意义上,"循环等待"并不是重复等待同一个子进程,而是一次性清空当前所有已退出、但尚未被回收的子进程。
借助SIGCHLD,父进程可以由"主动轮询子进程是否退出"转变为"在收到子进程退出通知后再执行回收",从而形成一种被动通知式的回收机制。但由于SIGCHLD属于标准信号,不保证按子进程退出次数逐次排队递送,因此在信号处理函数中不能只调用一次waitpid。正确做法是使用waitpid(-1, NULL, WNOHANG)循环回收,直到当前所有已经退出的子进程都被处理完毕。
为什么在 SIGCHLD 处理函数中必须使用 WNOHANG
在多子进程场景下,即使我们已经知道应当在 SIGCHLD 处理函数中循环调用 waitpid,也仍然不能直接采用阻塞式等待。原因在于:父进程在收到一次 SIGCHLD 时,并不知道到底有多少个子进程已经退出。
例如,假设父进程一共创建了 10 个子进程,但在某一时刻只有其中 5 个子进程退出。此时父进程收到 SIGCHLD 后,应当循环回收所有已经退出的子进程,因此前 5 次调用 waitpid 可能都会成功返回,并依次回收这 5 个已经终止的子进程。
问题在于:当第 6 次继续调用 waitpid 时,父进程并不能预先知道"后面是否还有子进程已经退出"。从父进程自身的视角来看,它只知道自己总共创建了多个子进程,但并不知道当前究竟退出了几个。因此,在设计回收逻辑时,父进程必须继续尝试调用 waitpid,直到确认当前没有更多已经退出的子进程为止。
如果此时使用的是阻塞式等待 ,那么一旦前 5 个子进程已经被回收完,而剩余的子进程尚未退出,第 6 次 waitpid 就会直接阻塞。这样一来,父进程会卡在信号处理函数内部,无法及时返回。这种行为显然是不合理的,因为信号处理函数的职责应当是"尽可能回收当前已经退出的子进程",而不是在其中无限等待未来尚未退出的子进程。
因此,在 SIGCHLD 的处理函数中,必须采用:
c
waitpid(-1, NULL, WNOHANG)
而不能简单使用阻塞式 waitpid。
其中:
-1表示等待任意一个子进程;WNOHANG表示采用非阻塞方式 等待。
其语义是:- 如果当前存在已经退出的子进程,就立即回收一个并返回其 PID;
- 如果当前仍有子进程存活,但此刻没有已经退出的子进程,则立即返回
0,而不会阻塞; - 如果返回
-1,通常表示当前没有可回收的子进程,或发生其他错误。
因此,在信号处理函数中,合理的循环逻辑应当是:
c
while (1)
{
pid_t ret = waitpid(-1, NULL, WNOHANG);
if (ret > 0)
{
// 成功回收一个已经退出的子进程,继续尝试回收
continue;
}
else if (ret == 0)
{
// 当前没有更多已经退出的子进程,本轮回收结束
break;
}
else
{
// 没有可回收子进程或发生错误,结束循环
break;
}
}
也就是说,父进程在收到一次 SIGCHLD 后,必须不断尝试回收所有已经退出的子进程;但由于无法事先知道退出了多少个子进程,因此必须依赖 waitpid(-1, NULL, WNOHANG) 的返回值来判断是否继续。只有当返回值为 0 时,才能说明:当前这一轮已经没有更多已退出、可立即回收的子进程了。
在 SIGCHLD 处理函数中,父进程通常需要循环调用 waitpid 回收所有已经退出的子进程。但由于父进程无法事先知道究竟有多少个子进程已经终止,因此不能使用阻塞式等待;否则,在回收完当前已退出的子进程后,后续一次 waitpid 可能会因为剩余子进程尚未退出而阻塞在处理函数内部。为避免这一问题,应使用 waitpid(-1, NULL, WNOHANG) 进行非阻塞式循环回收,并在返回值为 0 时结束本轮处理。
不显式回收的另一种方式:忽略 SIGCHLD
除了前面介绍的阻塞式等待、非阻塞式等待以及基于 SIGCHLD 的信号通知回收之外,在类 Unix 系统中,还存在一种"不显式回收子进程 "的处理方式:父进程可以通过 signal 或 sigaction,将 SIGCHLD 的处置方式显式设置为忽略,例如:
c
signal(SIGCHLD, SIG_IGN);
或使用 sigaction 配合相应选项实现同类语义。
在这种配置下,对于后续终止的子进程,内核通常不会再要求父进程通过 wait / waitpid 显式回收其退出状态;相应地,这些子进程在退出后一般不会长期保留为僵尸进程,而是由内核直接完成回收。换句话说,父进程在这种模式下可以不再主动执行传统的进程等待逻辑。
更准确地说,这种机制的效果是:
-
父进程显式声明自己不关心子进程的终止状态;
-
子进程退出后,其退出信息不会按通常方式长期保留等待父进程读取;
-
因此不会形成常规意义上的僵尸进程积压;
-
父进程通常也就不再需要对这些子进程调用
wait或waitpid。
需要补充一个重要细节:这里所说的"显式忽略SIGCHLD"与SIGCHLD在信号表中显示的默认Ign语义并不完全等价。在很多 Unix/Linux 实现中,显式设置SIGCHLD为SIG_IGN,或者使用sigaction的SA_NOCLDWAIT选项,才会触发"不保留僵尸、由内核直接回收"的特殊语义。
如果父进程通过signal(SIGCHLD, SIG_IGN)或等价机制显式声明忽略SIGCHLD,则内核通常会在子进程终止后直接完成回收,从而避免僵尸进程的产生,父进程也无需再调用wait/waitpid对其进行显式回收。#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>// volatile: 保持内存可见性!
// volatile int quit = 0;void handler(int signo)
{
printf("pid: %d, %d 号信号,正在被捕捉!\n", getpid(), signo)
}void Count(int cnt)
{
while (cnt)
{
printf("cnt: %2d\r", cnt);
fflush(stdout);
cnt--;
sleep(1);
}
printf("\n");
}int main()
{
// 显示的设置对SIGCHLD进行忽略
signal(SIGCHLD, SIG_IGN);
printf("我是父进程, %d, ppid: %d\n", getpid(), getppid());
pid_t id = fork();
if (id == 0)
{
printf("我是子进程, %d, ppid: %d,我要退出啦\n", getpid(), getppid());
Count(5);
exit(1);
}while (1) sleep(1);}
显式忽略 SIGCHLD 的实验现象
在该实验中,父进程通过:
signal(SIGCHLD, SIG_IGN);
显式地将 SIGCHLD 的处理方式设置为忽略 。这里的"显式设置为忽略"是指:程序员主动调用 signal,明确声明父进程不再关心子进程终止所带来的状态通知。
随后,程序继续通过 fork() 创建子进程,并让子进程运行 5 秒后调用 exit(1) 退出。在整个过程中:
- 父进程没有调用 wait 或 waitpid;
- 也没有在信号处理函数中执行任何回收逻辑;
- SIGCHLD 对应的处理动作已经被显式设置为 SIG_IGN。
从传统意义上看,如果父进程既不等待子进程,也不显式回收子进程,那么子进程退出后通常会先进入僵尸状态,直到父进程执行 wait / waitpid 为止。
但是,在本实验中,由于父进程已经显式忽略 SIGCHLD,因此子进程终止后并不会长期保留为僵尸进程,而是会被内核直接回收。
为了观察这一现象,可以在程序运行期间周期性执行 ps 查询进程状态。实验开始时能够同时观察到: - 父进程仍在运行;
- 子进程也在运行。
而在子进程执行完 5 秒倒计时并退出之后,再次查看进程列表时,会发现: - 父进程仍然存在;
- 子进程已经从进程列表中消失;
- 没有观察到该子进程处于僵尸状态。
这说明该子进程在退出后被内核直接完成了回收,而不是等待父进程显式调用 wait / waitpid。
因此,这个实验验证了如下结论:
当父进程显式将 SIGCHLD 设置为 SIG_IGN 时,后续终止的子进程通常不会形成常规意义上的僵尸进程,而会在退出后由内核直接回收。
在本实验中,父进程通过 signal(SIGCHLD, SIG_IGN) 显式忽略 SIGCHLD,随后创建一个 5 秒后退出的子进程。尽管父进程没有调用 wait 或 waitpid,也没有在信号处理函数中执行回收逻辑,但从进程状态观察可以发现:子进程退出后并未保留为僵尸进程,而是直接从系统中消失,只剩父进程继续运行。这表明,在显式忽略 SIGCHLD 的情况下,内核通常会对子进程执行自动回收。**
再严谨补一句:这种"自动回收"语义依赖具体 Unix/Linux 实现,但在 Linux 的常见行为下,signal(SIGCHLD, SIG_IGN) 通常确实会触发对子进程退出后的直接回收效果。
可以整理为下面这版更规范的专业表述:
SIGCHLD 的默认忽略与显式忽略并不等价
这里需要特别区分一个容易混淆的细节:虽然在信号说明中,SIGCHLD 的默认处置常被描述为 Ign(ignore),但这并不意味着它与用户显式调用
c
signal(SIGCHLD, SIG_IGN);
所产生的语义完全相同。
更准确地说,二者分别对应:
- 默认处置 :
SIG_DFL - 显式忽略 :
SIG_IGN
对于大多数信号来说,"默认处置是忽略"和"用户显式设置为忽略"在外部现象上往往比较接近;但SIGCHLD是一个特殊信号,内核对它赋予了额外语义,因此这两种情况在子进程回收行为上并不等价。
SIGCHLD 的默认处置:SIG_DFL
当父进程没有主动修改 SIGCHLD 的处理方式时,它采用的是 SIG_DFL。
对于 SIGCHLD 而言,SIG_DFL 的表面描述通常显示为"忽略",意思是:
- 父进程不会因为收到该信号而终止;
- 内核不会默认调用用户自定义处理函数;
- 信号本身不会表现出明显的用户态处理动作。
但是,这里的"忽略"并不等于"子进程自动回收"。在这种默认语义下: - 子进程退出后,仍然可能进入僵尸状态;
- 父进程如果需要释放其内核残留信息,仍应调用
wait/waitpid进行回收。
也就是说,SIGCHLD的默认处置虽然常显示为Ign,但父进程依旧负有常规的子进程回收责任。
显式设置为 SIG_IGN
如果父进程主动执行:
c
signal(SIGCHLD, SIG_IGN);
则含义发生了变化。这不再只是"维持默认行为",而是用户明确告诉内核:
父进程不关心子进程的终止状态,也不准备再通过 wait / waitpid 获取其退出信息。
在 Linux 和常见 Unix 语义下,内核会据此启用一种特殊处理:
- 子进程终止后,不再长期保留为僵尸进程;
- 其退出状态通常不会等待父进程显式读取;
- 内核会直接完成回收。
因此,显式设置SIGCHLD为SIG_IGN的效果,通常等价于声明"父进程放弃对子进程退出状态的后续处理需求",从而触发自动回收语义。
两者看起来都叫 ignore,但效果不同的原因
这是因为"Ign"在信号表中只是对默认动作的一个描述,而不是说它和程序中显式安装的 SIG_IGN 处置完全等价。
更严谨地说:
- 默认显示为
Ign:是SIGCHLD在SIG_DFL下的一种特殊默认语义描述; - 显式设置
SIG_IGN:是用户主动安装的信号处置方式,内核会据此附加"无需保留子进程退出状态"的特殊处理逻辑。
因此,这里的差异不在于"名字都叫 ignore",而在于:
SIG_DFL和SIG_IGN是两种不同的处置状态;对于SIGCHLD,内核会对这两种状态赋予不同的子进程回收语义。
对于SIGCHLD,信号表中显示的默认Ign与用户显式设置signal(SIGCHLD, SIG_IGN)并不等价。前者对应的是SIG_DFL下的默认处置,子进程退出后仍可能需要父进程通过wait/waitpid回收;后者则表示父进程明确放弃对子进程退出状态的处理需求,内核通常会对子进程执行自动回收,从而避免僵尸进程的产生。
对于SIGCHLD,默认"忽略"是默认处置语义,显式SIG_IGN是用户安装的特殊处置;二者名称相近,但在子进程回收行为上并不相同。