《Linux从练气到飞升》No.24 Linux中的信号(二)

简介: 《Linux从练气到飞升》No.24 Linux中的信号(二)

《Linux从练气到飞升》No.24 Linux中的信号(一)+https://developer.aliyun.com/article/1389845

3.5.6 操作系统怎么发信号

在进程的结构体中我们需要一个空间来保存收到的信号 为什么这么说呢

还记不记得我们上面举的闹钟响了但是你还想继续睡觉的例子 也就是说在信号发送的时候我们有可能有更加重要的事情(在计算机眼里就是更高优先级)要去做 所以说信号必须要被暂存下来

那么信号应该怎么保存呢? 我们使用kill -l指令查看所有的信号

我们可以看到 其实这里信号的编号是十分有规律的 它的编号是1~31

看到这么规律的数字我们一定能第一时间想到数组下标

当然使用数组的下标来保存信号是完全可行的

可是这里我们只需要知道信号是否存在就可以了不需要知道其他的事情 所以说这里使用比特位来标志一个信号是否存在是一种更好的做法

实际上在linux系统中也确实是用一个32位的无符号整数来标志每个信号是否存在的

概念图如下

我们将最低位定义为为第一位 依次往高位递增

如果该位的比特位为1则表示收到信号 如果该位的比特位为0则表示未收到信号

现在我们再来理解操作系统是如何发送信号的 本质就是操作系统将PCB中信号位图对于位置置1

所以说我们现在对于操作系统发送信号的理解应该是操作系统写入信号

3.6 总结思考

上面所说的所有信号产生,最终都要有OS来进行执行,为什么?

所有信号产生,最终都要由操作系统(OS)来进行执行,这是因为操作系统是进程的管理者。操作系统负责调度和控制进程的执行,包括处理信号。

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

在合适的时候,信号的处理通常是异步的,即信号可以在任何时候发生并被传递给进程。进程在收到信号后,根据信号的类型和进程当前的状态,可以选择立即处理信号或者将信号暂时记录下来等待合适的时机进行处理。

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

如果进程不立即处理信号,操作系统会将信号信息记录在进程的进程控制块(PCB)中。进程控制块是操作系统用来管理进程的数据结构,其中包含了进程的状态、上下文和其他相关信息。记录信号的目的是为了确保在适当的时候能够通知进程有未处理的信号需要处理。

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

一个进程在没有收到信号的时候,无法知道自己应该对合法信号作何处理。进程只能在收到信号后才能根据信号的类型和自身的状态来选择相应的信号处理方式。在信号传递给进程之前,进程也无法确定是否会收到某个特定的信号。

当进程收到信号时,它可以通过信号处理函数或者默认处理动作进行处理。如果进程没有设置自定义的信号处理函数,则会执行信号的默认处理动作。默认处理动作是由操作系统规定的,不同类型的信号可能有不同的默认处理动作。

需要注意的是,即使进程设置了自定义的信号处理函数,也不能保证该处理函数一定会被执行。例如,如果进程正在阻塞某个系统调用,则在信号到达时处理函数可能无法立即执行,而是要等待系统调用返回后再执行。

因此,进程无法在收到信号之前预先知道如何处理合法信号。它只能在收到信号后根据信号的类型和自身的状态来选择相应的处理方式。

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

完整的发送处理过程大致如下:

  1. 操作系统接收到信号并确定要发送给哪个进程。
  2. 操作系统检查目标进程对该信号的处理方式。
  3. 如果进程忽略该信号,则操作系统不进行任何处理。
  4. 如果进程设置了自定义的信号处理函数,操作系统将信号传递给该处理函数。
  5. 处理函数执行相关的操作,并可以根据需要修改信号处理方式或者其他状态。
  6. 如果进程没有设置自定义的信号处理函数且信号有默认处理动作,操作系统执行该默认处理动作。
  7. 处理完信号后,控制权返回给目标进程继续执行原来的程序。

4 信号产生中

4.1 基本概念 : 信号递达、信号未决、阻塞信号、忽略信号

在了解信号产生中的时候,我们需要了解几个基本概念:

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

怎么理解呢?

就拿这么一个例子来说,假设你设定了一个明天早上起床的闹钟,它会提醒你要起来上课了,到了第二天,会有这么几种情况:

  • 你听到闹钟然后起来上课,这就是信号递达,执行的是默认动作
  • 从你听到闹钟响到你去上课之间的状态就是信号未决
  • 你室友听到闹钟然后把它关了,导致你没有听见继续睡,这就是信号屏蔽/阻塞
  • 你听到了闹钟响但是不去管它,继续睡,这就是信号忽略
  • 你听到闹钟响然后起来关了以后不去上课而是干其他的事情,这就是执行自定义动作
  • 阻塞和忽略的区别就在于你是否收到了信号,阻塞是你没有收到,忽略则是你收到后不进行处理的行为。

4.2 内核中信号的结构 > block位图 pending位图 handler函数表

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

它们分别是block位图 pending位图 handler函数表

下面我将会一一介绍它们

block位图

block位图标志着该信号是否被阻塞

1表示该位图被阻塞 0则表示未被阻塞

pending位图

pending位图标志这是否接受到该信号

1表示收到该信号 0则表示未收到信号

handler函数表

handler函数表里面是信号接受时处理的各种函数地址

我们通过这些地址去调用函数

现在我们来回答一个之前的问题 为什么就算我们没有看见红绿灯过 我们也知道红灯停绿灯行呢 本质上是因为handler函数表中注册了红绿灯这个函数

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

4.3 sigset_t 类型

实际上在我们的操作系统中 除了像是int double等类型 还有一些操作系统给我们提供的类型

像是共享内存中的key_t类型

为了解决未决和阻塞的位问题 操作系统向我们提供了一个类似位图的数据类型 sigset_t

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

因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。

它在linux中的实现方式如下:

#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
  unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
typedef __sigset_t sigset_t;

这个类型可以表示每个信号的**“有效”或“无效”**状态。

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

下一节将详细介绍信号集的各种操作。

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

4.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 signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);  

函数解释

  • sigemptyset函数:初始化set所指向的信号集,使其中所有信号的对应bit清零 ,表示该信号集不包含任何有效信号
  • sigfillset函数:初始化set所指向的信号集, 使其中所有信号的对应bit置位 ,表示该信号集的有效信号包括系统支持的所有信号
  • sigaddset函数:在set所指向的信号集中添加某种有效信号
  • sigdelset函数:在set所指向的信号集中删除某种有效信号
  • sigemptyset、sigfillset、sigaddset和sigdelset函数都是成功返回0 出错返回-1
  • sigismember函数:判断在set所指向的信号集中是否包含某种信号 若包含则返回1 不包含则返回0 调用失败返回-1

4.5 sigprocmask 函数

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

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

返回值:

  • 若成功则为0,若出错则为-1

参数说明:

  • how参数一般我们使用宏来表示 它标志着设置的模式
  • 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
  • 如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。
  • 如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
  • 假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
    | 选项 | 含义 |
    | — | — |
    | SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask | set |
    | SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask && ~set |
    | SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |

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

也就是说如果我们之前阻塞了2号信号 现在解除阻塞 进程就会立刻收到2号信号 并且执行注册函数

为什么我们一解除阻塞进程就会收到信号这个我们后面会深入讲解

4.5.1 不能被阻塞的信号(现象)

我们使用刚刚学到的sigprocmask可以阻塞信号 下面我们使用一段代码来试试阻塞2号和9号信号

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
int main()
{
    sigset_t iset;
    sigemptyset(&iset);
    sigaddset(&iset , 2);
    sigaddset(&iset , 9);
    sigprocmask(SIG_SETMASK , &iset , NULL);
    while(1)
    {
        printf("hello world , my pid is:%d\n",getpid());                                                
        sleep(1);
    }
    return 0;
}

运行后我们使用ctrl c来终止程序,发现无效

我们重新开启一个进程并且发送一个9号信号给它

我们发现此时的进程就被直接杀死了

可是我们上面明明阻塞了9号信号

根据上面的现象我们可以推断 9号信号是不可以被阻塞的

4.6 sigpending 函数

sigpending函数可以获取进程的未决信号集合 它的函数原型如下

int sigpending(sigset_t *set);

返回值:

如果函数调用成功返回0 失败则返回-1

参数:

该函数的参数是一个输出型参数 我们只能使用该函数来获取进程的pending位图 而并不能使用pending位图来向进程直接发送信号 (发送信号的方式在前面已经介绍了 这里就不再赘述)

接下来我们做一个简单的试验:我们看看当我们发送信号的时候pending位图中是什么样子的

实验代码如下

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void printPending(sigset_t *pending)
{
  int i = 1;
  for (i = 1; i <= 31; i++){
    if (sigismember(pending, i)){
      printf("1");
    }
    else{
      printf("0");
    }
  }
  printf("\n");
}
int main()
{
  sigset_t set, oset;
  sigemptyset(&set);
  sigemptyset(&oset);
  sigaddset(&set, 2); //SIGINT
  sigprocmask(SIG_SETMASK, &set, &oset); //阻塞2号信号
  sigset_t pending;
  sigemptyset(&pending);
  while (1){
    sigpending(&pending); //获取pending
    printPending(&pending); //打印pending位图(1表示未决)
    sleep(1);
  }
  return 0;
}

解释下上面的代码

我们阻塞2号信号 之后每隔1秒 打印当前进程的pending位图

运行之后结果如下

我们发现收到二号信号之后pending位图的2号位置变成1了

如果我们想要观察到2号信号位置从1变0的过程单纯的解除阻塞是不行的

因为2号信号的默认处理方式是让进程退出 所以说如果我们要观察到2号信号从1变0的过程 我们需要对2号信号进行捕捉 代码表示如下

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void printPending(sigset_t *pending)
{
  int i = 1;
  for (i = 1; i <= 31; i++){
    if (sigismember(pending, i)){
      printf("1");
    }
    else{
      printf("0");
    }
  }
  printf("\n");
}
void handler(int signo)
{
  printf("handler signo:%d\n", signo);
}
int main()
{
  signal(2, handler);
  sigset_t set, oset;
  sigemptyset(&set);
  sigemptyset(&oset);
  sigaddset(&set, 2); //SIGINT
  sigprocmask(SIG_SETMASK, &set, &oset); //阻塞2号信号
  sigset_t pending;
  sigemptyset(&pending);
  int count = 0;
  while (1){
    sigpending(&pending); //获取pending
    printPending(&pending); //打印pending位图(1表示未决)
    sleep(1);
    count++;
    if (count == 20){
      sigprocmask(SIG_SETMASK, &oset, NULL); //恢复曾经的信号屏蔽字
      printf("恢复信号屏蔽字\n");
    }
  }
  return 0;
}

解释下上面的代码

该代码会在一开始阻塞进程的2号信号并且在20秒之后会解除对于2号信号的阻塞

每隔一秒会打印pending位图

演示结果如下

5 信号产生后

我们回顾之间讲解的例子,闹钟响了以后,你起床了,现在你要选择一个合适的时候去上课,这个合适的时候就是你洗漱、吃完早餐,在上课铃响之前就要去上课。

那么对于计算机来说这个合适的时候是什么呢?

我们首先要知道 因为信号是保存在进程的PCB当中的pending位图里的 如果要处理需要检测pending位图里面是否有信号、是否被阻塞、信号的处理方式是什么

我们这里直接下一个结论

信号从**内核态**返回**用户态**的时候进行上面的检测并处理

什么是内核态 什么又是用户态 我们下面去认识一下它们。

5.1 内核态和用户态

定义:

  • 内核态是操作系统内核运行的特权级别,具有最高的权限和访问权。在内核态下,操作系统可以直接访问和控制系统的全部硬件资源和核心功能,执行特权指令并处理关键的系统任务,如管理进程、文件系统、设备驱动等。内核态可以执行具有潜在危险和影响系统稳定性的操作,因此只有操作系统内核可以进入内核态。
  • 用户态是应用程序运行的一种受限权限的执行模式。在用户态下,应用程序只能访问受限的资源和执行受限的指令,不能直接访问和控制系统的底层硬件。用户态下运行的应用程序必须通过系统调用(System Call)来请求内核提供特权功能和服务,如创建进程、文件读写、网络通信等。用户态下的应用程序无法执行一些关键的特权指令和进行系统级的操作。

它们之间的区别在于权限,内核态的权限比用户态的权限大。

用户态和内核态之间的转换发生在什么时候?

就比如 系统调用 时使用系统的接口就会由用户态进入内核态,还有就是运行程序发生缺陷、陷阱、异常等情况也会由用户态进入内核态。

能否再具体?

当然

我们知道当我们运行用户写的程序的时候,操作系统会创建进程

此时会有一个叫PCB的数据结构在经由页表和mmu(硬件)的映射后加载到内存中,

那么操作系统的代码和数据是否也要加载到内存中呢?

当然了,实际上我们的电脑在开机等待的时间中就包括了操作系统的代码和数据加载到内存的时间。

那么现在我们就了解到操作系统和用户的数据和代码都会加载到内存中了。

那么用户态和内核态是如何区分的呢?

它们实际的调用过程如下图

可以看到的是用户态和内核态的代码和数据用的并不是一个页表

操作系统使用的是内核级页表

用户使用的是用户级页表

用户空间我们可以理解为一个临时变量 它是属于当前进程的

而内核空间则是一个全局变量 所有进程看到的都是一样的

从这里我们就能得到一个结论

不管我们如何切换进程 我们找到的操作系统代码和数据都是同一个

那么站在现在的角度我们如何理解进程切换?

实际上就是当前进程进入内核态并且找到操作系统的数据和代码 再之后利用操作系统的权限替换用户空间中的数据和代码 这样子就完成进程切换了

那么在系统中用户态和内核态的切换是大概是一个什么样子的呢?

大概变换如下图所示

需要注意的是 如果信号的处理动作是终止 那么当前进程就不需要再回到用户态直接终止了

如果觉得不好记我们可以将这张图再抽象一下

其中该图形和中间的线有几个交点就说明有几次切换

该图形中间的交点表示检查pending表的时刻

需要注意的是 我们检查pending表时是在内核态检查,也就是说该图形的交点必须要在内核态

前面我们讲过内核态的权限比用户态高,那么可以不进行用户态内核态切换直接在内核态处理数据嘛?

理论上是可以的 内核态的权限非常高可以执行用户态的各种函数

但是实际上是不能这么设计的 还是因为内核态的权限非常高 如果用户写出一些很危险的代码比如说删库等操作 放在内核态执行的话就会造成很不好的影响。

5.2 捕捉信号

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

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。

由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:

用户程序注册了SIGQUIT信号的处理函数sighandler。

当前正在执行main函数,这时发生中断或异常切换到内核态。

在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。

内核决定返回用户态后不是恢复main函数的上下文继续执行,

而是执行sighandler函数,

sighandler和main函数使用不同的堆栈空间,

它们之间不存在调用和被调用的关系,是两个独立的控制流程。

sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。

如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

5.2.2 sigaction 函数

捕捉信号除了用前面用过的signal函数之外 我们还可以使用sigaction函数对信号进行捕捉 sigaction函数的函数原型如下:

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

返回值:

  • sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。

参数:

  • signo是指定信号的编号。
  • 若act指针非空,则根据act修改该信号的处理动作。
  • 若oact指针非空,则通过oact传出该信号原来的处理动作。
  • act和oact指向sigaction结构体,该结构体的定义如下:
struct sigaction {
  void(*sa_handler)(int);
  void(*sa_sigaction)(int, siginfo_t *, void *);
  sigset_t   sa_mask;
  int        sa_flags;
  void(*sa_restorer)(void);
};

我们先了解前4个成员

结构体的第一个成员sa_handler:

  • 将sa_handler赋值为常数SIG_IGN传给sigaction函数 表示忽略信号
  • 将sa_handler赋值为常数SIG_DFL传给sigaction函数 表示执行系统默认动作
  • 将sa_handler赋值为一个函数指针 表示用自定义函数捕捉信号或者说向内核注册了一个信号处理函数

结构体的第二个成员sa_sigaction:

  • sa_sigaction是实时信号的处理函数 我们不必理会置空即可

结构体的第三个成员sa_mask:

  • 首先需要说明的是 当某个信号的处理函数被调用 内核自动将当前信号加入进程的信号屏蔽字 当信号处理函数返回时自动恢复原来的信号屏蔽字 这样就保证了在处理某个信号时 如果这种信号再次产生 那么它会被阻塞到当前处理结束为止
  • 如果在调用信号处理函数时 除了当前信号被自动屏蔽之外 还希望自动屏蔽另外一些信号 则用sa_mask字段说明这些需要额外屏蔽的信号 当信号处理函数返回时 自动恢复原来的信号屏蔽字

结构体的第四个成员sa_flags:

  • sa_flags字段包含一些选项 这里直接将sa_flags设置为0即可

接下来我们尝试来使用一下它:

代码如下:

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
struct sigaction act, oact;
void handler(int signo)
{
  printf("get a signal:%d\n", signo);
  sigaction(2, &oact, NULL);
}
int main()
{
  memset(&act, 0, sizeof(act));
  memset(&oact, 0, sizeof(oact));
  act.sa_handler = handler;
  act.sa_flags = 0;
  sigemptyset(&act.sa_mask);
  sigaction(2, &act, &oact);
  while (1){
    printf("I am a process...\n");
    sleep(1);
  }
  return 0;
}

我们给程序发一个2号信号,然后再让它在第二次收到的时候将它恢复

效果演示如下

5.3 可重入函数

我们通过一个链表插入的例子了解它。

下面主函数中调用insert函数向链表中插入结点node1

某信号处理函数中也调用了insert函数向链表中插入结点node2

这是该链表

下面是这个链表的变化过程

  1. 我们调用函数进行头插 并在此时发送信号 (该信号中也用到了头插函数) 在完成头插的第一步之后进程中断进入内核态

  1. 内核态检查信号 并且调用自定义处理方式头插链表(回到用户态)

  1. 回到内核态检查没有信号 继续插入完成链表的头插 回到用户态

  1. 继续被中断的的头插node1,结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。

看到这里熟悉链表的同学可能会发现问题了 这里会造成内存泄漏

而我们没有任何办法可以找到node2的地址

在上面的操作中可能在第一次调用还没返回时就再次进入该函数 我们将这种现象称之为重入

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

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

5.4 关键字 volatile

volatile是C语言的一个关键字 该关键字的作用是保持内存的可见性

有人可能会问,这个有什么用?

别说,还真有用

现在的市面上有很多编译器,它们都会对程序进行各种优化来减少CPU的消耗,但是不是所有的优化都是合适的,比如说下面这段代码:

#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int signo)
{
  printf("get a signal:%d\n", signo);
  flag = 1;
}
int main()
{
  signal(2, handler);
  while (!flag);
  printf("Proc Normal Quit!\n");
  return 0;
}

在上面的代码中 我们对2号信号进行了捕捉 当该进程收到2号信号时会将全局变量flag由0置1 也就是说 在进程收到2号信号之前 该进程会一直处于死循环状态 直到收到2号信号时将flag置1才能够正常退出

我们来运行一下

这里也符合我们的预期

那么上面的这段代码一定是正确的嘛? 答案是否定的,在编译器优化后就不是了。

因为flag在main函数当中并没有做任何的修改 如果在优化级别比较高的情况下 编译器可能会将flag这个变量放到寄存器中去

而handler执行流只是将内存中flag的值置为1了 那么此时就算进程收到2号信号也不会跳出死循环

我们可以试验下

在使用优化编译器(如 -O3)时,编译器会对代码进行各种优化,以提高程序的执行效率。其中一种常见的优化是在循环中消除空闲等待,即编译器可能会检测到while (!flag);这个循环实际上没有任何实质性的计算或操作,它只是在等待flag变为非零。编译器可能会认为这个循环是一个空循环,并将其优化掉,因此导致没有看到预期的信号处理输出。

此时我们只需要在flag的前面加上 volatile关键字 就可以避免这种情况了

volatile int flag = 0;

我们加上volatile关键字之后falg就对内存可见了,自然它变化之后我们的main执行流就能跳出死循环

5.5 SIGCHLD信号

进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。

  • 采用第一种方式,父进程阻塞了就不 能处理自己的工作了;
  • 采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
  • 其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

我们等会可以编写一个程序完成以下功能:

  • 父进程fork出子进程,子进程调用exit()终止
  • 父进程自定义SIGCHLD信号的处理函数
  • 在其中调用wait获得子进程的退出状态并打印。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>
void handler(int signo)
{
  printf("get a signal: %d\n", signo);
  int ret = 0;
  while ((ret = waitpid(-1, NULL, WNOHANG)) > 0){
    printf("wait child %d success\n", ret);
  }
}
int main()
{
  signal(SIGCHLD, handler);
  if (fork() == 0){
    //child
    printf("child is running, begin dead: %d\n", getpid());
    sleep(3);
    exit(1);
  }
  //father
  while (1);
  return 0;
}

运行结果

上面代码中对SIGCHLD信号进行了捕捉 并将在该信号的处理函数中调用了waitpid函数对子进程进行了清理

注意:

  1. SIGCHLD属于普通信号,记录该信号的pending位只有一个,如果在同一时刻有多个子进程同时退出,那么在handler函数当中实际上只清理了一个子进程,因此在使用waitpid函数清理子进程时需要使用while不断进行清理
  2. 使用waitpid函数时 需要设置WNOHANG选项,即非阻塞式等待,否则当所有子进程都已经清理完毕时,由于while循环,会再次调用waitpid函数 此时就会在这里阻塞住

5.6 两种不能被捕捉和被阻塞的信号

SIGKILL(9)和SIGSTOP (19)

相关文章
|
6天前
|
Unix Linux C语言
|
6天前
|
安全 Linux
【Linux】详解用户态和内核态&&内核中信号被处理的时机&&sigaction信号自定义处理方法
【Linux】详解用户态和内核态&&内核中信号被处理的时机&&sigaction信号自定义处理方法
|
6天前
|
存储 Linux C++
【Linux】详解信号的保存&&信号屏蔽字的设置
【Linux】详解信号的保存&&信号屏蔽字的设置
|
6天前
|
存储 Linux
【Linux】对信号产生的内核级理解
【Linux】对信号产生的内核级理解
|
6天前
|
Ubuntu Linux
【Linux】详解信号产生的方式
【Linux】详解信号产生的方式
|
6天前
|
Unix Linux
【Linux】详解信号的分类&&如何自定义信号的作用
【Linux】详解信号的分类&&如何自定义信号的作用
|
6天前
|
存储 安全 Linux
【探索Linux】P.18(进程信号 —— 信号捕捉 | 信号处理 | sigaction() )
【探索Linux】P.18(进程信号 —— 信号捕捉 | 信号处理 | sigaction() )
9 0
|
6天前
|
存储 算法 Linux
【探索Linux】P.17(进程信号 —— 信号保存 | 阻塞信号 | sigprocmask() | sigpending() )
【探索Linux】P.17(进程信号 —— 信号保存 | 阻塞信号 | sigprocmask() | sigpending() )
12 0
|
6天前
|
算法 Linux C++
【探索Linux】P.16(进程信号 —— 信号产生 | 信号发送 | 核心转储)
【探索Linux】P.16(进程信号 —— 信号产生 | 信号发送 | 核心转储)
9 0
|
6天前
|
存储 Linux 编译器
[Linux打怪升级之路]-信号的保存和递达
[Linux打怪升级之路]-信号的保存和递达