• 【Linux】进程信号(学习复习兼顾)


    🏠 大家好,我是 兔7 ,一位努力学习C++的博主~💬

    🍑 如果文章知识点有错误的地方,请指正!和大家一起学习,一起进步👀

    🚀 如有不懂,可以随时向我提问,我会全力讲解~

    🔥 如果感觉博主的文章还不错的话,希望大家关注、点赞、收藏三连支持一下博主哦~!

    🔥 你们的支持是我创作的动力!

    🧸 我相信现在的努力的艰辛,都是为以后的美好最好的见证!

    🧸 人的心态决定姿态!

    🚀 本文章CSDN首发!

    目录

    0. 前言

    1. 信号入门

    1.1 生活角度的信号

    1.2 技术应用角度的信号

    1.3 注意

    1.4 信号概念

    1.5 用kill -l命令可以察看系统定义的信号列表

    1.6 信号处理常见方式概览

    1.7 总结

    2. 产生信号

    2.1 通过终端按键产生信号

    Core Dump

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

    2.3 由软件条件产生信号

    2.4 硬件异常产生信号

    3. 总结思考一下

    4. 阻塞信号

    4.1 信号其他相关常见概念

    4.2 在内核中的表示

    4.3 sigset_t

    4.4 信号集操作函数

    4.4.1 sigprocmask

    4.4.2 sigpending

    5. 捕捉信号

    5.1 内核如何实现信号的捕捉

    5.2 sigaction

    6. 可重入函数

    7. volatile

    8. SIGCHLD信号


    0. 前言

            此博客为博主以后复习的资料,所以大家放心学习,总结的很全面,每段代码都给大家发了出来,大家如果有疑问可以尝试去调试。

            大家一定要认真看图,图里的文字都是精华,好多的细节都在图中展示、写出来了,所以大家一定要仔细哦~

            感谢大家对我的支持,感谢大家的喜欢, 兔7 祝大家在学习的路上一路顺利,生活的路上顺心顺意~!

    1. 信号入门

    1.1 生活角度的信号

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

    接下来先说一下理解信号的前提知识:

            这个是通过自然世界中人对信号的基本理解。

            接下来就是对进程进行分析了。

            那么此时虽然我们还不是很理解信号,但是我们通过对信号产生的生命周期的解释,也对信号有了一个大体的认识。

            首先带大家看一下信号:

            我们通过这种方式可以找到,而且看到信号都是宏。

            在正式讲解前先回答一个问题:信号是如何发送的以及如何记录的?

    1.2 技术应用角度的信号

    1. 用户输入命令,在Shell下启动一个前台进程。

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

            接下里就通过例子,让大家更容易的去理解:

            当我们用 ctrl+c 这个组合键结束这个进程的本质是,操作系统识别到 ctrl+c 这个组合键,操作系统将 ctrl+c 解释成了 2号 信号,也就是 SIGINT 。

    下面来证明一下:

            我们知道处理信号有三种方案

    1. 默认行为
    2. 自定义行为
    3. 忽略信号

            我们刚来处理  ctrl+c ,我们的默认动作叫做 终止进程 。那么我们为了能够让信号自定义,那么就先来看一个接口:

            我们可以看到,第一个参数就是信号编号,也就是信号中的 1-31 。

            第二个参数的类型可以看到是一个函数指针,而且是一个回调函数,就是相当于我们可以通过 signal ,提前向进程注册一个对信号的处理方法。

            所以我们要想自定义信号就必须要设置一个返回这为 void ,一个参数为 int 的函数,然后将函数名为参数给 signal 当实参。

            我们可以看到,当我们 ctrl+c 的时候,其实是操作系统识别到向目标进程发送 2号信号,本质就是修改 2号 信号内部的位图,将第二个位图由 0 制 1了。所以进程是在运行中的一瞬间就处理了这个信号,然后就打印出了那句话。

            默认 ctrl+c 不就是终止进程么,这次不终止了是因为我们将默认行为改成了自定义行为。 

             我们可以看到,发送二号信号都被进程捕捉了。(SIGINT就是 2号 信号,这里是为了证明输入宏也可以)

            那么我们输入其他信号呢?

            我们可以看到程序退出了,这是因为我们只捕捉了 2号 信号,没有捕捉 3号 信号。

    1.3 注意

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

    Ctrl+C 产生的信号只能发给前台进程:

            我们可以看到,如果后面加上 & 后,进程就在后台进行了,当我们 CTRL+C 的时候程序本应该停止或者发送我们刚才的字符串,但是它什么都没干,所以我们只能通过用 kill 进行停止此进程,换句话说, Ctrl+C 产生的信号只能发给前台进程。

            而且要说明一点,当我们在前台运行这个进程的时候:

            我们会发现:我们在发送 ls pwd top 这种命令时,所有的命令都没有效果了,这时因为在 Linux 中只允许有一个前台进程。

            bash 是默认的前台进程,入宫 myproc 变成前台进程了,那么其中的 bash 就不是了,那么也就没有办法解释这种命令了,但是... ...

            我们将 myproc 放在后台进行运行后,我们就可以运行命令了,这是因为 bash 此时是前台进程,可以对命令进行解析。只不过后台运行的 myproc 打印的数据会对我们进行干扰,

    1.4 信号概念

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

    1.5 用kill -l命令可以察看系统定义的信号列表

    • 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义 #define SIGINT 2
    • 编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal

    Term 是 terminal(终端)

    Ign 是 ignore(忽略)

    1.6 信号处理常见方式概览

    (sigaction函数稍后详细介绍),可选的处理动作有以下三种:

    1. 忽略此信号。
    2. 执行该信号的默认处理动作。
    3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。

    1.7 总结

            通过上面我们已经知道信号是什么了,那么接下来就说一下:

    为什么要有信号?

            因为计算机大多是为了解决人的问题,所以大多部分计算机的处理逻辑也是从人的生活中来的,而且大家也可以发现我们人在生活中处理事件的时候,我们无外乎在处理两种事件:一种是常规事件(按部就班的),第二种是突发事件。那么人就要有能够处理突发事件的能力,因为我们必须要处理,因为事情永远是推着人走的。

            同样的,当一个进程正在运行时,它也可能会遇到突发状况,比方说突然收到了 CTRL+C ,突然 除 0 了,导致程序出现错误了。

            所以为什么要有信号,本质是要让程序具备处理突发事件的能力,人如此,进程也如此。

            那么此时下面就要进行说明怎么办了。

    2. 产生信号

    2.1 通过终端按键产生信号

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

            我们可以看到,我们用 CTRL+ \ 是对应的 3号 信号:SIGQUIT。我们通过上面给的图可以看到 3号 信号默认的动作是 Core ,表示的是在结束的时候它有一个动作叫做核心转储。

            因为我用的是云服务器,但是云服务器的核心转储是不明显的,默认是关掉的。

            接下来我们看一下:

            我们要注意的是黄色围起来的这一项,我们可以看它的大小是 0  。

            那么我们接下来来设置一下:

            当我们设置完我们就可以看到 core file size 的大小就变成个了 10240,此时叫做将它的核心转储直接打开(默认是0的话就意味着它是关闭状态)。

            那么继续看:

            我们可以看到,当我们再进行 CTRL+ \ 的时候也退出了,而且后面有一个 (core dumped) ,而且当我们查看当前目录下多了一个 core.31923 这个临时文件,这个 31923 数字叫做发生这次核心转储的进程的 id 。

            解释一下:一个进程在终止的时候有很多种终止方式,其中 Terminal 一般是直接退出,也可以理解成是我们手动的让它退出了,但不做任何转储文件的 dump(转储) ,而如果我们自己打开了核心转储,并且我们收到了信号(不同的信号有不同的作用,不同的信号是一种不同的错误类别),而有些信号是需要进行核心转储的。

            比方说,代码运行的时候出错了,我们关心的是代码因为什么出错了,我们之前讲的代码退出的三个方式:1. 代码跑完结果对,2. 代码跑完结果不对,3. 代码运行中的时候出错。前两个最起码是跑完了,最后根据退出码就能判断哪里有问题,那么当第三种:代码运行中的时候出错了,我们也要有办法判定是什么原因出错了。

            我们在平时出现第三种情况的时候,我们一般是通过调试来判断哪里出现了问题,但其实还有 Linux 中还有一种方法就是通过核心转储功能:把进程在内存中的核心数据转储到磁盘上,core.pid -> 核心转储文件。目的是为了调试、定位问题。一般云服务器是属于线上生产环境,默认是关闭的(有的小伙伴可能用的虚拟机,虚拟机是默认打开的)。

            打开的状况我们上面那也进程了演示。那么这里有一个问题:

    为什么在云服务器上核心转储功能默认是关闭的呢?

            比方说我们在服务器上写了一个网络服务或者定期执行的一个服务,这个服务可能因为某种异常而挂掉,如果你打开了核心转储,那么挂掉之后会在本地的磁盘文件中生成 corn 文件,这个无可厚非,但是一般大的互联网公司在服务挂掉的时候,最重要的事情不是在乎是因为什么原因挂掉的,重要的是想尽快的让它恢复正常。因为出 BUG 不是经常事件,而是偶尔的事情。所以重要的是先让服务跑起来,不要让公司受到太大的影响。当服务恢复之后再进行对故障的排除工作。

            如果是小问题的话那么就先让服务恢复出来,然后再进行检查,但是如果出现了大问题,而且有一个一崩就重启的功能,那么一重启起来就崩,崩了就重启,如此往复。就会出现大量的 core file 文件:

             而且我们可以看到,这种文件一个都要 1MB 多,每个都不小,要说重启很长事件,那么当我们去排查的时候会发现 core 文件将某个分区或者磁盘文件都沾满了,最终导致服务想重启都没法重启,甚至操作系统都挂了,所以默认是关闭的。


    Core Dump

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


            那么我们就来使用一下 corn dumped 级别的调试:

            注意我们在编译的时候一定要加上 -g 选项,因为core dumped 文件需要使用 gdb 。

             我们可以看到虽然有警告,但是没有事情,因为就是故意写错的,我们会看到,1 秒之后,会看到有一个浮点型异常,而且出现了一个 core.8881 这个 core dumped 文件。

            当我们打开的时候会发现里面都是乱码,什么都看不到,因为它是直接把内存中的有效核心数据 dumped 到磁盘上(转储到磁盘上),这个是帮我们定位问题。

            接下来:

             我们通过 gdb 和 core.8881 文件找到了问题在 16 行,这样我们就快速的定位到了刚刚的代码是因为什么原因出错的,这个调试方法叫做事后调试,也就是当我们的程序崩溃了再进行调试。

            而且我们刚才看到的错误是 FLOATING POINT EXCEPTION :

            其中将 FLOATING POINT EXCEPTION 的首字母提出来就是 FPE,也就是 8号 信号。

            我们发现 除0 错误是代码错误,然后我们进程终止了。

    为什么C/C++进程会崩溃?

            本质就是因为收到了信号!


            那么接下来再实验一个:

              我们可以看到出现的是 11号 信号,Segmentation fault 段错误。

    那么为什么会受到信号?

            首先我们要知道,信号都是由OS发送的,那么OS又怎么识别到有进程触发了问题呢?

            OS在进行正常运行的时候发现CPU内有一个计算状态标志位发生了除0错误,然后操作系统就立马定位当前运行的哪个进程,所以就来进行终止。

            所以操作系统识别到了硬件错误,然后将这个硬件错误解释(包装)成信号发送给目标进程。

            其实本质就是找到这个进程的PCB,向目标的位图比特位由0制1,然后这个进程在合适的时候处理8号信号时默认就给"自己终止"了

     

     

            这下就能回答这个问题了:错误最终一定会在硬件层面上有所表表现,进而被OS识别到!所以进程最后才会崩溃。

             还记得之前的这幅图么,当时只用到了次低8位的退出码,和低7位的信号编号,但是没有用低8位这个 core dump 。

            接下来我们就看看这个 core dump :

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4 #include
    5. 5 #include
    6. 6 #include
    7. 12 int main()
    8. 13 {
    9. 14 if(fork() == 0){
    10. 15 printf("I am child ,pid: %d\n",getpid());
    11. 16 sleep(3);
    12. 17 int a = 1/0;
    13. 18 exit(1);
    14. 19 }
    15. 20 int status = 0;
    16. 21 waitpid(-1, &status, 0);
    17. 22 printf("exit code: %d, core dump: %d, signal: %d\n",
    18. 23 (status>>8)&0xff, (status>>7)&1, status&0x7f);
    19. 31 return 0;
    20. 32 }

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4 #include
    5. 5 #include
    6. 6 #include
    7. 11 //
    8. 12 int main()
    9. 13 {
    10. 14 if(fork() == 0){
    11. 15 printf("I am child ,pid: %d\n",getpid());
    12. 16 sleep(3);
    13. 17 int *p = NULL;
    14. 18 *p = 7;
    15. 19 exit(1);
    16. 20 }
    17. 21 int status = 0;
    18. 22 waitpid(-1, &status, 0);
    19. 23 printf("exit code: %d, core dump: %d, signal: %d\n",
    20. 24 (status>>8)&0xff, (status>>7)&1, status&0x7f);
    21. 32 return 0;
    22. 33 }

             所以这里是 core dump 的意思是:进程崩溃的时候,是否 core dump !

             但也不是所有信号退出的时候都会 core dump ,比如 2号 信号。


    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4 #include
    5. 5 #include
    6. 6 #include
    7. 7 void handler(int signo)//传来的就是信号编号
    8. 8 {
    9. 9 printf("get a signal! signo: %d\n",signo);
    10. 10 }
    11. 11
    12. 12 int main()
    13. 13 {
    14. 14 for(int signo = 1; signo < 32; signo++){
    15. 15 signal(signo, handler);
    16. 16 }
    17. 17 while(1){
    18. 18 sleep(1);
    19. 19 }
    20. 20 return 0;
    21. 21 }

            我们可以看到还有一些用过键盘可以组合的信号。

            然后当我们 CTRL+Z 之后,进程就被暂停了,但是我们可以查看:

            我们 myproc 正在运行,状态是 T 。如果我们想让它再运行起来,我们可以发送 CONTINUE 信号:

            这个不是重要,我想说的是这个进程一旦被暂停就会被放在后台,一旦放在后台我们若想查看后台任务可以用 jobs ,我们想让这个进程放在前台立马运行起来用 fg * 。

            而且我们发现:

            我们发送 2号 信号被捕捉了,但是发送 9号 信号的时候没有被捕捉。

            所以虽然代码中看起来是从 1 到 32 都捕捉,但实际上有些信号是不能被捕捉的,比如 9号 ,因为如果所有的信号都可以被捕捉,那么就可以把所有的信号忽略掉,那么这个进程就没有办法可以杀死了,即便是操作系统。

            所以操作系统允许捕捉,但不允许全部捕捉。

     

            还有一个问题就是当连续操作两次的时候会终止,可能是这个操作系统对这个操作做了特殊处理,其他的操作系统这么写代码是没有问题的,那么我们只要再捕捉信号的那个 handler 中再重新注册一下信号:

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4 #include
    5. 5 #include
    6. 6 #include
    7. 7 void handler(int signo)//传来的就是信号编号
    8. 8 {
    9. 9 printf("get a signal! signo: %d\n",signo);
    10. 10 signal(signo ,handler);
    11. 11 }
    12. 12
    13. 13 int main()
    14. 14 {
    15. 15 for(int signo = 1; signo < 32; signo++){
    16. 16 signal(signo, handler);
    17. 17 }
    18. 18 while(1){
    19. 19 sleep(1);
    20. 20 }
    21. 21 return 0;
    22. 22 }

             这样就可以了,但是这样就没有办法退出了,我们就新建一个 SSH 渠道进行终止就好了。

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

            我们前面学过:

    1. 通过键盘产生信号
    2. 程序异常导致硬件问题产生信号
    3. 系统调用

            当然最后一个正是要学的。

            我们要用到的接口是 kill :

            而且我们要使用的方式是通过:

            所以也就表明了,我们在函数实现的时候一定要传命令行参数

            为了更模拟 kill ,我们就将此文件目录添加到环境变量中。

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4 #include
    5. 5
    6. 6 void Usage(char *proc)
    7. 7 {
    8. 8 printf("Usage: %s pid signo\n",proc);
    9. 9 }
    10. 10
    11. 11 int main(int argc, char *argv[])
    12. 12 {
    13. 13 if(argc != 3){
    14. 14 Usage(argv[0]);
    15. 15 return 1;
    16. 16 }
    17. 17
    18. 18 pid_t pid = atoi(argv[1]);
    19. 19 int signo = atoi(argv[2]);
    20. 20
    21. 21 kill(pid, signo);
    22. 22 return 0;
    23. 23 }

            我们写了一个重复打印的 myproc ,然后通过 mykill.c 去用系统调用 kill 掉 myproc 进程,可以看到已经成功的使 myproc 退出。

            当然还有这个接口,这个接口是自己给自己发信号,那么接下来我们就来用用:

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4 #include
    5. 5
    6. 6 void handler(int signo)
    7. 7 {
    8. 8 printf("get a sig: %d\n",signo);
    9. 9 }
    10. 10
    11. 11 int main()
    12. 12 {
    13. 13 signal(2, handler);
    14. 14 while(1){
    15. 15 printf("I am a process, pid: %d\n",getpid());
    16. 16 sleep(1);
    17. 17 raise(2);
    18. 18 }
    19. 19 return 0;
    20. 20 }

            我们可以看到,每隔一秒都会发送一次 2号 信号,然后因为我们注册了 2号 信号,所以被进程接收然后进行自定义行为。

            当然还有一个调用:

            这个也是自己给自己发信号,是发 6号 信号(SIGABRT)。

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4 #include
    5. 5 #include
    6. 6
    7. 7 void handler(int signo)
    8. 8 {
    9. 9 printf("get a sig: %d\n",signo);
    10. 10 }
    11. 11
    12. 12 int main()
    13. 13 {
    14. 14 signal(6, handler);
    15. 15 while(1){
    16. 16 printf("I am a process, pid: %d\n",getpid());
    17. 17 sleep(1);
    18. 18 //raise(2);
    19. 19 abort();
    20. 20 }
    21. 21 return 0;
    22. 22 }

     

            我们可以看到,运行起来后,接收的是 6号 信号,而且接收到一次之后就退出了,可是我们上面明明对 6号 信号进行了捕捉。

            因为有些信号是可以被捕捉,有些信号不可以被捕捉, 6号 信号即被捕捉了,也被终止了,这就是 6号 信号,abort 的作用很像我们一直用到的 exit(),但是exit()是正常终止,而 abort() 本质上是通过信号来终止,是自己终止自己。但是要说明的是,exit() 本质上是函数,只要是函数就说明它可能会失败,而 abort 函数总是会成功(函数无返回值)。

    2.3 由软件条件产生信号

            我们之前的异常本质上是由软件上引起的,但最终引起的问题是在是在硬件上,也就是CPU的状态寄存器出了问题,MMU转化出了问题,所以最后我们就看到操作系统识别硬件出现了错误,然后转化成信号发送给进程。

            软件条件产生信号:在我们写管道那里的时候说,有一端是读端,有一端是写端,如果将读端关闭,写端一直写,那么写端就会被立刻终止。这样的原因就是写入的软件条件不满足,也就是当前管道是不允许你写入的,所以我们当时就受到了一个 SIGPIPE 这个信号,这个信号就是由于软件条件产生的信号,所以就是我们写入的条件不成熟,这就是软件条件。

            当然还有其他的软件条件,也就是我接下来要将的 alarm(闹钟) 函数。

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

    #include

    unsigned int alarm(unsigned int seconds);

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

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4 #include
    5. 5 #include
    6. 6
    7. 7 void handler(int signo)
    8. 8 {
    9. 9 printf("get a sig: %d\n",signo);
    10. 10 }
    11. 11
    12. 12 int main()
    13. 13 {
    14. 14 alarm(1);
    15. 15 int count = 0;
    16. 16 while(1){
    17. 17 printf("count is: %d\n",count++);
    18. 18 }
    19. 19 return 0;
    20. 20 }

             这个代码的意思是,1秒 后发送 14号 信号 SIGALRM ,然后在这 1秒 内看能进行多少次 count++ 并打印出来,我们可以看到,在六万左右,但是这其实不代表真实的速度,因为我们这里是在外设上打印了,就会慢很多。而且也有网络的原因,我们在网络上计算,然后再发送过来,就会慢。

            那么我们接下里再改一改:

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4 #include
    5. 5 #include
    6. 6
    7. 7 int count = 0;
    8. 8
    9. 9 void handler(int signo)
    10. 10 {
    11. 11 printf("count is: %d\n",count);
    12. 12 exit(1);
    13. 13 }
    14. 14
    15. 15 int main()
    16. 16 {
    17. 17 signal(14, handler);
    18. 18 alarm(1);
    19. 19 while(1){
    20. 20 count++;
    21. 21 }
    22. 22 return 0;
    23. 23 }

             我们可以看到,我们直接让它累加,然后最后再打印,就可以看到会加到这么大的数,这就是因为在累加的时候没有进行 IO ,所以我们可以得知,如果计算机在进行 IO 的时候,效率是有多低。

            我们要想每隔一段时间执行一次任务,那么这样就可以完成。

    2.4 硬件异常产生信号

            硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

            当然这段的操作我们在上面将信号的时候已经测试过了,所以这里就不进行测试了。

    3. 总结思考一下

    • 上面所说的所有信号产生,最终都要有OS来进行执行,为什么?
      答:OS是进程的管理者
    • 信号的处理是否是立即处理的?
      答:在合适的时候
    • 信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
      答:需要。记录在进程的PCB中,有对应的PCB位图
    • 一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
      答:知道:默认、自定义、捕捉。
    • 如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
      答:本质就是OS根据某种信号类别,直接去修改PCB位图中的 0 1 序列,进而达到发送信号的目的。

     

    4. 阻塞信号

    4.1 信号其他相关常见概念

    • 实际执行信号的处理动作称为信号递达(Delivery)
    • 信号从产生到递达之间的状态,称为信号未决(Pending)。
    • 进程可以选择阻塞 (Block )某个信号。
    • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
    • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

    4.2 在内核中的表示

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

    • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
    • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
    • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
      如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次而实时信号在递达之前产生多次可以依次放在一个队列里。

    4.3 sigset_t

            从上图来,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。后面会详细介绍信号集的各种操作。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

    4.4 信号集操作函数

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

    #include
    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 sigismemberconst sigset_t *set, int signo);   判断在不在
    • 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
    • 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
    • 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

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

            其实在 C90 版本是不存在布尔值的,只有宏,但是 C98 后就有了。

            这个栈是用户栈,这个空间是用户空间定义的,与用户空间对应的是内核空间,我们下面调用的清空、设置、添加,不会影响进程的任何行为。

            有的人可能认为不对啊,前面不是讲了可以通过信号集来对进程的 pending 、 block 表进行相关设置么,那为什么不会影响任何进程的行为呢。

            原因是因为我们这里的设置并没有设置进进程相关的比方说PCB内。所以我们需要通过这种参数,通过系统调用设置进操作系统,这个行为才能够影响操作系统。

            所以我们需要调用 sigprocmask 。

    4.4.1 sigprocmask

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

    #include

    int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

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

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

    4.4.2 sigpending

    #include

    sigpending

    读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。下面用刚学的几个函数做个实验。程序如下:

            这个函数主要是来获取当前进程的 pending 信号集,所以这个函数是一个输出型参数。

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4
    5. 5 void printfPending(sigset_t *pending)
    6. 6 {
    7. 7 int i = 1;
    8. 8 for(; i <= 31; i++){
    9. 9 if(sigismember(pending,i)){
    10. 10 printf("1 ");
    11. 11 }
    12. 12 else{
    13. 13 printf("0 ");
    14. 14 }
    15. 15 }
    16. 16 printf("\n");
    17. 17 }
    18. 18
    19. 19 int main()
    20. 20 {
    21. 21 //设置block信号集
    22. 22 sigset_t set, oset;//用户空间定义的变量
    23. 23 sigemptyset(&set);
    24. 24 sigemptyset(&oset);
    25. 25
    26. 26 sigaddset(&set, 2); //SIGINT
    27. 27
    28. 28 sigprocmask(SIG_SETMASK, &set, &oset);
    29. 29
    30. 30 //获取pending信号集
    31. 31 sigset_t pending;
    32. 32 while(1){
    33. 33 sigemptyset(&pending);
    34. 34 sigpending(&pending);
    35. 35
    36. 36 printfPending(&pending);
    37. 37 sleep(1);
    38. 38 }
    39. 39
    40. 40 return 0;
    41. 41 }

            我们可以看到,没发送 2号 信号前为全 0 ,但是发送完 2号 信号后,第二个位置由 0 制 1 了。

            这说明操作系统向进程发送了信号,但是当前这个信号无法被立即递达,那么这个信号就处于 pending 状态。处于 pending 状态我们就可以通过打印获取到了。

            当然我们也可以通过某种方式恢复:

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4
    5. 5 void printfPending(sigset_t *pending)
    6. 6 {
    7. 7 int i = 1;
    8. 8 for(; i <= 31; i++){
    9. 9 if(sigismember(pending,i)){
    10. 10 printf("1 ");
    11. 11 }
    12. 12 else{
    13. 13 printf("0 ");
    14. 14 }
    15. 15 }
    16. 16 printf("\n");
    17. 17 }
    18. 18
    19. 19 void handler(int signo)
    20. 20 {
    21. 21 printf("get signo: %d\n",signo);
    22. 22 }
    23. 23
    24. 24 int main()
    25. 25 {
    26. 26 signal(2, handler);
    27. 27
    28. 28 //设置block信号集
    29. 29 sigset_t set, oset;//用户空间定义的变量
    30. 30 sigemptyset(&set);
    31. 31 sigemptyset(&oset);
    32. 32
    33. 33 sigaddset(&set, 2); //SIGINT
    34. 34
    35. 35 sigprocmask(SIG_SETMASK, &set, &oset);
    36. 36
    37. 37 //获取pending信号集
    38. 38 sigset_t pending;
    39. 39 int count = 0;
    40. 40 while(1){
    41. 41 sigemptyset(&pending);
    42. 42 sigpending(&pending);
    43. 43
    44. 44 printfPending(&pending);
    45. 45 sleep(1);
    46. 46 count++;
    47. 47 if(count == 5){
    48. 48 sigprocmask(SIG_SETMASK, &oset, NULL);//恢复曾经的信号屏蔽字
    49. 49 printf("恢复信号屏蔽字\n");
    50. 50 }
    51. 51 }
    52. 52
    53. 53 return 0;
    54. 54 }

            我们可以看到,开始没收到 2号 信号的时候,处于全 0 的状态,收到之后 第二位由 0 制 1 了,然后隔几秒后又恢复曾经的信号屏蔽字,也就是全 0 状态。

            所以我们看到的结果就是这样子。

    5. 捕捉信号

    5.1 内核如何实现信号的捕捉

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

     

            这样就对内核态和用户态就有一个大致了解了。

     

            当然上面那幅图也可以用高数中的无穷来看:

    5.2 sigaction

    #include
    int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

    • sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回 -1 。signo 是指定信号的编号。若 act 指针非空,则根据 act 修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act 和 oact 指向sigaction结构体:
    • 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
            当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处
    理函数返回时自动恢复原来的信号屏蔽字。

     

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4 #include
    5. 5
    6. 6 void handler(int signo)
    7. 7 {
    8. 8 printf("get a signal: %d\n",signo);
    9. 9 }
    10. 10
    11. 11 int main()
    12. 12 {
    13. 13 struct sigaction act, oact;
    14. 14 memset(&act, 0, sizeof(act));
    15. 15 memset(&act, 0, sizeof(act));
    16. 16
    17. 17 act.sa_handler = handler;
    18. 18 act.sa_flags = 0;
    19. 19 sigemptyset(&act.sa_mask);
    20. 20
    21. 21 sigaction(SIGINT, &act, &oact);
    22. 22 while(1){
    23. 23 printf("I am a process!\n");
    24. 24 sleep(1);
    25. 25 }
    26. 26 return 0;
    27. 27 }

     

             我们可以看到,我们已经对 2号 信号进行了捕捉。

            如果我想恢复出来呢:

            我们可以看到,我们这要这样恢复一下就可以了。

    6. 可重入函数

            我们用到的各种STL,全部都是单进程,也就是一个执行流,如果是多执行流那么代码就有可能重复调用。

            最后我们可以看到 node2 这个节点丢失了,这个现象就叫做内存泄漏。

            而且STL里的大多也都是不可重入的。

    •  main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函 数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从 sighandler 返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
    • 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?

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

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

    7. volatile

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4 #include
    5. 5
    6. 6 int flag = 0;
    7. 7
    8. 8 void handler(int signo)
    9. 9 {
    10. 10 printf("get a signo: %d\n",signo);
    11. 11 flag = 1;
    12. 12 }
    13. 13
    14. 14 int main()
    15. 15 {
    16. 16 signal(2, handler);
    17. 17 while(!flag);
    18. 18 printf("Proc Normal Quit!\n");
    19. 19 return 0;
    20. 20 }

              我们可以看到,当我们运行起来的时候是不会停止的,然后我们通过键盘发送 2号 信号后,才会正常退出。没有问题。

            通过我们上面的学习,我们知道信号捕捉和 main 函数是两种执行流,有可能我们这个进程跑起来永远都不会收到二号信号,只有我们 CTRL+C 发送它才能收到二号信号。

            也就是说 main 执行流永远执行,而信号捕捉函数不一定会执行。

            但是 while 循环是在 main 函数中的,main 函数编译器在编译的时候,只能检测到 main 函数对 flag 的使用,flag 是全局变量,在运行时,编译器只能检测到 main 函数对 flag 没有任何更改操作(虽然在信号捕捉函数有,但是编译器识别不到,因为它们是两个执行流)。

            如果没有人改 flag ,如果编译器优化级别较高的时候,那么编译器会将 flag 优化成寄存器变量,或者将 flag 设置到寄存器里。

            所以如果编译器优化级别较高的时候,发送了二号信号,即使被捕捉了,也一直死循环不会退出。

            接下来就来优化一下:

            我们可以看到,我们只要在编译的时候加上 -O3 ,就会发生我们上面说到的那种情况。

            那么接下来就要用到 volatile 进行解决:

            我们可以看到,即使现在还是 -O3 的优化,我们还是可以通过发送 2号 信号来结束进程。

    这个 volatile 的意义是:

    1. 编译器编译时不要把变量放到寄存器里。
    2. 检测的时候一定不能只对 flag 的寄存器级别的检测,而是先从内存里读到 flag 的值到寄存器里再进行检测。

            所以它 volatile 是保持了内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作

    8. SIGCHLD信号

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

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

            请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定 义SIGCHLD信号的处理函数, 在其中调用wait获得子进程的退出状态并打印。

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

    1. #include
    2. #include
    3. #include
    4. void handler(int sig)
    5. {
    6. pid_t id;
    7. while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
    8. printf("wait child success: %d\n", id);
    9. }
    10. printf("child is quit! %d\n", getpid());
    11. }
    12. int main()
    13. {
    14. signal(SIGCHLD, handler);
    15. pid_t cid;
    16. if((cid = fork()) == 0){//child
    17. printf("child : %d\n", getpid());
    18. sleep(3);
    19. exit(1);
    20. }
    21. while(1){
    22. printf("father proc is doing some thing!\n");
    23. sleep(1);
    24. }
    25. return 0;
    26. }

            这里在 handler 循环的原因是:SIGCHLD(17号)是普通信号的,有可能有很多进程,最终在某一个时刻都退出了,因为记录信号的 pending 位只有一个,所以此时知道有进程退出,但是如果此时只 wait 一次,那么就极有可能只回收了一次,那么其它信号可能就没有回收,所以这里要用循环不断的去回收。

            在 waitpid 的时候设置位非阻塞(WNOHANG):当在进行 while 循环检测时会有这种现象:我们创建了十个子进程,此时有五个退出了,当不断的将五个回收完,我们此时应该还是要 wait,所以我们这里的 waitpid 要有两个功能,一是去检测还有没有子进程退出的,二回收已经退出的。所以当把五个回收完它并不知道退出完了,就会认为还有,所以还会调 wait,但是如果没有了,那么 wait 就会被阻塞住,进而导致在 handler 那里就回不去了。

            所以这里就设置成 WNOHANG,有就一直回收,只要回收失败的时候,就证明把所有的子进程回收完了,然后再继续去运行父进程。


            接下来我们要验证一下,怎么确认子进程终止时会给父进程发SIGCHLD信号:

    1. 1 #include
    2. 2 #include
    3. 3 #include
    4. 4 #include
    5. 5 #include
    6. 6 #include
    7. 7
    8. 8 void handler(int signo)
    9. 9 {
    10. 10 printf("get a signo: %d\n", signo);
    11. 11 int ret = 0;
    12. 12 while((ret = waitpid(-1, NULL, WNOHANG)) > 0){
    13. 13 printf("wait child %d success!\n", ret);
    14. 14 }
    15. 15 }
    16. 16
    17. 17 int main()
    18. 18 {
    19. 19 signal(17, handler);
    20. 20
    21. 21 if(fork() == 0){
    22. 22 printf("child %d is running, begin dead!\n", getpid());
    23. 23 sleep(3);
    24. 24 exit(1);
    25. 25 }
    26. 26 while(1);
    27. 27 return 0;
    28. 28 }

             我们可以看到,确实会给父进程发送 17号 信号,而且确定是回收的子进程。

            接下来我们就来试试通过调用 SIG_IGN  去自动清理子进程:

     

            我们可以看到在前三秒内有两个进程,三秒后子进程退出,而且没有看到僵尸(Z状态)进程,这个只在Linux下是可以的,其他的好像不可以。

             如上就是 进程信号 的所有知识,接下来要讲解 Linux多线程 如果大家喜欢看此文章并且有收获,可以支持下 兔7 ,给 兔7 三连加关注,你的关注是对我最大的鼓励,也是我的创作动力~!

            再次感谢大家观看,感谢大家支持!

  • 相关阅读:
    EtherCAT主站IgH解析(二)-- 如何将Igh移植到Linux/Windows/RTOS等多操作系统
    刚工作菜鸟的小总结1
    solidity Foundry开发工具
    虚幻引擎图文笔记:使用VAT(Vertex Aniamtion Texture)制作破碎特效(Houdini,UE4/UE5)上 Houdini端
    QT发送Get请求并返回内容
    leetcode_17电话号码的组合
    快鲸智慧园区管理系统-提供智慧园区一站式解决方案
    搜维尔科技:【周刊】适用于虚拟现实VR中的OptiTrack
    Hbuilder本地调试微信H5项目(一)
    Nat. Commun. | 大规模高分辨单光子成像
  • 原文地址:https://blog.csdn.net/weixin_69725192/article/details/126082636