当前位置: 首页 > news >正文

深入理解 Linux 进程信号

文章目录

    • 一、信号入门
      • 1. 生活角度的信号
      • 2. 技术应用角度的信号
      • 3. 信号概念
      • 4. 信号处理常见方式
      • 5. 信号的存储位置
    • 二、初识捕捉信号
    • 三、产生信号
      • 1. 调用系统函数向进程发信号
      • 2. 硬件异常产生信号
      • 3. 软件条件产生信号
      • 4. 通过终端按键产生信号
    • 四、信号产生总结
      • 1. 所有信号产生,最终都要有 OS 来进行执行,为什么?
      • 2. 信号的处理是否是立即处理的?
      • 3. 信号如果不是被立即处理,那是否需要暂时被进程记录下来?记录在哪里最合适?
      • 4. 一个进程在没有收到信号的时候,能否知道自己应该对合法信号作何处理?
      • 5. 如何理解 OS 向进程发送信号?能否描述一下完整的发送处理过程?
    • 五、阻塞信号
      • 1. 信号常见概念
      • 2. 在内核中的表示
      • 3. sigset_t
      • 4. 信号集操作函数
      • 5. sigprocmask
      • 6. sigpending
    • 六、捕捉信号
      • 1. 知识点一
      • 2. 知识点二
      • 3. 知识点三
      • 4. 知识点四
      • 5. 信号的捕捉
      • 6. 内核如何实现信号的捕捉
      • 7. sigaction
    • 七、可重入函数
    • 八、volatile
    • 九、SIGCHLD 信号


一、信号入门

1. 生活角度的信号

我这里引入一个生活中取快递的例子:

  • 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递,也就是你能 “识别快递”
  • 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需 5min 之后才能去取快递。那么在在这 5min 之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成 “在合适的时候去取”
  • 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你 “记住了有一个快递要去取”
  • 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:
    • 执行默认动作(幸福的打开快递,使用商品)
    • 执行自定义动作(快递是零食,你要送给你你的女朋友)
    • 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
  • 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。

2. 技术应用角度的信号

我们输入命令,在 Shell 下启动一个前台进程。

代码如下

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
using namespace std;// 我写了一个将来一直会运行的程序, 用来进行后续的测试
int main()
{while (true){cout << "我是一个进程, 我的pid是: " << getpid() << ", 我的ppid是: " << getppid() << endl;sleep(1);}return 0;
}

接着按下 Ctrl + C,这个键盘输入产生一个硬件中断,被 OS 获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。

在这里插入图片描述

除此之外,我们还可以使用 kill -9 457 来终止掉这个进程!

注意:

  • Ctrl + C 产生的信号只能发给前台进程。一个命令后面加个 & 可以放到后台运行,这样 Shell 不必等待进程结束就可以接受新的命令,启动新的进程。
  • Shell 可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl + C 这种控制键产生的信号。
  • 前台进程在运行过程中,用户随时可能按下 Ctrl + C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。

3. 信号概念

信号是进程之间事件异步通知的一种方式,属于软中断。

在 Linux 下可以使用 kill -l 命令查看系统定义的信号列表。

在这里插入图片描述

每个信号都有一个编号和一个宏定义名称,这些宏定义可以在 signal.h 中找到,例如其中有定义 #define SIGINT 2

编号 [34 ~ 64] 以上的是实时信号,本文章只讨论编号 [1 ~ 31] 的普通信号,不讨论实时信号。

这些信号各自在什么条件下产生,默认的处理动作是什么,在 signal(7) 中都有详细说明 man 7 signal:比如

在这里插入图片描述

4. 信号处理常见方式

信号是操作系统向进程发送的异步通知机制(例如通过 kill -9 8888 命令),但进程不一定能立即处理它。由于信号的产生是异步的,可能随时发生,而进程可能正在执行优先级更高的任务。

因此,进程需要具备两个核心能力:

  • 信号识别机制:进程通过程序员预先编码的逻辑来识别特定信号(如 SIGTERM、SIGKILL)
  • 信号存储能力:当信号到达时,进程会将其存入内部队列(如未决信号集),待当前执行流程允许时再处理

信号处理有三种典型方式:

  • 默认动作:由操作系统定义的默认行为(如终止进程)
  • 自定义处理:通过 signal() 等函数注册自定义处理函数
    • 提供一个信号处理函数 handler,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉 (Catch) 一个信号。
  • 忽略信号:对特定信号(如 SIG_IGN)不做任何处理

当进程从队列中取出信号并执行对应动作时,称为"信号被捕捉"。整个机制确保了系统在响应外部事件的同时,不会中断关键任务的执行。

5. 信号的存储位置

当操作系统需要向进程发送信号时,信号状态存储在进程控制块(PCB)的 task_struct 结构体中。对于 [1 ~ 31] 范围内的普通信号,Linux 内核采用位图(bitmap)机制来高效管理:

struct task_struct {// ...其他字段unsigned int signal;  // 32位无符号整数,每比特代表一个信号// ...其他字段
};

我们是用比特位的位置来代表信号编号,而用比特位的内容来表示是否收到该信号,其中 0 表示没有收到相应信号,1 则表示收到了。简单来讲,信号在进程控制块(PCB)里呈现的就是位图结构。

在这里插入图片描述

接下来再说说如何理解信号的发送。发送信号,其实质就是修改 PCB 中的信号位图。打个比方,假如要发送 9 号信号,那么只需要把第 9 个比特位由 0 设置为 1 就可以了,这就是所谓的信号发送。

我们都清楚,PCB 是由内核进行维护的数据结构对象,所以 PCB 的管理者是操作系统(OS),这也就意味着只有 OS 才有权利去修改 PCB 中的相关内容。

无论在后续学习过程中,我们会接触到多少种发送信号的方式,其本质都是通过 OS 向目标进程发送信号。因此, OS 必须要提供用于发送信号以及处理信号的相关系统调用。就像我们平常经常使用的kill命令,它在底层一定是运用了系统调用的。

二、初识捕捉信号

函数原型

#include <signal.h>sighandler_t signal(int signum, sighandler_t handler);

功能:

  • 为指定的信号 signum 设置一个新的信号处理函数 handler。
  • 当进程接收到信号 signum 时,将执行 handler 函数。

参数:

  • signum:要捕获的信号编号,例如 SIGINT(Ctrl+C,编号为 2)、SIGTERM(终止信号,编号为 15)等。
  • handler:信号处理函数,类型为 sighandler_t,它是一个接受 int 参数并返回 void 的函数指针
  • 此外,handler 还可以取以下特殊值:
    • SIG_DFL:恢复信号的默认处理行为。
    • SIG_IGN:忽略该信号。

代码实现

#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <signal.h>
#include <cassert>// 给2号信号设置的默认捕捉方法
void handler(int signo)
{std::cout << "进程捕捉到了一个信号, 信号编号是: " << signo << std::endl;
}int main()
{signal(2, handler); while (true){std::cout << "我是一个进程, 我的pid是: " << getpid() << ", 我的ppid是: " << getppid() << std::endl;sleep(1);}return 0;
}

运行结果

在这里插入图片描述

那么为什么这里 Ctrl + C 不能终止掉进程了呢?因为我把它的默认动作改成了自定义动作,那么它的自定义动作不再是终止进程,而是进程捕捉。如果想终止,只需要在函数中添加 exit(0) 即可。

// 给2号信号设置的默认捕捉方法
void handler(int signo)
{cout << "进程捕捉到了一个信号, 信号编号是: " << signo << endl;exit(0);
}

此时就能退出了

在这里插入图片描述

三、产生信号

1. 调用系统函数向进程发信号

kill 命令是调用 kill() 函数实现的,kill() 函数可以给一个指定的进程发送指定的信号,我们可以自己实现一个 kill 命令。

mysignal.cpp

#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <signal.h>
#include <cassert>
#include <string>
#include <sys/types.h>using namespace std;static void Usage(const string &proc)
{cout << "\nUsage: " << proc << " pid signo\n" << endl;
}int main(int argc, char *argv[])
{if (argc != 3){Usage(argv[0]);exit(1);}pid_t pid = atoi(argv[1]);int signo = atoi(argv[2]);int n = kill(pid, signo);if (n != 0){perror("kill");}return 0;
}

mytest.cpp

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
using namespace std;// 我写了一个将来一直会运行的程序, 用来进行后续的测试
int main()
{while (true){cout << "我是一个进程, 我的pid是: " << getpid() << ", 我的ppid是: " << getppid() << endl;sleep(1);}return 0;
}

Makefile

.PHONY:all
all:mysignal mytestmytest:mytest.cppg++ -o $@ $^ -std=c++11mysignal:mysignal.cppg++ -o $@ $^ -std=c++11 -g.PHONY:clean
clean:rm -f mytest mysignal

运行结果如下:

在这里插入图片描述

raise() 函数可以给当前进程发送指定的信号(自己给自己发信号)

#include <signal.h>int raise(int sig);

代码示例

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>using namespace std;int main()
{// 系统调用向目标进程发送信号// raise 给自己发送任意信号 kill(getpid(), 任意信号)int cnt = 0;while (cnt <= 10){printf("cnt: %d\n", cnt++);sleep(1);if (cnt >= 5){raise(3); // 当cnt=5时, 给自己发送3号信号}}return 0;
}

运行结果

在这里插入图片描述

abort() 函数使当前进程接收到信号而异常终止。

#include <stdlib.h>
void abort(void);就像exit函数一样,abort函数总是会成功的,所以没有返回值。

代码实现

#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>using namespace std;int main()
{// 系统调用向目标进程发送信号// abort 给自己发送 指定的6号信号SIGABRT == kill(getpid, SIGABRT)int cnt = 0;while (cnt <= 10){printf("cnt: %d\n", cnt++);sleep(1);if (cnt >= 5){abort(); // 当cnt=5时, 给自己发送3号信号}}return 0;
}

运行结果

在这里插入图片描述

2. 硬件异常产生信号

硬件异常被硬件以某种方式即被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。

例如当前进程执行了除以 0 的指令,CPU 的运算单元会产生异常,内核将这个异常解释为 SIGFPE 信号发送给进程。

再比如当前进程访问了非法内存地址,MMU 会产生异常,内核将这个异常解释为 SIGSEGV 信号发送给进程。

代码示例

#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>using namespace std;// 自定义捕捉信号
void catchSig(int signo)
{cout << "获取到一个信号, 信号编号是: " << signo << endl;sleep(1);
}int main(int argc, char *argv[])
{signal(SIGFPE, catchSig); while (true){cout << "我在运行中......" << endl;sleep(1);int a = 10;a /= 0;}return 0;
}

运行结果

在这里插入图片描述

思考一下:操作系统是如何得知应该给当前进程发送 8 号信号的呢?也就是说,OS怎么知道 a / 0 了呢?

CPU 具备众多寄存器,像通用寄存器就有 eax、ebx、ecx、edx 等等。由于在 CPU 内部必然会进行大量的计算,所以每当在 CPU 上开展相应运算时,除了要得出正常的运算结果外,这里所说的正常运算,例如把 10 加载到 eax 中,把 0 加载到 ebx 中,再把 10 除以 0 的结果存放到 ecx 中,这就是所谓的正常计算情况。

也就是说,CPU 在进行对应运算时,不但要算出结果,还得确保此次运算是否存在问题,正因如此,CPU 内部设有一个名为 状态寄存器 的部件。

状态寄存器当中包含大量的数据,不过这些数据并不属于代码里的数据范畴,它们主要是用于衡量此次运算的结果。
 
打个比方,当你在运算过程中出现除以零的情况时,计算机里 除以零 就等同于除以无穷大,就像 10 / 0,其结果就是数据无穷大,这样最终就会致使状态寄存器当中的溢出标志位从 0 变为 1。
 
要知道寄存器是由二进制序列构成的,其内部的比特位都有各自的含义,在状态寄存器里就有一个溢出标记位。
 
这个标记位默认是 0,若为 0 则代表此次运算没有问题,不存在溢出情况。而要是进行了除以 0 的运算,那么 CPU 在运算时马上就能发现会出现溢出,一旦溢出,状态寄存器中的该标记位就会被置为 1,这意味着本次计算处于溢出状态。
 
所以,经过这样的运算后得出的结果是没有意义的,不能被采纳。
 
至此,也就相当于出现了 CPU 的运算异常,鉴于操作系统是软硬件资源的管理者,所以操作系统(OS)必须要能够识别出这个异常。

那它是如何识别的呢?其实就是查看状态寄存器中的标志位!

在这里插入图片描述

只要这个标志位被置为 1 了,操作系统就能立刻知晓硬件上的 CPU 发生了溢出,并且清楚是由谁导致了这个溢出。

随后,操作系统会向目标进程修改相应标记位,并发送 8 号信号,如此一来,这个进程在收到 8 号信号后,经过相应处理就会自行终止了。

那么,为什么上述代码运行后会持续循环打印呢?

当进程接收到信号时,并不一定会直接退出。若进程未退出,则仍有可能被CPU调度执行。需要明确的是,CPU内部的寄存器是唯一的,但寄存器中的内容却属于当前运行进程的上下文。
 
由于我们无法手动修正状态寄存器的异常,因此在进程调度切换的过程中,状态寄存器会被反复保存和恢复。每次恢复时,操作系统都会检测到状态寄存器中的溢出标志位(Overflow Flag)为 1,这就使得进程不断收到操作系统发送的异常信号,进而导致持续打印的现象。

本质上,C/C++ 代码中的除零操作在底层会触发硬件异常。当 CPU 执行除法指令时,如果除数为零,状态寄存器的溢出标志位会被置为 1。操作系统在检测到这一硬件异常后,会将其转换为软件层面的信号(如 SIGFPE,即 8 号信号)发送给目标进程。若进程未对该信号进行特殊处理(如忽略或捕获),默认行为就是终止进程,这就是为什么除零操作会导致进程崩溃的根本原因。

再来一个经典的例子:

#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>using namespace std;// 自定义捕捉信号
void catchSig(int signo)
{cout << "获取到一个信号, 信号编号是: " << signo << endl;exit(1); // 收到信号以后就终止
}int main()
{signal(SIGSEGV, catchSig); while (true){cout << "我在运行中......" << endl;sleep(1);int *p = nullptr;*p = 100;}return 0;
}

运行结果

在这里插入图片描述

为什么野指针就会崩溃呢?因为 OS 会给当前进程发送指定的 11 号信号 SIGSEGV(代表非法内存引用)

那么 OS 怎么知道野指针的呢?

在进程的虚拟地址空间中,任何内存访问都需要通过页表映射到物理内存。例如,当执行 int* p; *p = nullptr 后,解引用操作 *p 本质上是访问虚拟地址(指针的本质就是虚拟地址),而虚拟地址到物理地址的转换依赖两个核心组件:页表MMU(内存管理单元)。其中 MMU 是集成在 CPU 内部的硬件单元,负责执行地址转换。

当解引用 p 时,若 p 的值为 nullptr(即 0 号虚拟地址),MMU 会通过页表进行地址转换。此时页表会明确标记:0 号地址属于内核空间,禁止用户进程访问。因此,MMU 在执行转换时会因 越界访问 触发硬件异常。

操作系统作为硬件的管理者,会立即捕获这个异常。由于异常发生在地址转换阶段,操作系统会将其翻译为 SIGSEGV(11号信号),并发送给目标进程。进程接收到该信号后,默认行为是终止并生成核心转储文件(Core Dump)。

在这里插入图片描述

总结来说,野指针的本质是 非法内存访问。当进程尝试访问未被页表映射的虚拟地址(如 nullptr )时,MMU 会因地址转换失败触发硬件异常,操作系统将其转换为信号发送给进程,最终导致进程崩溃。这一机制确保了系统的安全性 —— 任何越界访问都会被及时拦截,防止恶意或误操作破坏系统稳定性。

3. 软件条件产生信号

SIGPIPE 是一种由软件条件产生的信号,在 “管道” 中已经介绍过了。这里主要介绍 alarm 函数 和 SIGALRM 信号。

调用 alarm() 函数可以设定一个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发 SIGALRM 信号,该信号的默认处理动作是终止当前进程。

#include <unistd.h>unsigned int alarm(unsigned int seconds);

这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。

打个比方,某人要小睡一觉,设定闹钟为 30 分钟之后响,20 分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为 15 分钟之后响,“以前设定的闹钟时间还余下的时间” 就是 10 分钟。
 
如果 seconds 值为 0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。

下面代码的作用是 1 秒钟之内不停地数数,1 秒钟到了就被 SIGALRM 信号终止。

#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>using namespace std;// 自定义捕捉信号
void catchSig(int signo)
{cout << "获取到一个信号, 信号编号是: " << signo << endl;exit(1); // 收到信号以后就终止
}int main()
{signal(SIGALRM, catchSig); // 统计1s左右, 计算机能够将数据累计相加多生成!int cnt = 14;alarm(1);while (true){cnt++;cout << "cnt: " << cnt << endl;}return 0;
}

运行结果

在这里插入图片描述

那么,该如何理解操作系统(OS)中的闹钟呢?

闹钟实际上是通过软件来实现的。任意一个进程都能够借助 “alarm” 系统调用,在内核里设置闹钟。这也就意味着在操作系统内部可能存在众多的闹钟,所以操作系统需要采用先描述再组织的方式对这些闹钟进行管理。

在操作系统内部,要为闹钟创建特定的数据结构对象,其伪代码如下:

struct alarm
{uint64_t when;  // 未来的超时时间int type;  // 闹钟类型task_struct *p;struct alarm *next;
}

随后创建一个对象,比如:struct alarm myalarm = {...}。接着,创建一个名为 struct alarm *head 的头指针,我们可以将设置好的闹钟放入对应的队列当中,然后把所有人设定的闹钟通过特定的数据结构连接起来,而操作系统会周期性地去检查这些闹钟。

那操作系统是如何进行检查的呢?它首先会获取当前的时间戳 curr_timestamp,再将其与 alarm.when 进行对比,如果 curr_timestamp 大于 alarm.when,那就表明已经超时了。一旦超时,操作系统会向 alarm.p(这里的 p 代表指定的进程)发送 SIGALARM 信号。

若要理解这个闹钟,从本质上来说,操作系统内部对闹钟的管理最终就演变成了对链表的增删查改操作。

所谓闹钟是软件条件,其过程是这样的:操作系统需要定期去检查超时条件,这里所说的超时,就是指操作系统这样的软件去对闹钟以及闹钟所维护的软件集合进行检查。当时间到达设定值的时候,操作系统会从相应的结构当中查找对应的超时时间。它的这类行为全部是由软件来完成的,而我们所说的条件就体现在超时这个方面,所以才将其称作软件条件。

4. 通过终端按键产生信号

SIGINT 的默认处理动作是终止进程,SIGQUIT 的默认处理动作是终止进程并且 Core Dump,现在我们来验证一下。

首先解释什么是 Core Dump:

当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是 core,这叫做 Core Dump。
 
进程异常终止通常是因为有 Bug,比如非法内存访问导致段错误,事后可以用调试器检查 core 文件以查清错误原因,这叫做 Post-mortem Debug(事后调试)。一个进程允许产生多大的 core 文件取决于进程的 Resource Limit(这个信息保存在 PCB 中)。
 
默认是不允许产生 core 文件的,因为 core 文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。

代码如下:

int main()
{// 测试核心转储while (true){int a[10];a[10000] = 106; // 数组越界}return 0;
}

运行结果:

在这里插入图片描述

注意:云服务器默认关闭了core file 选项,使用 ulimit -a 查看一下:

在这里插入图片描述

我们可以打开云服务器的 core file 选项,允许 core 文件最大为 1024K:

在这里插入图片描述

此时我们再运行一次代码:

在这里插入图片描述

core dumped 表示核心转储,在当前目录下,有一个 core.1452 的临时文件,1452 是引起 core 问题的进程 pid。

核心转储是当进程出现异常的时候,我们将进程对应的时刻,在内存中的有效数据转储到磁盘中。

那么为什么要有核心转储呢???操作系统为了便于我们后期做调试,它会将我们程序进程在运行期间出现崩溃的代码的相关上下文数据,全部给我们 dumped 到磁盘当中,那么用来进行支持调试!

在这里插入图片描述

那么思考一下:SIGKILL 是 Trem,而 SIGQUIT 是 Core,它们两个之间的区别是什么呢?

在这里插入图片描述

Core 退出是可以被核心转储的,也就是可以后续快速定位问题的,也是我们写代码当中最常见的问题。而一般 Trem 这种终止,就是我们主动的正常杀掉进程啊。

总结:如果你的程序出异常了,那么你可以先确认一下这个异常。进程出现异常,一定是收到了信号,首先确认是几号信号,然后再确认信号是 Core 还是 Term?如果是 Core,你可以把核心转储打开,然后直接让它再运行一次,形成核心转储文件,直接 GDB 定位错误的行数。

四、信号产生总结

1. 所有信号产生,最终都要有 OS 来进行执行,为什么?

原因:因为信号是一种由操作系统提供的异步通信机制。

信号的产生可能来自:

  • 硬件中断(如 Ctrl+C )
  • 系统调用(如 kill()
  • 内核检测(如非法内存访问导致的 SIGSEGV

这些行为都发生在内核态,用户态的进程不能主动处理信号,必须等 OS 转而调度执行进程的信号处理函数,所以信号的分发和处理调度,必须由 OS 统一协调。

2. 信号的处理是否是立即处理的?

不一定是立即处理。

如果进程 当前是可中断状态(比如处于用户态运行中),并且信号未被屏蔽,那么可以 立即处理中断执行流,调用相应的信号处理函数。

如果进程 正在内核态执行系统调用、信号被屏蔽、或者被阻塞(如 sleep()),则信号的处理会被延迟,直到进程回到用户态或解除阻塞状态。

3. 信号如果不是被立即处理,那是否需要暂时被进程记录下来?记录在哪里最合适?

是的,信号需要暂时 “挂起” 并被记录下来。

  • 内核为每个进程维护一个 “挂起信号集”(pending signal set)。
  • 这是一个 位图(bitmap),表示每个信号是否已经被送达但尚未处理。
  • 该结构保存在进程的 task_struct(任务控制块)中,是 OS 维护进程状态的一部分。

4. 一个进程在没有收到信号的时候,能否知道自己应该对合法信号作何处理?

可以。

每个进程都维护一个 信号处理表(signal disposition table):

  • 默认处理(如终止、忽略、core dump)
  • 用户自定义处理函数(通过 signal()sigaction() 设置)

这些设置由进程自己在运行时决定和注册,操作系统在信号到达时,会参考这张表来判断该信号应该如何被处理。

5. 如何理解 OS 向进程发送信号?能否描述一下完整的发送处理过程?

完整过程如下:

  • 信号产生:由其他进程调用 kill(pid, sig),或者 OS 检测到某种条件(如段错误)决定发送信号。
  • 内核接收信号请求:内核确认目标进程存在,并判断权限是否允许发送。
  • 挂起信号:
    • 内核将信号标记到目标进程的 挂起信号集 中。
    • 如果进程正在屏蔽该信号,则不立即处理,只是挂起。
  • 进程调度时检查信号:
    • 每次返回用户态或系统调用结束时,内核会检查进程的挂起信号集。
    • 如果存在未屏蔽的信号,则:
      • 暂停当前执行
      • 调用信号处理函数(若注册)
      • 或执行默认处理动作(终止、忽略等)
  • 处理完成后,恢复原执行流。

流程图如下:

在这里插入图片描述

五、阻塞信号

1. 信号常见概念

  • 实际执行信号的处理动作称为 信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为 信号未决(Pending)
  • 进程可以选择阻塞(Block)某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

2. 在内核中的表示

信号在内核中的表示示意图:

在这里插入图片描述

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。

在下图的例子中:

  • SIGHUP 信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT 信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • SIGQUIT 信号未产生过,一旦产生 SIGQUIT 信号将被阻塞,它的处理动作是用户自定义函数 sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1 允许系统递送该信号一次或多次。

Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。

在这里插入图片描述

你是否还记得 signal(signo, handler) 函数?它的作用是针对特定信号设定专门的回调(捕捉)方法。

下面以 3 号信号为例详细说明,当调用 signal(3, handler) 时,系统会进行如下操作:

  • 在内核维护的 handler[32] 数组里查找 3 号信号对应的位置。由于信号编号从 1 开始,所以 3 号信号存于数组的索引 3 处。
  • 把用户定义的信号处理函数 handler 的地址,写入到 handler[32] 数组索引为 3 的位置。

当 3 号信号产生时,系统会按以下步骤处理:

  • 在内核的 pending 位图里,将对应 3 号信号的比特位置为 1。
  • 若该信号未被阻塞(即 block 位图中对应位为 0),操作系统就会把这个信号传递给进程。
  • 信号传递时,操作系统依据 pending 位图中被置为 1 的位,反推出信号编号。
  • 通过信号编号访问 handler[32] 数组,获取事先注册的处理函数并执行。

这里的 pending 位图、block 位图和 handler 数组,是内核为每个进程都设置好的数据结构。借助系统调用(像 sigaction ),用户进程能够操作这些结构,进而构建起完整的信号处理机制。

总结如下:

  • 信号即便还未产生,也能够预先将其阻塞,这是合理可行的。
  • 进程能够识别并处理信号,依靠的是内核为每个进程维护的三种数据结构:
    • pending 位图:其作用是记录已经产生但尚未被处理的信号。
    • block 位图:用于标记被阻塞(暂时不处理)的信号。
    • handler 数组:里面存储着每个信号对应的处理函数指针。

这三种结构协同工作,实现了进程对信号的识别与响应。

3. sigset_t

从上图来看,每个信号只有一个 bit 的未决标志,非 0 即 1,不记录该信号产生了多少次,阻塞标志也是这样表示的。

因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集,这个类型可以表示每个信号的 “有效”“无效” 状态。

  • 在阻塞信号集中 “有效”“无效” 的含义是:该信号是否被阻塞。

  • 在未决信号集中 “有效”“无效” 的含义是:该信号是否处于未决状态。

阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的 “屏蔽” 应该理解为阻塞而不是忽略。

4. 信号集操作函数

sigset_t 类型对于每种信号用一个 bit 表示 “有效”“无效” 状态,至于这个类型内部如何存储这些 bit 则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 sigset_ t 变量,而不应该对它的内部数据做任何解释,比如用 printf 直接打印 sigset_t 变量是没有意义的。

系统调用函数如下:

#include <signal.h>int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo); 

含义如下:

  • 函数 sigemptyset 初始化 set 所指向的信号集,使其中所有信号的对应 bit 清零,表示该信号集不包含任何有效信号。
  • 函数 sigfillset 初始化 set 所指向的信号集,使其中所有信号的对应 bit 置位,表示该信号集的有效信号包括系统支持的所有信号。
  • 注意,在使用 sigset_ t 类型的变量之前,一定要调用 sigemptysetsigfillset 做初始化,使信号集处于确定的状态。初始化 sigset_t 变量之后,就可以再调用 sigaddsetsigdelset 在该信号集中添加或删除某种有效信号。

这四个函数都是成功返回 0,出错返回 -1。sigismember 是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回 1,不包含则返回 0,出错返回 -1。

5. sigprocmask

调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。

#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 

返回值:若成功则为 0,若出错则为 -1。

如果 oset 是非空指针,则读取进程的当前信号屏蔽字通过 oset 参数传出。
 
如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how 指示如何更改。
 
如果 oset 和 set 都是非空指针,则先将原来的信号屏蔽字备份到 oset 里,然后根据 set 和 how 参数更改信号屏蔽字。

假设当前的信号屏蔽字为 mask,下表说明了 how 参数的可选值。

在这里插入图片描述

如果调用 sigprocmask 解除了对当前若干个未决信号的阻塞,则在 sigprocmask 返回前,至少将其中一个信号递达。

6. sigpending

读取当前进程的未决信号集,通过 set 参数传出。调用成功则返回 0,出错则返回 -1。

#include <signal.h>int sigpending(sigset_t *set);

下面用刚学的几个函数做个实验,代码如下:

#include <iostream>
#include <vector>
#include <signal.h>
#include <unistd.h>using namespace std;#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31static void show_pending(const sigset_t &pending)
{for (int signo = MAX_SIGNUM; signo >= 1; signo--){// 判断当前的signo信号是否被pendingif (sigismember(&pending, signo)){cout << "1";}elsecout << "0";}cout << "\n";
}int main()
{// 1. 先尝试屏蔽指定的信号sigset_t block, oblock, pending;// 1.1 初始化sigemptyset(&block);sigemptyset(&oblock);sigemptyset(&pending);// 1.2 添加要屏蔽的信号sigaddset(&block, BLOCK_SIGNAL); // 屏蔽2号信号// 1.3 开始屏蔽, 设置进内核, 即进程的PCB中sigprocmask(SIG_SETMASK, &block, &oblock);// 2. 遍历打印所有的pending信号集while (true){// 2.1 初始化sigemptyset(&pending);// 2.2 获取pending信号集sigpending(&pending);// 2.3 打印它show_pending(pending);// 3. 慢一点打印sleep(1);}return 0;
}

运行结果:

在这里插入图片描述

  • 每行 31 个字符,表示信号 1~31 的挂起状态,从高位(信号 31)打印到低位(信号 1)。
  • ^C 是你按下 Ctrl+C(发送 SIGINT)的表现。
  • 因为信号 2 被阻塞了,所以不能终止程序,而是进入 pending 状态。
  • 所以我们看到:
 0000000000000000000000000000010↑↑信号2被挂起
  • 连续打印了几次 ...0010,表示 SIGINT 一直处于挂起状态(直到被解除阻塞或处理)。
  • 最后输入 ^\ 是你按下 **Ctrl + **(发送 SIGQUIT,信号3),这是未被阻塞的,会终止程序(显示 Quit)。

上面是由 0 置1,再来看看由 1 置 0 的过程,也就是说:重新设置恢复,我就应该看到这个信号,它在进程从内核态到用户态返回时,它就应该要进行我们对应的信号递达,然后我们就应该看到它的二进制序列的第二个位置由 0 变成 1,再由 1 再变成 0。

代码如下:

#include <iostream>
#include <vector>
#include <signal.h>
#include <unistd.h>using namespace std;#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31static void show_pending(const sigset_t &pending)
{for (int signo = MAX_SIGNUM; signo >= 1; signo--){// 判断当前的signo信号是否被pendingif (sigismember(&pending, signo)){cout << "1";}elsecout << "0";}cout << "\n";
}static void myhandler(int signo)
{cout << signo << " 号信号已经被递达!!!" << endl;
}int main()
{// 自定义捕捉2号信号signal(BLOCK_SIGNAL, myhandler);// 1. 先尝试屏蔽指定的信号sigset_t block, oblock, pending;// 1.1 初始化sigemptyset(&block);sigemptyset(&oblock);sigemptyset(&pending);// 1.2 添加要屏蔽的信号sigaddset(&block, BLOCK_SIGNAL); // 屏蔽2号信号// 1.3 开始屏蔽, 设置进内核, 即进程的PCB中sigprocmask(SIG_SETMASK, &block, &oblock);// 2. 遍历打印所有的pending信号集int cnt = 6;while (true){// 2.1 初始化sigemptyset(&pending);// 2.2 获取pending信号集sigpending(&pending);// 2.3 打印它show_pending(pending);// 3. 慢一点打印sleep(1);if (cnt-- == 0){sigprocmask(SIG_SETMASK, &oblock, &block);cout << "恢复对信号的屏蔽, 即不屏蔽任何信号" << endl;}}return 0;
}

运行结果:

在这里插入图片描述

六、捕捉信号

我们知道,信号产生的时候,不会被立即处理,而是在合适的时候。

那么什么时候才算合适呢?其实是从内核态返回用户态的时候,进行处理!

当进行系统调用或者进程切换的时候,就会进入到内核态!

1. 知识点一

在操作系统里,用户态和内核态是两个重要的概念。当我们日常编写的代码被编译运行后,通常处于用户态。在这种状态下,若要访问内核功能或者硬件资源,就需要借助系统调用。不过,普通用户没办法直接以用户态的身份去执行系统调用,而是要先从用户态转变为内核态才行。

这里要明确的是,虽然表面上看是进程在执行系统调用,但实际上执行这些操作的是内核。由于系统调用的过程相对复杂,会消耗较多的时间,所以在进行编程时,应尽量避免频繁地进行系统调用,以此来提升程序的运行效率。

在这里插入图片描述

那用户态和内核态到底是什么呢?又怎样判断当前处于用户态还是内核态呢?

2. 知识点二

当进程真正开始执行时,必然要把自身的上下文信息传递给 CPU。而 CPU 里存在着数量众多的寄存器,这些寄存器通常可分为两类:

  • 可见寄存器(像 eax、ebx、ecx 等)
  • 不可见寄存器(例如状态寄存器)

那些与当前进程紧密相连的信息,都属于进程的上下文数据。

在这里插入图片描述

实际上,CPU内部还有不少在进程里有着特定用途的寄存器。

  • 有一类被称作 correct 的寄存器,它的作用是在寄存器里直接指向当前正在运行进程的 PCB。
  • 还有一些寄存器能够直接保存当前进程用户级页表,也就是指向页表的起始地址。
  • 另外有个名为 CR3 的寄存器,其内部的标志位可用来表明当前进程的运行级别:
    • 0 代表内核态
    • 3 代表用户态

3. 知识点三

思考这样一个问题:我作为一个进程,怎么会跑到操作系统的内核里去执行方法呢?

我们都知道,task_struct 是直接指向当前进程的地址空间 mm_struct 的。随后,mm_struct 会通过页表映射到与之对应的物理内存的特定位置,如此一来,便能访问相应的代码和数据了。
 
以 32 位操作系统为例,mm_struct 的总大小是 4GB,其中用户空间占据了 0~3GB 的范围,而内核空间则占了 3~4GB,这个内核空间就是供当前进程去映射操作系统的。
 
每个进程都拥有各自独立的用户级页表,也就是说,每一个进程都需要凭借自己对应的虚拟地址,经过自身的用户级页表,被映射到对应的不同物理内存位置。由于每个进程的页表不一样,映射关系也各有不同,所以每个进程能够确保自身的独立性。

在操作系统内部,还维护着一张内核级页表,这张表实际上是操作系统为了维护从虚拟到物理之间的操作系统级别代码而构建的一张内核级映射表。

在开机时,操作系统会被加载到内存当中,并且操作系统在物理内存里只会存在一份,而进程及其代码却可以有多个副本。
 
因为操作系统在物理内存当中只有一份,所以它的代码和数据在系统内是唯一的,也就是在内存里仅有一份,这也就决定了内核级页表的相关情况。
 
最终,当 当前进程在 mm_struct 中映射这 1GB 的内核空间时,会把内核的代码和数据映射到对应的物理内存中的 1GB 空间里,这个时候我们只需要使用内核级页表就可以了,所以内核级页表只需要一份就足够了。
 
也可以这样理解,在 CPU 内部有一个寄存器,它能够直接指向我们对应的操作系统的内核级页表,在后续进行切换时,这个寄存器是不会改变的。
 
因此,每个进程都可以在自己的地址空间特定区域内,通过内核级页表的方式去访问操作系统的代码和数据。

每一个进程都有自己的地址空间,用户空间是独占的,而内核空间则被映射到了每一个进程的 3~4GB 的范围,那么进程要访问操作系统的接口,其实只需要在自己的地址空间上进行跳转就行。

例如,当我们要进行系统调用时,由于操作系统的系统接口其实是可以通过内核级页表以及这 3~4GB 的空间,直接让进程看到的,所以在代码当中调用所谓的系统调用时,其实就是在自己的上下文当中,从用户空间的正文代码段跳转到内核空间区域,找到对应的方法,在执行时,要借助内核级页表来找到操作系统的代码和数据,执行完之后再返回到用户空间的代码处,继续往后运行。

所以,执行操作系统的代码在自己的上下文当中就能够完成了!

在这里插入图片描述

4. 知识点四

每个进程都拥有 3~4GB 的内核空间,并且所有进程会共享一个内核级页表。无论进程怎样进行切换,这 3~4GB 的内核空间都不会发生改变。换而言之,0~3G 的用户空间是属于进程自身的,而 3~4G 的内核空间则归操作系统所有。

那么,作为用户,凭什么能够访问指向内核的接口呢?其实很简单,必须要确保我们当前进程在 CPU 的 CR3 寄存器内,其所对应的运行级别处于内核态才行。
 
不妨思考一下:在调用系统调用接口之前,进程肯定是处于用户态的,那它是什么时候转变为内核态的呢?不用担心,当你进行系统调用接口操作时,起始位置会负责完成这个状态转变的工作。

总结一下就是:假如我是一个进程,那我是怎样进入操作系统内部去执行相关操作的呢?答案并不复杂。

  • 首先,我们进程凭借自身的 3~4G 地址空间,是能够访问到操作系统相关的代码与数据的。
  • 其次,我们的操作系统内部维护着一张公共的内核级页表,所有进程都共享这张页表,所以不管进程如何切换,当前进程始终都能够访问到内核的代码与数据。
  • 接着,在进行相应切换的时候,我们需要将自身的状态从用户态切换为内核态。
  • 然后,再在自身地址空间的范围内进行跳转,去执行系统调用,等执行完毕后再返回就可以了。

5. 信号的捕捉

捕捉信号的完整流程:进程正在执行系统调用,由用户态直接进入到内核态,然后它执行对应的方法,检测我们进程相关的信号,然后发现是否需要处理,需要处理就跑过去,直接看着方法处理完再返回内核,再直接从内核态返回到用户态,再继续向后执行好。

如下图所示:

在这里插入图片描述

6. 内核如何实现信号的捕捉

如果信号的处理动作是用户自定义函数(myhandler),在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂。

举例如下:

用户程序注册了 SIGQUIT 信号的处理函数 sighandler。当前正在执行 main 函数,这时发生中断或异常切换到内核态。 在中断处理完毕后,要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达。
 
内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数,sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
 
sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。

7. sigaction

sigaction 函数可以读取和修改与指定信号相关联的处理动作。

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact); 
  • 调用成功则返回 0,出错则返回 -1。
  • signo 是指定信号的编号。
  • 若 act 指针非空,则根据 act 修改该信号的处理动作。
  • 若 oact 指针非空,则通过 oact 传出该信号原来的处理动作。
  • act 和 oact 指向 sigaction 结构体。

代码如下:

#include <iostream>
#include <stdio.h>
#include <vector>
#include <signal.h>
#include <unistd.h>using namespace std;void myhandler(int signo)
{cout << "get a signo: " << signo << " 号信号正在处理中..." << endl;
}int main()
{// 自定义捕捉2号信号struct sigaction act, oact;act.sa_handler = myhandler;act.sa_flags = 0;sigemptyset(&act.sa_mask); // 当我们正在处理某一种信号的时候, 我们也想顺便屏蔽其他信号, 就可以添加到这个sa_mask中//sigaddset(&act.sa_mask, 3); // 比如处理2号信号的同时, 屏蔽3号信号sigaction(SIGINT, &act, &oact);while (true)sleep(1);return 0;
}

运行结果

在这里插入图片描述

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

七、可重入函数

如下图所示:

在这里插入图片描述

main 函数调用 insert 函数向一个链表 head 中插入节点 node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到 sighandler 函数,sighandler 也调用 insert 函数向同一个链表 head 中插入节点 node2,插入操作的两步都做完之后从 sighandler 返回内核态,再次回到用户态就从 main 函数调用的 insert 函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。

结果是,main 函数和 sighandler 先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。

像上例这样,insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数。反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。

想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了 malloc 或 free,因为 malloc 也是用全局链表来管理堆的。
  • 调用了标准 I/O 库函数。标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。

八、volatile

该关键字在 C 语言当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下。

代码实现

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h> 
#include <sys/types.h>
#include <sys/wait.h>using namespace std;int quit = 0;static void myhandler(int signo)
{printf("%d号信号, 正在被捕捉!\n", signo);printf("quit: %d", quit);quit = 1;printf(" -> %d\n", quit);
}int main()
{signal(2, myhandler);while (!quit);printf("注意, 我是正常退出的!\n");return 0;
}

运行结果

在这里插入图片描述

我们现在加一个 -O3 选项,再继续编译运行:

在这里插入图片描述

我把这个进程 kill 了四次,并且此时 quit 已经由 0 变为 1 了,为什么没有退出呢???

优化情况下,键入 CTRL + C(2号信号)被捕捉,执行自定义动作,修改 quit=1,但是 while 条件依旧满足,进程继续运行!但是很明显 quit 肯定已经被修改了,但是为何循环依旧执行?

很明显,while 循环检查的 quit,并不是内存中最新的 quit,这就存在了数据二异性的问题。while 检测的 quit 其实已经因为优化,被放在了 CPU 寄存器当中。

如何解决呢?此时需要 volatile 关键字:

  • volatile 的作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
volatile int quit = 0;

用大白话说就是,while 循环在这里做检测,虽然这个 quit 不会在 main 函数当中的执行流里面被修改,但是呢,以后在检测 quit 的时候,请不要给我优化到寄存器里,而是每一次检测都要时时刻刻从内存里读,要保持内存的可见性,而不是每次读都通过寄存器来覆盖我们物理内存当中的某个变量。

此时再向该进程发生 2 好信号就能正常退出了!!!

在这里插入图片描述

九、SIGCHLD 信号

我在前面讲过用 waitwaitpid 函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。

其实,子进程在终止时会给父进程发 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 清理子进程即可。

SIGCHLD 是 17 号信号,代码如下:

#include <iostream>
#include <cstdio>
#include <vector>
#include <signal.h>
#include <unistd.h>using namespace std;static void myhandler(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()
{// 捕捉17号信号signal(SIGCHLD, myhandler);printf("我是父进程, pid: %d, ppid: %d\n", getpid(), getppid());pid_t id = fork();if (id == 0){printf("我是子进程, pid: %d, ppid: %d, 我要退出啦!\n", getpid(), getppid());Count(5);exit(1);}while (1)sleep(1);return 0;
}

运行结果

在这里插入图片描述

那么此时,父进程收到 SIGCHLD 以后,假设有多个子进程,那么父进程就可以 while 循环调用 waitpid,把 waitpid 里面的 pid 设为 -1,那么代表的就是父进程会等待任意一个子进程退出。

代码如下

#include <iostream>
#include <cstdio>
#include <vector>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>using namespace std;#define CHILD_NUM 2// 信号处理函数
static void myhandler(int signo)
{pid_t ret;while ((ret = waitpid(-1, NULL, WNOHANG)) > 0){printf("父进程 %d 捕捉到 %d 号信号,成功回收子进程 %d\n", getpid(), signo, ret);sleep(1);}
}// 每个子进程执行不同任务(现在只是一条简单输出)
void do_task(int task_id)
{printf("子进程 %d 执行任务:我是编号为 %d 的任务\n", getpid(), task_id);printf("\n");
}int main()
{// 注册信号处理signal(SIGCHLD, myhandler);printf("我是父进程, pid: %d\n", getpid());cout << endl;for (int i = 0; i < CHILD_NUM; ++i){pid_t id = fork();if (id == 0){// 子进程printf("子进程 %d 开始执行任务,ppid: %d\n", getpid(), getppid());do_task(i);sleep(3);exit(1);  // 子进程正常退出}}// 父进程等待子进程退出while (1)sleep(1);return 0;
}

运行结果

在这里插入图片描述

事实上,由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction 将 SIGCHLD 的处理动作置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用 sigaction 函数自定义的忽略通常是没有区别的,但这是一个特例。

代码如下

#include <iostream>
#include <cstdio>
#include <vector>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>using namespace std;#define CHILD_NUM 2// 信号处理函数
static void myhandler(int signo)
{pid_t ret;while ((ret = waitpid(-1, NULL, WNOHANG)) > 0){printf("父进程 %d 捕捉到 %d 号信号,成功回收子进程 %d\n", getpid(), signo, ret);sleep(1);}
}// 每个子进程执行不同任务(现在只是一条简单输出)
void do_task(int task_id)
{printf("子进程 %d 执行任务:我是编号为 %d 的任务\n", getpid(), task_id);printf("\n");
}int main()
{// 显示的对SIGCHLD进行忽略signal(SIGCHLD, SIG_IGN);printf("我是父进程, pid: %d\n", getpid());cout << endl;for (int i = 0; i < CHILD_NUM; ++i){pid_t id = fork();if (id == 0){// 子进程printf("子进程 %d 开始执行任务,ppid: %d\n", getpid(), getppid());do_task(i);sleep(3);exit(1);  // 子进程正常退出}}// 父进程等待子进程退出while (1)sleep(1);return 0;
}

运行结果

在这里插入图片描述

此方法对于 Linux 可用,但不保证在其它 UNIX 系统上都可用。

http://www.dtcms.com/a/298306.html

相关文章:

  • Linux 桌面市场份额突破 5%:开源生态的里程碑与未来启示
  • [MMU]四级页表查找(table walk)的流程
  • 流式接口,断点续传解决方案及实现
  • 前端核心进阶:从原理到手写Promise、防抖节流与深拷贝
  • iOS 抓包工具有哪些?模块化功能解析与选型思路
  • 容器化环境下的服务器性能瓶颈与优化策略
  • ubuntu22.04.4锁定内核应对海光服务器升级内核无法启动问题
  • Qt Mysql linux驱动编译
  • Google AI Mode 解析:生成式搜索功能的核心机制与应用场景
  • PowerDesigner安装教程(附加安装包)PowerDesigner详细安装教程PowerDesigner 16.6 最新版安装教程
  • Nacos-服务注册,服务发现(一)
  • 【模型剪枝1】结构化剪枝论文学习笔记
  • 如何理解SpringBoot starters的自动装配
  • 地下隧道管廊结构健康监测系统 测点的布设及设备选型
  • 1 51单片机-C51语法
  • 4麦 360度定位
  • docker搭建ray集群
  • SAP-PP-MRPLIST
  • MybatisPlus-17.扩展功能-JSON处理器
  • 【57】MFC入门到精通——MFC 多线程编程总结
  • 【lucene】自定义tokenfilter 自带payload
  • String类常用方法练习
  • synchronized锁普通方法和锁静态方法有什么区别?
  • RPG66.制作死亡画面(二):实现按钮逻辑
  • 毕业论文参考文档(免费)—DHT11 温湿度传感器的硬件与软件系统设计
  • Pydantic 配置管理
  • vehicle_template | vehicle_seat_addon
  • 功能安全实战系列14-英飞凌TC3xx MBIST检测理论篇
  • 【大模型关键技术】Transformer 前沿发展
  • 模糊匹配fuzzywuzzy