【Linux】从内核认识信号

一、阻塞信号

         1 .信号的一些其他相关概念

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

         2 .在内核中的表示

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

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

总结:

在block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞。

(正常的信号是31个,我们用4个字节就可以表示所有信号的状态)

 
在pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号。

 
handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。

 
block、pending和handler这三张表的每一个位置是一一对应的。

sigze_t类型:sigset_t Linux给用户提供的一个用户级的数据类型, 禁止用户直接修改位图

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

在不同的系统中sigze_t类型的定义不同,在我当前系统中是这样的:

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

typedef __sigset_t sigset_t;

sigset_t   说白了就是一个结构体,结构体中有一个数组实现了位图。 

 进程要维护信号的三种状态:

信号递达:

实际执行信号的处理动作称之为信号递达 (delivery)。信号递达之后的处理动作有 3 种:

  1. 执行信号的默认执行动作signal(信号编号, SIG_DFL); 对指定信号执行其默认动作。
  2. 忽略信号signal(信号编号, SIG_IGN); 忽略递达的信号,让该信号啥也干不了。
  3. 对信号的自定义捕捉处理:就是信号的捕捉

 信号未决:

  • 信号从产生到递达之间的状态称为信号未决 (pending)。
  • 将信号保存在信号位图叫做信号未决

信号阻塞

  • 进程可以选择阻塞 (block) 某个信号。被阻塞的信号将保持在 pending 状态,直到进程解除对该信号的阻塞,才去执行递达的动作。
    • 信号阻塞和信号忽略不同:信号被阻塞时就不会被递达;信号忽略是在递达之后可选的一种处理动作。

我们进程要维护以上三种状态

OS在内核中要维护三张表,分别是:阻塞 (block) 表、未决位图 (pending) 表、handler 表

也就是:

block 表:该表是个位图结构,比特位的位置表示信号的编号,比特位的内容表示否是对特定的信号进行阻塞。

        对应位 n 上的值为 1 表示 n 号信号被阻塞,0 表示 n 号信号未被阻塞。


pending 表:该表是用于保存信号的位图结构,比特位的位置表示信号的编号,比特位的内容表示否是收到特定编号的信号。
        对应位 n 上的值为 1 表示收到了 n 号信号,0 表示未收到 n 号信号。

上面两张表都是用上面提到的sigset_t结构体表示的。


handler 表:这张表表是一个函数指针数组,信号编号是该数组的下标,该数组的内容是对应信号编号的处理方法。这些方法有默认,忽略和自定义

OS识别信号:

OS先去识别pending表的位图,如果被置为1。那就去block表对应位置去查看释放被阻塞。如果未被阻塞就去对应的handler表里面去查找执行函数并执行其内容。在执行函数内容之前将pending表对应位置置为0。

如果发现被阻塞了,则当前pending图的位置不管,继续去查看是否收到了其他信号。

         3 . 信号集操作函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”,至于这个类型内部如何存储这些bit则依赖于操作系统的实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作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);  

函数解释:

1. sigemptyset:用于将信号集中的全部比特位变成 0,成功返回 0,失败则返回 -1.

  

2.sigfillset: 用于将信号集中的全部比特位变成 1,成功返回 0,失败则返回 -1.

  

3. sigaddset:将指定信号添加到信号集中 (将特定比特位变为 1),成功返回 0,失败则返回 -1.

  

4. sigdelset:将指定信号从信号集中删除 (将特定比特位变为 0),成功返回 0,失败则返回 -1.

  

5. sigismember:判断信号集的有效信号中是否包含指定信号 (判断特定比特位是否为 1),若包含则返回 1,不包含则返回 0,出错返回-1。

注意: 在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号处于确定的状态。

sigprocmask

sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集),该函数的函数原型如下:

参数:

  • how:指定对信号屏蔽字的操作方式,可以取以下三个值之一:

  • set:指向一个sigset_t类型的信号集合,用于指定需要修改的信号集合。

  • oldset:是一个可选的输出参数,用于获取调用sigprocmask函数前的旧的信号屏蔽字。如果不需要保存旧的信号屏蔽字,可以传递NULL

返回值:

  • 执行成功时,sigprocmask函数返回0。
  • 执行失败时,返回-1,并设置相应的errno以指示错误原因。

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

sigpending

sigpending函数可以用于读取进程的未决信号集,该函数的函数原型如下:

参数:

  • set:指向一个sigset_t类型的信号集合的指针,用于存储当前进程的未决信号集合。调用成功后,该信号集合将包含所有当前未决的信号。

返回值:

  • 调用成功时,sigpending函数返回0。
  • 调用失败时,返回-1,并设置相应的errno以指示错误原因。

下面我们做一个小实验来使用以下以上的函数:

步骤:

先将2号信号用我们自定义方法进行捕捉 。

然后用上述的函数将2号信号进行屏蔽(阻塞)。

 
使用kill命令或组合按键向进程发送2号信号。 

 
此时2号信号会一直被阻塞,并一直处于pending(未决)状态。

 
使用sigpending函数获取当前进程的pending信号集进行验证。

5秒之后,解除对2号信号的屏蔽。

代码:

#include <iostream>
#include <unistd.h>
#include <signal.h>

void PrintPending(sigset_t pending)
{
    std::cout << "cur process [ " << getpid() << " ]pending: " ;
    for (int i = 31; i > 0; i--)
    {
        if (sigismember(&pending, i))
        {
            std::cout << 1;
        }
        else
            std::cout << 0;
    }
    std::cout << std::endl;
}

// 当前如果正在对n号信号进行处理,默认n号信号会被自动屏蔽
// 对n号信号处理完成的时候,会自动解除对n号信号的屏蔽
void handler(int signo)
{
    std::cout << "get a sig " << signo << "  pid: " << getpid() << std::endl;
}
int main()
{
    // 0. 用自定义方法捕捉2号信号
    signal(2, handler); // 自定义捕捉

    // 1. 屏蔽2号信号
    sigset_t block_set, old_set;
    sigemptyset(&block_set); // 先清空信号集
    sigemptyset(&old_set);
    sigaddset(&block_set, SIGINT); // 此时我们并没有修改当前内核的block,因为还没有将block表导入到内核block表里面

    // 设置进入进程的block表中
    sigprocmask(SIG_BLOCK, &block_set, &old_set); // 此操作才是真正将内核block表修改,将2号信号屏蔽

    int cnt = 5;//5秒后解除对2号信号的屏蔽

    while (true)
    {
        // 将pending表打印出来
        sigset_t pending;
        sigpending(&pending);
        PrintPending(pending);

        // 解除对2号信号的屏蔽
        if (cnt == 0)
        {
            std::cout << "已经解除对2号信号的屏蔽!!!" << std::endl;
            sigprocmask(SIG_SETMASK, &old_set, &block_set);
        }
        sleep(1);
        cnt--;
    }

    return 0;
}

执行结果:这里我用的组合键

可以看到,程序刚刚运行时,因为没有收到任何信号,所以此时该进程的pending表一直是全0,而当我们使用组合键向该进程发送2号信号后,由于2号信号是阻塞的,因此2号信号一直处于未决状态,所以我们看到pending表中倒数的第二个数字一直是1。

为了看到2号信号递达后pending表的变化,我们可以设置一段时间后,自动解除2号信号的阻塞状态,解除2号信号的阻塞状态后2号信号就会立即被递达。

因为2号信号的默认处理动作是终止进程,所以为了看到2号信号递达后的pending表,我们可以将2号信号进行捕捉,让2号信号递达时执行我们所给的自定义动作。

进程收到2号信号后,该信号在一段时间内处于未决状态,当解除2号信号的屏蔽后,2号信号就会立即递达,执行我们所给的自定义动作,而此时的pending表也变回了全0。

注意:在执行自定义处理函数之前pending表已经被置0;因为如果调用sigprocmask解除对当前若干个未决信号的阻塞,则在sigprocmask函数返回前,至少将其中一个信号递达。

二、捕捉信号

         1 .自定义捕捉

  • 信号可以被自定义捕捉,进程接收到信号后可以不执行本来应该执行的任务,而是去执行自己定义的任务。
  • 可以使用 signal 函数对信号进行捕捉。

 signal 函数原型

函数参数

  • signum:指定你要捕捉的是哪个信号
  • handler:更改进程持有的函数指针数组中信号对应下标位置的函数指针所指向的函数为指定函数。捕捉到指定信号之后,去执行该函数指针所指向的函数,并且要将捕捉到的信号作为参数传递给该函数。

         2 .无法被捕捉的信号

  • 如果所有的信号都能被自定义捕捉的话,某个进程将所有的信号全部给捉了去,该进程就无敌了,没有人能干掉它,OS 也不行。
  • 显然 OS 是不允许这样的事发生的,因此 OS 设置了几个无法被捕获的信号。

无法被自定义捕捉的信号 

  • SIGKILL (9 号) 信号:这是一个用来立即结束程序的信号,不能被忽略、阻塞或捕捉。进程一旦接收到该信号,将被无条件终止。
  • SIGSTOP (19 号) 信号:该信号用于停止 (挂起) 进程的执行,不能被忽略、阻塞或捕捉。 

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

    3.1 键盘输入过程

我们先了解一下硬件中断:

  • 定义:硬件中断是由硬件设备(如磁盘、网卡、键盘、时钟等)产生的中断信号,当这些设备有数据或事件需要处理时,会向CPU发送中断请求。
  • 特点
    • 实时性强:由于直接由硬件设备触发,能够迅速响应外部事件。
    • 处理速度快:中断处理程序通常设计得较为精简,以确保快速完成任务。
    • 可屏蔽性:部分硬件中断可通过设置中断屏蔽寄存器来关闭,以控制中断的响应。

中断的处理过程:

  1. 中断请求:硬件设备向CPU发送中断请求信号。
  2. 中断响应:CPU在收到中断请求后,会暂停当前正在执行的任务,转而处理中断请求。
  3. 中断服务:CPU执行相应的中断服务程序,处理中断请求。
  4. 恢复现场:中断服务程序执行完毕后,CPU恢复之前被中断的程序的现场,包括寄存器值、程序计数器等。
  5. 中断返回:CPU继续执行之前被中断的程序。

首先,我们先要知道,在OS内有一张表。他类似于handler表一样。是一个函数指针数组。里面存放的是一些方法。他是中断向量表,里面存放的是调用鼠标,键盘,网卡等硬件的函数。这些函数是有OS封装起来。然后提供接口给硬件厂商,硬件厂商根据这些接口进行驱动开发。

当我们按下键盘的时候, cpu中会有一个硬件产生中断(高低电平),该中断就是一个数字。这个数字就是对应该硬件调用函数在中断向量表当中的下标。当中断产生时,CPU会让OS去该下标中找到执行方法,然后调用该方法。这样就可以从键盘输入内容了。

这样的方式有一个好处,就是OS不用时刻检测是否硬件需要输入/输出操作。等硬件发中断的时候他直接去接受就行了。并且每一个硬件都有自己的中断号。根据中断号,OS也能精确的调用各自的方法去应对硬件设备。这也是外部中断,他的目的就是让CPU内部形成一个中断数字。

另外,在我们还需要了解时钟中断,我们电脑的cpu中会有一块小电池一直维护着电脑的时间戳,这也是为什么我们电脑关机后再开启时间一直在跟着走,不会说每次都需要自己去调时间。当然如果电脑长时间不使用,完全没有电了后再打开,时间就不是当时的时间了。

时钟中断还一直维护着OS的运作。OS会在每隔一定时间片后发送中断执行OS的调用函数。所以OS是一个死循环程序。

    3.2 理解系统调用

OS有很多系统调用,他也与中断和信号一样,被组织起来。也有一个函数指针表。同样的,在OS内部对系统调用进行了编号,在OS运行起来的时候,OS会将函数地址放进该函数指针表。下标则是该系统调用的编号。也叫系统调用号。在调用的时候只要找到特定系统调用的下标,就能进行系统调用了。

CPU当中有一个寄存器——CS寄存器。里面有一个状态标志位,这个标识程序当前的调用的状态。用0表示内核,1表示用户。

当我们程序中进行系统调用的时候。寄存器检测是否是系统调用。如果是系统调用,OS会将CS寄存器的状态改为内核态,然后将系统调用号存到PC指针中。然后执行该函数。当执行完后就返回用户态,修改CS的状态。当然返回之前还需要检查一下信号表。

因此,进程执行任何系统调用就是两步:

1. 得到系统调用号

2. 系统调用 函数指针数组

信号处理的时机

  • 进程从内核态返回到用户态的时候,会进行信号的检测和信号的处理。
  • 系统调用背后,就包含了身份的变化。

信号捕捉的过程

当进程从内核态返回用户态时 (如系统调用返回、中断处理完毕等),内核会检查 pending 表中是否有待处理的信号。


如果有信号待处理且该信号未被阻塞,内核会查找 handler 表以确定该信号的处理方式。


如果信号的处理方式是用户自定义的 (即捕捉信号),内核会创建一个新的堆栈帧,用于保存当前进程的上下文 (如寄存器状态、信号掩码等),并调用用户定义的信号处理函数。

但如果待处理信号是自定义捕捉的,即该信号的处理动作是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作。执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位。如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。

注意: sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。因为main函数没有调用handler函数,handler函数也没有调用main函数

该图形与直线有几个交点就代表在这期间有几次状态切换,而箭头的方向就代表着此次状态切换的方向,图形中间的圆点就代表着检查pending表。

 当识别到信号的处理动作是自定义时,能直接在内核态执行用户空间的代码吗?

技术角度上来说是可以的,但是内核态是一种权限非常高的状态,因此绝对不能这样设计。

如果允许在内核态直接执行用户空间的代码,那么用户就可以在代码中设计一些非法操作,比如用execl系列函数清空数据库等,虽然在用户态时没有足够的权限做到清空数据库,但是如果是在内核态时执行了这种非法代码,那么数据库就真的被清空了,因为内核态是有足够权限清空数据库的。这样就对数据造成了破环。

也就是说,不能让操作系统直接去执行用户的代码,因为操作系统无法保证用户的代码是合法代码,即操作系统不信任任何用户。

         4 . 内核空间与用户空间

每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成:

用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。


内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。


内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。

因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。

所以我们访问OS,本质还是在我们的地址空间中访问的,和我们访问库函数没有区别。

需要注意的是:

虽然每个进程都能够看到操作系统,但并不意味着每个进程都能够随时对其进行访问。

进程切换

在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。
执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。

注意: 访问用户空间只能是用户态,访问内核空间只能是内核态。

         5 .内核态与用户态

内核态通常用来执行操作系统的代码,是一种权限非常高的状态。是进程地址空间的3-4GB空间
用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态。【0-3】GB空间

进程收到信号之后,并不是立即处理信号,而是在合适的时候,这里所说的合适的时候实际上就是指,从内核态切换回用户态的时候。

内核级页表在整个操作系统中只有一张,与动态库类似。因此无论进程如何调度,CPU 都能直接找到 OS

内核态和用户态之间是进行如何切换的?

从用户态切换为内核态通常有几种情况:

1. 需要进行系统调用时。

  
2. 当前进程的时间片到了,导致进程切换。

  
3. 产生异常、中断、陷阱等。

与之相对应,从内核态切换为用户态有如下几种情况:

1. 系统调用返回时。

  

2. 进程切换完毕。

  

3. 异常、中断、陷阱等处理完毕。

其中,由用户态切换为内核态我们称之为陷入内核。

每当我们需要陷入内核的时,本质上是因为我们需要执行操作系统的代码。

比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态。如果在内核态直接执行用户态的代码可能会出现利用OS的大权限破坏OS

补充:

         6 .sigaction检查并修改信号的处理动作

sigaction 函数原型

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  • 该函数可以读取和修改与指定信号相关联的处理动作。
  • 调用成功则返回 0,出错则返回 -1。

参数:

  • signum:指定要设置或获取处理程序的信号编号。除了SIGKILL和SIGSTOP信号外,几乎所有信号都可以被sigaction处理。
  • act:指向sigaction结构体的指针,该结构体包含了新的信号处理函数及其他相关设置。如果此参数为NULL,则sigaction将不会改变当前信号的处理方式,只会返回当前处理方式(如果oldact非NULL)。
  • oldact:如果此参数非NULL,它将用于存储信号signum当前的处理方式。这允许程序在更改信号处理方式之前先保存它,以便稍后恢复。

act和oldact的定义如下:

struct sigaction {  
    void (*sa_handler)(int); // 信号处理函数,不接受额外数据  
    void (*sa_sigaction)(int, siginfo_t *, void *); // 信号处理函数,能接受额外数据  
    sigset_t sa_mask; // 在信号处理函数执行期间要阻塞的信号集  
    int sa_flags; // 标志位,用于修改信号处理的行为  
    void (*sa_restorer)(void); // 已废弃,不用关心  
};

结构体的成员信息:

  • sa_handler:默认的信号处理函数,其参数为接收到的信号编号。如果设置为SIG_DFL,则采用默认处理方式;如果设置为SIG_IGN,则忽略该信号。
  • sa_sigaction:与sa_handler类似,但提供了接收额外信息的能力,通常与sa_flags中的SA_SIGINFO标志一起使用。这一个我们暂时不关心
  • sa_mask:指定在信号处理函数执行期间要阻塞的信号集。默认情况下,当前信号本身会被阻塞,以防止信号的嵌套发送。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。
  • sa_flags:一系列标志位,用于修改信号处理的行为。sa_flags字段包含一些选项,这里直接将sa_flags设置为0即可。

例如,下面我们用sigaction函数对2号信号进行了捕捉,将2号信号的处理动作改为了自定义的行为,并在执行一次自定义动作后将2号信号的处理动作恢复为原来默认的处理动作。

代码:

void handler(int signo)
{
    std::cout<<"get a sig :"<<signo<<std::endl;
    sigaction(2,&oact,NULL);
}
int main()
{
    struct sigaction act,oact;
    memset(&act,0,sizeof(act));
    memset(&oact,0,sizeof(oact));
    act.sa_handler=handler;

    // 如果还想处理2号(OS对2号自动屏蔽),同时对其他信号也进行屏蔽
    // sigaddset(&act.sa_mask, 3);//增加屏蔽3号

    act.sa_flags=0;
    sigemptyset(&act.sa_mask);

    sigaction(2,&act,&oact);
    while(true)
    {
        std::cout<<" i am a process "<<std::endl;
        sleep(1);
    }
    return 0;
}

执行结果:

运行代码后,第一次向进程发送2号信号,执行我们自定义的函数,当我们再次向进程发送2号信号,就执行该信号的默认处理动作了,即终止进程。

如果在屏蔽2号信号的同时屏蔽3号信号,可以的,只需要用函数 sigaddset()   就行了

sigaddset(&act.sa_mask, 3);

三、可重入函数

看下面主函数中调用insert函数向链表中插入结点node1,某信号处理函数中也调用了insert函数向链表中插入结点node2

以上图片说了一个问题,就是链表在一次插入操作未完成之前收到其他信号执行了第二次插入,这导致第一次未完成,没有连接好时。第二次就插入使整个链表发生了连接混乱。最终形成了第4个的造型。这样在内存释放的时候,不知道node2的存在,这样就可能会造成内存泄漏。

上述例子中,各函数执行的先后顺序如下:

像上例这样,insert函数被不同的控制流调用(main函数和sighandler函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立的控制流程),有可能在第一次调用还没返回时就再次进入该函数,我们将这种现象称之为重入。

而insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数我们称之为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称之为可重入(Reentrant)函数。

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

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

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

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

四、volatile

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

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

代码:

int flag=0;

void handler(int signo)
{
    std::cout<<"get a sig "<<signo<<std::endl;
    flag=1;
}
int main()
{
    signal(2,handler);
    while(!flag);
    std::cout<<"proc quit"<<std::endl;
    return 0;
}

执行结果:

此时程序结果与预期一样,当捕捉2号信号后,修改flag,然后while不满足条件,程序退出。

当我们改变操作系统的优化等级:

执行结果:

输入 ctrl + c 后,2 号信号被捕捉,执行自定义动作,修改 flag=1 ,但是 while 条件依旧满足,进程继续运行!
flag 此时已经被修改了,循环依旧在执行,因为 while 循环检查的 flag,并不是内存中最新的 flag,出现了二义性。
编译器没看见 main 函数中有谁修改 flag,因此直接将 flag 放进 CPU 寄存器中,whilie 检测的是寄存器中的 flag 而不是内存中的。

代码中的main函数和handler函数是两个独立的执行流,而while循环是在main函数当中的,在编译器编译时只能检测到在main函数中对flag变量的使用。

此时编译器检测到在main函数中并没有对flag变量做修改操作,在编译器优化级别较高的时候,就有可能将flag设置进寄存器里面。while循环用的时候直接在寄存器去取值了,并没有去物理内存。因为OS认为flag并没有改变。

这里我们为了防止这种情况发生,就需要用到关键字——volatile 不准将变量放进寄存器

  • 被 volatile 关键字修饰的变量,每次访问都必须从内存中读取取。
  • 用 volatile 修饰了 flag 之后,while 每次访问都必须跑到内存中去读取 flag 然后进行判断。

五、SIGCHLD信号

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

其实,学习了信号之后,我们知道子进程在终止时会给父进程发生SIGCHLD信号,该信号的默认处理动作是忽略,

父进程可以自定义SIGCHLD信号的处理动作,这样父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait或waitpid函数清理子进程即可。

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

void handler(int signo)
{
    std::cout<<"get a sig :"<<signo<<std::endl;
    
    while(true)
    {
       pid_t ret=waitpid(-1,NULL,WNOHANG);
       if(ret==0)
       {
        std::cout<<"child quit done "<<std::endl;
        break;
       }
       else if(ret<0)
       {
        std::cout<<" child quit done "<<std::endl;
        break;
       }
    }
}

void DoOtherThing()
{
    std::cout<<"DoOtherThing"<<std::endl;
}
int main()
{
    signal(SIGCHLD,handler);
    for(int i=0;i<10;i++)
    {
        pid_t id=fork();
        if(id==0)
        {
            std::cout<<"i am child process,pid :"<<getpid()<<std::endl;
            sleep(3);
            exit(1);
        }
    }
    //父进程
    while(true)
    {
        DoOtherThing();
        sleep(1);
    }
    return 0;
}

这样的方式去等待,我们可以将waitpid()函数变为非阻塞式等待了,这样父进程就可以自由做自己的事情了。当多个进程同时结束的时候也能等待成功。

当然我们也能将子进程发的信号设置为忽略状态,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。

系统默认的忽略动作和用户用signal或sigaction函数自定义的忽略通常是没有区别的,但这是一个特列。此方法对于Linux可用,但不保证在其他UNIX系统上都可用。

signal(SIGCHLD, SIG_IGN);

此时子进程在终止时会自动被清理掉,不会产生僵尸进程,也不会通知父进程。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

何陈陈

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值