进程与信号

文章目录

  • 父子进程与信号
    • 子进程退出与僵尸进程
    • 父进程等待子进程的两种方式
      • [利用 SIGCHLD进行被动回收](#利用 SIGCHLD进行被动回收)
        • [利用 `SIGCHLD` 进行被动回收的意义](#利用 SIGCHLD 进行被动回收的意义)
        • [为什么在 `handler` 中不能只调用一次 `waitpid`](#为什么在 handler 中不能只调用一次 waitpid)
      • [不显式回收的另一种方式:忽略 SIGCHLD](#不显式回收的另一种方式:忽略 SIGCHLD)
        • [`SIGCHLD` 的默认忽略与显式忽略并不等价](#SIGCHLD 的默认忽略与显式忽略并不等价)
          • [`SIGCHLD` 的默认处置:`SIG_DFL`](#SIGCHLD 的默认处置:SIG_DFL)
        • [显式设置为 `SIG_IGN`](#显式设置为 SIG_IGN)
        • [两者看起来都叫 ignore,但效果不同的原因](#两者看起来都叫 ignore,但效果不同的原因)

父子进程与信号

子进程退出与僵尸进程

当一个子进程终止时,它不会立即从系统中彻底消失,而是会先进入僵尸状态(zombie state) 。处于僵尸状态的子进程仍然保留最基本的进程控制块信息,以便其父进程后续通过 waitwaitpid 读取该子进程的退出状态、终止信号等信息。只有在父进程完成回收之后,内核才会真正释放该子进程的相关资源。

父进程在等待子进程时,可以采用阻塞式等待 ,也可以采用非阻塞式等待 。如果目标子进程尚未退出,那么阻塞式等待会使父进程挂起,直到子进程状态发生变化;而非阻塞式等待则会立即返回,由父进程自行决定后续处理逻辑。当子进程终止后,waitwaitpid 才会成功返回,并向父进程提供该子进程的退出码或终止原因。

需要补充的一个重要事实是:**子进程终止时并不是静默进入僵尸状态的。**在子进程结束运行时,内核会主动向其父进程发送一个 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

所以整个运行过程是这样的:

  1. 父进程启动,并注册 SIGCHLD 的处理函数。
  2. fork() 创建一个子进程。
  3. 子进程倒计时 5 秒后执行 exit(1)
  4. 子进程退出时,内核向父进程发送 SIGCHLD
  5. 父进程收到 SIGCHLD 后,进入 handler
  6. handler 里打印的 getpid()父进程自己的 PID ,这就说明收到信号的是父进程,而不是子进程。
    所以,这段代码证明的是:
    子进程终止并不是静默发生的,内核会通过 SIGCHLD 异步通知父进程。
    还有一个很重要的点:这段代码只验证了通知机制,没有完成回收机制 。因为你的 handler 里没有调用 wait()waitpid(),所以子进程退出后,父进程虽然收到了 SIGCHLD,但没有把子进程回收掉。只要父进程还活着,这个子进程就可能暂时处于僵尸状态。
    如果把这段代码的意义压成一句话,就是:
    父进程先捕捉 SIGCHLD,子进程 5 秒后退出;如果父进程随后执行了 handler,就说明子进程退出时确实向父进程发送了 SIGCHLD
    可以整理为下面这版更规范的专业表述:
利用 SIGCHLD 进行被动回收的意义

在不借助信号通知的情况下,父进程通常无法事先准确知道子进程何时退出,因此只能主动调用 waitwaitpid 去检测子进程状态。无论采用阻塞式等待还是非阻塞式轮询,其本质都是由父进程主动发起检查:如果子进程尚未退出,父进程就只能继续等待,或者在后续时刻再次发起检测。**

而引入 SIGCHLD 后,处理方式就发生了变化。由于子进程在终止时会主动向父进程发送 SIGCHLD,因此父进程可以不再持续主动探测,而是采用一种被动通知机制 :父进程只需预先为 SIGCHLD 注册处理函数,等子进程退出时由内核发出通知,父进程在收到该信号后再执行回收逻辑即可。

也就是说,相比于"父进程不断主动询问子进程是否结束",SIGCHLD 提供了一种"子进程结束时主动通知父进程"的机制。这样一来,父进程只有在真正收到子进程状态变化通知时,才需要调用 wait / waitpid,这在设计思路上更加自然。

为什么在 handler 中不能只调用一次 waitpid

虽然父进程可以在 SIGCHLD 的处理函数中调用 waitwaitpid 来回收子进程,但如果只调用一次,这种实现并不健壮。原因在于:在多子进程场景下,可能有多个子进程几乎在同一时刻退出。

例如,父进程如果通过循环创建了多个子进程,而这些子进程又在某个时间点几乎同时终止,那么它们都会触发 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 系统中,还存在一种"不显式回收子进程 "的处理方式:父进程可以通过 signalsigaction,将 SIGCHLD 的处置方式显式设置为忽略,例如:

c 复制代码
signal(SIGCHLD, SIG_IGN);

或使用 sigaction 配合相应选项实现同类语义。

在这种配置下,对于后续终止的子进程,内核通常不会再要求父进程通过 wait / waitpid 显式回收其退出状态;相应地,这些子进程在退出后一般不会长期保留为僵尸进程,而是由内核直接完成回收。换句话说,父进程在这种模式下可以不再主动执行传统的进程等待逻辑。

更准确地说,这种机制的效果是:

  1. 父进程显式声明自己不关心子进程的终止状态

  2. 子进程退出后,其退出信息不会按通常方式长期保留等待父进程读取;

  3. 因此不会形成常规意义上的僵尸进程积压;

  4. 父进程通常也就不再需要对这些子进程调用 waitwaitpid
    需要补充一个重要细节:这里所说的"显式忽略 SIGCHLD"与 SIGCHLD 在信号表中显示的默认 Ign 语义并不完全等价。在很多 Unix/Linux 实现中,显式设置 SIGCHLDSIG_IGN ,或者使用 sigactionSA_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 语义下,内核会据此启用一种特殊处理:

  • 子进程终止后,不再长期保留为僵尸进程;
  • 其退出状态通常不会等待父进程显式读取;
  • 内核会直接完成回收。
    因此,显式设置 SIGCHLDSIG_IGN 的效果,通常等价于声明"父进程放弃对子进程退出状态的后续处理需求",从而触发自动回收语义。
两者看起来都叫 ignore,但效果不同的原因

这是因为"Ign"在信号表中只是对默认动作的一个描述,而不是说它和程序中显式安装的 SIG_IGN 处置完全等价。

更严谨地说:

  • 默认显示为 Ign :是 SIGCHLDSIG_DFL 下的一种特殊默认语义描述;
  • 显式设置 SIG_IGN :是用户主动安装的信号处置方式,内核会据此附加"无需保留子进程退出状态"的特殊处理逻辑。
    因此,这里的差异不在于"名字都叫 ignore",而在于:
    SIG_DFLSIG_IGN 是两种不同的处置状态;对于 SIGCHLD,内核会对这两种状态赋予不同的子进程回收语义。
    对于 SIGCHLD,信号表中显示的默认 Ign 与用户显式设置 signal(SIGCHLD, SIG_IGN) 并不等价。前者对应的是 SIG_DFL 下的默认处置,子进程退出后仍可能需要父进程通过 wait / waitpid 回收;后者则表示父进程明确放弃对子进程退出状态的处理需求,内核通常会对子进程执行自动回收,从而避免僵尸进程的产生。
    对于 SIGCHLD,默认"忽略"是默认处置语义,显式 SIG_IGN 是用户安装的特殊处置;二者名称相近,但在子进程回收行为上并不相同。
相关推荐
我不是懒洋洋8 小时前
手写一个B+树:从原理到数据库索引实战
c语言·c++·经验分享
奶茶树8 小时前
【STL/数据结构】哈希表和unordered系列容器的封装
开发语言·c++·散列表
Brilliantwxx8 小时前
【C++】初步认识STL(3)
开发语言·c++·笔记·算法
HalvmånEver8 小时前
MySQL表的内连和外连
linux·数据库·学习·mysql
charlie1145141918 小时前
通用GUI编程技术——图形渲染实战(四十)——深度缓冲与3D变换:从平面到立体
开发语言·c++·平面·3d·图形渲染·win32
啊吧怪不啊吧8 小时前
C++之基于正倒排索引的Boost搜索引擎项目日志+server代码及详解
c++·搜索引擎·项目
Hommy888 小时前
【开源剪映小助手】视频生成接口
服务器·github·aigc·剪映小助手·视频剪辑自动化
AI进化营-智能译站8 小时前
ROS2 C++开发系列06:变量、数据类型与IO实战
java·开发语言·c++·ai
HABuo10 小时前
【linux(四)】套接字编程--基于UDP协议的客户端服务端
linux·服务器·c++·网络协议·ubuntu·udp·centos