Linux——信号

目录

Linux------信号

信号是有生命周期的:

预备------信号产生------信号保存------信号处理(递达)

1.信号的基础了解

  • 信号的概念

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

  • 生活角度的信号
  1. 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能"识别快递"

  2. 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成"在合适的时候去取"。

  3. 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你"记住了有一个快递要去取"

  4. 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)

  5. 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

  • 从进程的角度
  1. 信号是给进程主动发送的,进程需要先识别信号【认识+动作】
  2. 进程本身是程序员写的代码数据和逻辑以及属性的集合
  3. 当进程收到信号的时候,进程本身可能在执行其他任务,因此信号可能不会被立即处理
  4. 进程本身必须要有对于信号的保存能力。才能延迟处理信号
  5. 当进程开始处理信号(信号被捕捉)的时候,会有三种动作(默认,自定义,忽略)

那进程将信号保存到哪里了呢?------进程PCB【task_struct】

但是信号一共不是有很多个吗,这里一个unsigned int类型也只能保存32个信号。这是因为信号分为普通信号和实时信号,这里这个信号只保存普通信号[1, 31]

因此,发送信号的本质其实就是修改PCB中的信号标记位的信号位图!!!

而PCB属于内核数据结构,因此只有OS才能对其进行修改,因此不论是什么发送信号的方式,本质都是通过OS修改目标进程的PCB的信号位图【也就是说,OS必须提供信号相关的系统调用】

2.技术应用角度的信号

这里先写一个很简单的代码:

cpp 复制代码
#include<iostream>
#include<unistd.h>

using namespace std;

int main()
{
    while(1)
    {
        sleep(1);
        cout << "pid: " << getpid() << endl;
    }
    return 0;
}

如上图所示,当时遇到一个死循环的时候,输入一个ctrl + c可以终止一个前台进程的运行。为什么?

这是因为ctrl + c是一个热键,本质是一个组合键。OS识别到之后将其解释为2号信号

前面说了,当进程接收到一个信号之后,可能会采取3种动作(默认,自定义,忽略)

如果要查询信号的动作可以输入man 7 signal

来验证一下ctrl + c 就是被OS识别成了信号2

这里需要介绍一个接口signal

这个接口本事就是一个函数指针,当碰到了sig信号后,就会直接调用func函数

cpp 复制代码
#include<iostream>
#include<unistd.h>
#include<signal.h>

using namespace std;

void handler(int signo)
{
    cout << "进程捕捉到了一个信号:" << signo << endl;
    //exit(1);
}

int main()
{
    signal(2, handler); // 当进程接收到了2号信号就会执行handler函数

    while(1)
    {
        sleep(1);
        cout << "pid: " << getpid() << endl;
    }
    return 0;
}

进程接收到ctrl + c,即2号信号就是自定义处理

此时输入kill -9 21717就能杀死

如果handler函数自带exit,那么也能退出

注意:

  1. Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。

  2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。

  3. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。

3.产生信号

3.1按键组合

就是类似ctrl + c这样的组合按键,会被OS识别为特定的信号

这里在介绍一个ctrl + /,会被OS识别为3号新号SIGQUIT

SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump

3.2系统调用产生信号

3.2.1 kill()

第一个接口:kill

这个接口也很简单,就是给参数pid的这个进程发送一个sig信号

成功返回0,失败返回-1

实验:模拟实现一个kill

cpp 复制代码
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<cstdio>

using namespace std;
// 模拟实现系统调用的kill

int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        cout << "\nUsage: " << argv[0] << " pid signo\n" << endl;
        exit(1);
    }
    pid_t pid = atoi(argv[1]);
    int signo = atoi(argv[2]);
    int n = kill(pid, signo); //利用系统调用发送信号
    if(n != 0)
    {
        perror("kill error");
        exit(1);
    }
}

如果此时有一个进程死循环,可以通过这个进程来实现kill的功能,通过命令参数,先传进程pid,在传signo【要发送的信号】

kill()函数可以向任意进程发送任意信号

3.2.2 raise()

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

cpp 复制代码
int cnt = 0;
while(cnt != 10)
{
	cout << "cnt: " << cnt << endl;
	cnt++;
	if(cnt == 5)
		raise(3); // 自己给自己发送3号信号
}

执行结果如下:

这个函数很简单,这里不多讲

3.2.3 abort()

给自己发送指定的6号信号SIGABRT

这个函数其实就等价于kill(getpid(), 6);

这个也很简单,不多说

3.3**.** 软件条件产生信号

SIGPIPE是一种由软件条件产生的信号,在"管道"中已经介绍过了。

这里主要介绍alarm函数 和SIGALRM信号

c 复制代码
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。

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

1s之后,os会给当前进程发送一个SIGALRM信号,即14号信号

这个alarm的意义是什么呢?其实就是统计1s内,某种累加行为能够累加到多少

上述代码执行的话最终会累加到4w~10w多次【取决于是本地虚拟机还是云服务器】,但是如果不输出到显示器上,而是一直while循环让cnt++,直至alarm发14号信号给进程的时候就将最终的cnt输出到屏幕上。此时cnt的值会是3亿多。这是因为cout输出到限制器上有一个与外设的IO过程。速度比起cpu来说很慢

拓展:

alarm是一个系统调用,很有可能有很多进程都在调用这个接口来在内核中设置闹钟。而为了更好的管理多个alarm的调用,依然是对alarm这个闹钟先描述后组织。

将闹钟管理起来的数据结构方法有很多,可以通过最小堆来实现。堆顶就是里超时时间最短的那个闹钟

3.4硬件异常产生信号

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

3.4.1 /0异常

比如下面这段代码:当前进程执行了除以0的指令

运行的时候就会崩溃。这是在学c语言的时候就知道的

可以看到进程确实终止了,这是一个现象,那到底为什么会终止呢?其实就是OS给当进程发了一个8号信号SIGFPE!

但是OS凭什么给进程发送信号,它怎么知道是这个进程运算出错了呢?

这就和硬件相关了!

在CPU中,有很多寄存器,有一些寄存器负责计算,而有一个寄存器负责计算状态是否正确------状态寄存器

当CPU的运算单元/0发生计算出来的数是一个无穷大的数,为异常,那么状态寄存器的溢出标志位就会置为1!本次计算状态为溢出状态!

而CPU出现异常计算状态,那OS就会识别到这个异常,并判断CPU的状态寄存器,识别是什么异常 ,发现是溢出标志位为1之后,就会找到是那个进程正在调度CPU,就会将这个异常解释 为SIGFPE信号发送给进程。

3.4.2 内存越界异常

其实就是数组越界或者野指针问题

bash 复制代码
[hb@localhost code_test]$ cat sig.c 
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
 	printf("catch a sig : %d\n", sig);
}
int main()
{
 	//signal(SIGSEGV, handler);
 	sleep(1);
 	int *p = NULL;
 	*p = 100;
 	while(1);
 	
 	return 0;
}
[hb@localhost code_test]$ ./sig 
Segmentation fault
[hb@localhost code_test]$ ./sig 
catch a sig : 11
catch a sig : 11
catch a sig : 11

11号信号就是SIGSEGV

那OS这里又是怎么知道是那个进程内存越界了呢?

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

这个MMU是内存管理单元,它是集成在CPU中的

4.理解信号的存在

我们发现,目标进程对os发送给它的信号会做处理,但是这个默认的处理,多数都是直接终止进程

那设置这么多信号的意义何在呢?

信号的意义:不同的信号,代表着不同的情况/事件。

而对不同的情况/事件的处理动作可以一样,都是进程终止,但是不同的情况代表着不同的终止原因

就像/0异常和内存越界异常一样,一个是8号信号,一个是11号信号。这就说明了不同的信号可以代表不同的情况,从而快速定位出错的原因,来修正代码!

5.总结一下

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

OS是进程的管理者,只有OS有权力修改位于进程PCB的信号位图

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

在合适的时候,这个合适的时候在学习信号捕捉之后就明白了

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

是的,并且要被记录在当前进程的PCB的信号标记位

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

应该知道

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

发送信号的本质其实就是修改进程PCB中的信号位图。

6.核心转储

在Linux中,c/c++在发生数组越界访问的时候,不一定会报错。比如下面这个代码:

c 复制代码
int main()
{
	int a[10];
	//a[10] = 1;
	//a[100] = 1;
	//a[1000] = 1
	a[10000] = 1; // 执行到这里才报错
    
	return 0;
}

这是因为在Linux中,向OS申请一个栈帧的时候,虽然a[10],只申请了10个空间,a也确实只能用10个空间,但是这不代表OS实际给你的空间就是10个int类型大小。

这里出现的错误也是段错误,11号信号

注意:在云服务器上,core除了终止进程所做的其他事情,是看不太到的。需要先打开限制

云服务器默认关闭了 这个选项,可以看到是0。

因此要输入ulimit -c 1024来打开这个选项

此时多了一个core dumped,意思就是核心转储,并且还会再当前目录生成一个文件

核心转储的概念:当进程因为异常而退出的时候,将进程在对应时刻的位于内存的有效数据转储到磁盘中!

这个文件的内容全都是二进制,直接打开行不通的

那核心转储的意义是什么呢?

**配合gdb来支持更好的调试,找到出错的原因!**在gdb上下文可以直接找到问题出错的地方和原因

7.全部信号都可以被自定义捕获吗?

先说结论:不可以,哪怕手动捕获了31个普通信号,但是OS仍旧不允许9号信号被捕捉。OS至少会保留一个9号信号来杀死异常的信号

代码如下:

cpp 复制代码
#include<iostream>
#include<unistd.h>
#include<signal.h>

using namespace std;

void catsig(int i)
{
    cout << "捕获到信号:" << i << endl;
}

int main()
{
    for(int i = 1; i < 32; i++)
    {
        signal(i, catsig);
    }

    int cnt = 0;
    while(1)
    {
        sleep(1);
        cout << "我正在运行: " << cnt++ << endl;
    }

    return 0;
}

尽管我们手动捕获了全部31个普通信号,但是9号仍然可以终止进程。

8.阻塞信号

  • 实际执行信号的处理动作称为信号递达(Delivery)

  • 信号从产生到递达之间的状态,称为信号未决(Pending)。

  • 进程可以选择阻塞 (Block )某个信号。

  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.

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

9.信号在内核中的表示(信号的保存)

说了这么多,信号未达,信号递达,信号阻塞这些概念,实际上信号在内核中到底是怎么被保存起来的呢?可以看下面这张图:

pending是决定一个进程是否收到信号的位图,是一个32位的位图,可以存储31个普通信号,从右往左,第一个比特位置1就是当前进程接收到信号1

block是决定一个进程是否阻塞了某个信号的32位的位图

结合pending和block两个位图,此时可以判断一个进程是否递达了某个普通信号,但是递达了之后要处理信号(默认、自定义、忽略)。而handler就提供了OS中对某个信号的处理方法

handler本质上是一个函数指针数组!每个下标都对应一个信号编号,而下标对应的内容,就是对应信号的处理方法!

那当一个2号信号发给一个进程的时候,在内核数据结构的角度来看发生了什么?

首先判断是否存在自定义处理信号2的函数,如果有就将该函数的地址填入到该进程的handler数组中下标为2的位置,然后判断该进程的block位图的从右到左第二个比特位是否为1,也就是判断该进程是否阻塞2号信号,如果不阻塞(比特位为1),就判断在pending位图的第二个是否为1,不为1就置为1,然后调用handler中存储的信号2的处理方法

因此,现在在对第一个图片的例子进行一个总结:

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

  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。

  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。

  • 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。这里不讨论实时信号

10.信号的捕捉流程(重要)

10.1用户态、内核态

信号产生的时候不会立即被处理,而是在合适的时候!

这个合适的时候就是------从内核态转变为用户态的时候!要弄清楚信号的捕捉流程,就要弄清楚内核态和用户态是什么,内核态如何转变为用户态,什么时候?

很多时候,我们作为用户级,总是有对硬件和内核资源的访问需求,而硬件和内核资源又只能由OS访问,因此当我们的进程调用系统调用的时候,实际上是切换为内核态去执行的系统调用

那是CPU怎么知道,什么时候是用户态,执行用户代码,什么时候是内核态,执行内核级代码,这就要了解CPU的结构了

10.2 CPU寄存器CR3

在CPU中有很多看得见的寄存器也有很多看不见的寄存器,而cpu怎么执行进程的代码呢,这是因为有一个寄存器专门负责记载task_struct,也有一个寄存器专门负责页表的起始地址,因此,也有一个寄存器叫CR3,专门负责当前进程的运行级别

10.3进程地址空间的内核区

但是作为一个进程,我又如何能够切换到内核态,执行OS的方法呢?

这就要回忆之前所学的进程地址空间了。前面学的都是用户层面的进程地址空间**,完整的进程地址空间一共有4G,用户级占3G,内核级占1G**

因此内核级进程地址空间,自然也需要内核级页表来映射物理地址和虚拟地址之间的关系,而每次开机的时候,就会把os的相关数据加载到内存中。

由于所有用户层调用的都是一样的系统调用和内核数据,因此所有内核级页表一个就够了。也就是说,所有进程地址空间的内核区(3~4G)都是同一个内核级页表映射的!即每一个进程地址空间能够找到的OS都是同一个。

这也很合理,OS本来也只有一个,让所有的进程都看到同一个OS才是正常的。

这就意味着:一个进程想要调用系统调用,只需要在自己的进程地址空间,跳转到内核区,然后就可以调用了!

拓展:

进程切换有两种切换方式,一种是时间片策略,一个进程被OS强制切换了,还有一种是进程主动退出【通过系统调用的接口实现】,让给其他进程

10.4用户态和内核态的切换时机

知道了上面三个点之后。我们知道了,用户希望访问内核或者硬件资源,就会在自己进程的地址空间跳转到内核区找到对应的系统调用。但是凭什么用户就能够直接访问内核数据和硬件资源呢?不是说CR3会判断当前进程的执行身份吗?用户的进程肯定是3(用户态)

这是因为,当用户的进程调用系统调用的接口时,在系统调用中,会有代码将当前进程的身份从3(用户态)切换到0(内核态),然后再跳转到进程地址空间的内核区去访问OS的代码和数据。然后CPU才能接着执行

10.5总结

因此,当一个用户进程需要访问OS内核数据和代码的整体过程是怎么样的呢?

首先用户进程执行到系统调用的接口的时候,该接口会先将进程的用户态改为内核态,然后再自己的进程地址空间,从用户区跳转到内核区,然后CPU判断当前进程身份是否是内核态,然后再调用需要的OS的数据或代码。执行完后将内核态切换为用户态,然后回到进程地址空间的用户区,继续执行用户代码

这个时候回到我们一开始的问题,信号的捕捉流程 ,信号的捕捉其实就是对信号做处理。而信号不会立即被处理,是在内核态转为用户态的时候处理。这个过程是什么样的呢?来看看下图:

在这张图片中,我们可以看到,用户进程在调用系统调用接口之后,切换到了内核态【在切换之前所做的准备工作前面已经学习了】,访问了OS的相关代码和数据,此时执行完了之后,就准备要从内核态切换回用户态

而此时OS就会做一个工作!就是从当前进程的PCB中,找到信号的内核数据结构,判断当前进程是否保存了信号需要处理、如果检测到有一个信号未被block阻塞,也在pending中置1,那就会去handler的对应下标找到处理方法去处理。

而处理又分三种方式【默认,忽略,自定义】。而默认和忽略都很简单。

  • 默认:大部分默认方法就是os内部规定的,而此时还处于内核态,因此顺便就将信号处理了,
  • 忽略:将pending的对应的比特位置0,然后返回用户态继续执行
  • 自定义:就需要在handler这个函数指针数组中,找到用户自定义的函数地址,然后跳转过去执行?不是的,此时进程处于内核态,无法执行用户态的函数!【os实际上从权限上看能执行用户态的代码,但是不可以这样做!】
  • 因此自定义是这样处理的:在handler这个函数指针数组中,在对应下标找到用户自定义的函数地址,然后先切换回用户态!然后跳转过去执行自定义的方法,完成对信号的处理(捕获),然后再切换回内核态,最终在返回到用户态!

将上述过程抽象一下就得到下图,方便记忆

10.6官方解释

上述的理解都是比较偏主观的,没有那么官方,下面是一些官方的说法,会带上具体是如何实现的

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

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

11. sigset_t

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

因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有 效"和"无效"的含义是该信号是否处于未决状态

阻塞信号集也叫信号屏蔽字(Signal Mask)

12.信号集操作函数

**sigset_t类型并不是上面我们所讲的一样就是一个32位的位图,只能用来表示每个信号的有效或无效状态,它实际上还封装了一些东西,是一个结构体类型。**因此并不能简单的直接那这个类型的变量去做位运算操作,而是要使用os提供的接口

c 复制代码
#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类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的 状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号

  • signsmember: 可以查看set信号集的第signo个比特位是否有效

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

13.sigprocmask && sigpending

  • sigprocmask

前面的信号集操作函数都是对一个信号集(sigset_t)类型进行操作,而sigprocmask就是直接对进程当前的阻塞信号集(block)进行操作。调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。

c 复制代码
#include <signal.h>
 int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
返回值:若成功则为0,若出错则为-1

how参数是一个选项,表示可以选择的操作,下图都是SIG开头,少截了

set参数是配合how的,如果要添加,就将set参数表示的信号集设置到当前进程的阻塞信号集。如果要解除,也是将其set表示的信号集设置到进程的阻塞信号集

oset参数是一个输出型参数,它就记载修改前的block

  • sigpending

这个接口很简单,谁调用它,谁就能获取当前进程的pending信号集

c 复制代码
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

14.实验

用前面学的几个系统调用来做一个实验:

cpp 复制代码
#include<iostream>
#include<vector>
#include<signal.h>
#include<unistd.h>

using namespace std;

#define MAX_SIG 31

static vector<int> sig_block = {2, 3}; // 要阻塞的信号都装在这个vector中

void Print_Pengding(const sigset_t& pengding)
{
    //打印
    for(int i = MAX_SIG; i >= 1; i--)
    {
        //判断pengding信号集第i位的的比特位是否为1
        if(sigismember(&pengding, i))
            cout << "1";
        else    
            cout << "0";
    }
    cout << "\n";
}

int main()
{
    // 1. 初始化信号集
    sigset_t block, oblock, pending;
    sigemptyset(&block);
    sigemptyset(&oblock);
    sigemptyset(&pending);

    //2. 添加要阻塞的信号
    for(auto& sig : sig_block)
        sigaddset(&block, sig);

    //3. 将要阻塞的信号添加到当前进程的block中
    sigprocmask(SIG_SETMASK, &block, &oblock);

    //4. 不断获取当前进程的pending信号集
    int cnt = 10;
    while(true)
    {
        //4,1 初始化
        sigemptyset(&pending);
        //4.2 获取当前进程pengding
        sigpending(&pending);
        //4.3 打印pending信号集
        Print_Pengding(pending);
        //慢一点
        sleep(1);
        if(cnt-- == 0)
        {
            cout << "解除对信号的屏蔽\n";
            //将修改之前的信号集设置到当前进程
            sigprocmask(SIG_SETMASK, &oblock, &block); //一旦解除对信号的屏蔽,OS至少会立马处理一个信号!
            cout << "解除完毕\n";

        }
    }

    return 0;
}

执行结果如下:

这里有个问题,为什么cout << "解除完毕\n";这个代码没有被执行呢?

因为我们这里没有自定义处理信号2,当信号被解除屏蔽之后,会立马处理信号2,而处理方法就是默认,就直接到handler数组中找到默认处理方法,然后处于内核态的情况下就直接处理了信号2,因此不会回到用户态了,自然不会执行用户态的下一步代码。

因此:如果不想要推出,可以用signal自定义捕获2号信号。并且在自定义方法内也不要退出。这样进程就会回到用户态

代码也很简单:

cpp 复制代码
void handler(int signo)
{
    cout << signo << "号信号被捕获" << endl;    
}
cpp 复制代码
    //对sig_block内的信号进行自定义捕获
    for(auto& signo : sig_block)
        signal(signo, handler);

此时执行效果如下:

但是此时打印出来的pengding信号集,无法显示某个信号被发送到该进程了。既无法看到发送的信号变成1了,如下图所示

这是因为,之前被阻塞的信号不在阻塞之后,每次发送到该进程都能直接递达,并且直接自定义方法处理。

让实验更有趣

因此想重新看到某个信号因为阻塞了,而处于未达状态,就要重新设置阻塞。也就是能重新看到pending信号集出现1

代码如下:

cpp 复制代码
#include<iostream>
#include<vector>
#include<signal.h>
#include<unistd.h>

using namespace std;

#define MAX_SIG 31

static vector<int> sig_block = {2, 3}; // 要阻塞的信号都装在这个vector中

void Print_Pengding(const sigset_t& pengding)
{
    //打印
    for(int i = MAX_SIG; i >= 1; i--)
    {
        //判断pengding信号集第i位的的比特位是否为1
        if(sigismember(&pengding, i))
            cout << "1";
        else    
            cout << "0";
    }
    cout << "\n";
}

void handler(int signo)
{
    cout << signo << "号信号被捕获" << endl;
}

int main()
{
    //对sig_block内的信号进行自定义捕获
    for(auto& signo : sig_block)
        signal(signo, handler);


    // 1. 初始化信号集
    sigset_t block, oblock, pending;
    sigemptyset(&block);
    sigemptyset(&oblock);
    sigemptyset(&pending);

    //2. 添加要阻塞的信号
    for(auto& sig : sig_block)
        sigaddset(&block, sig);

    //3. 将要阻塞的信号添加到当前进程的block中
    sigprocmask(SIG_SETMASK, &block, &oblock);

    //4. 不断获取当前进程的pending信号集
    int cnt = 10;
    while(true)
    {
        //4,1 初始化
        sigemptyset(&pending);
        //4.2 获取当前进程pengding
        sigpending(&pending);
        //4.3 打印pending信号集
        Print_Pengding(pending);
        //慢一点
        sleep(1);
        if(cnt-- == 0)
        {
            cout << "解除对信号的屏蔽\n";
            //将修改之前的信号集设置到当前进程
            sigprocmask(SIG_SETMASK, &oblock, &block); //一旦解除对信号的屏蔽,OS至少会立马处理一个信号!
            cout << "解除完毕\n";
            //如果想要sig_block内的信号重新被阻塞,这里可以设置
            cout << "重新设置阻塞\n";
            sigprocmask(SIG_SETMASK, &block, &oblock); //将之前被解除阻塞的信号重新设置阻塞
            cnt = 10; //重置,再来10s之后,再次进入该分支
        }
    }

    return 0;
}

此时这个代码会每个10s为一个周期,将阻塞的信号取消阻塞,然后os进行一次集中的递达,然后自定义处理,然后重新设置阻塞信号

执行效果如下:

15.sigaction

除了signal可以对信号进行自定义捕获,还要一个函数sigaction也可以进行捕获。这个函数的功能更加多样

c 复制代码
#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结构体:

  • 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动 作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回 值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信 号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用

OS对于信号的处理原则:

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

OS处理信号的原则:只允许串行的处理信号,而不允许递归

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

sa_flags字段包含一些选项,本章的代码都 把sa_flags设为0,sa_sigaction是实时信号的处理函数,这里不详细解释这两个字段

16.可重入函数 && 不可重入函数

这里不深讲,到线程部分会学习,这里就是引出一个概念,了解一下

一般来说,都认为main函数执行流和信号捕获执行流是两条执行流

就如下图所示:

insert是一个头插函数,本来按照main函数执行流,是应该让node1头插,但是当执行到node1的next指向原来的头节点,即将把head修改到node1的时候,此时发生了信号的捕获(自定义),在自定义处理函数中,再度调用了一次insert,此时头插了另一个节点node2,这就导致了当回到main执行流的时候,head从指向node2再度指向了node1。这造成了内存泄漏,因为node2是无效的

像上例这样**,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数有可能因为重入而造成错乱,像这样的函数称为不可重入函数**

反过来,如果一个函数不会因为重入而出错的话,就称为可重入函数

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

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

17.volatile

下面来聊一下编译器优化的问题,下面是一个代码,编译的时候不带优化选项

bash 复制代码
[hb@localhost code_test]$ cat sig.c 
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{
	printf("chage flag 0 to 1\n");
	flag = 1;
}
int main()
{
	signal(2, handler);
	while(!flag);
	printf("process quit normal\n");
	return 0;
}
[hb@localhost code_test]$ cat Makefile 
sig:sig.c
    gcc -o sig sig.c #-O3
.PHONY:clean
clean:
	rm -f sig
[hb@localhost code_test]$ ./sig 
^Cchage flag 0 to 1
process quit normal

按照本次代码的思路就是:有一个死循环while,如果当前进程递达了一个信号2,那么就会自定义处理,在handler函数中会将flag的值改为1,然后该进程就会退出循环,然后正常退出

但是当我们带上编译器的优化选项之后:会发现一个奇怪的现象------无论我们怎么发信号给当前进程,尽管会不断的递达,不断的自定义捕获2号信号,但是循环无法退出

这是为什么呢?看下图:

因此,为了解决这个问题,volatile关键字就出现了

volatile:保持内存可见性

它的作用说白话就是告诉CPU,我volatile声明的变量,你不要给我保存在寄存器中,我要你每次取他的时候都要从内存中取

因此上面的代码只需要给flag带上一个volatile声明即可:

c 复制代码
volatile int flag = 0;

18.SIGCHLD信号(与进程等待相关)

之前在进程等待和进程状态的时候,我们说过,当一个子进程死亡的时候,会进入僵尸状态,然后等待父进程来回收自己,而父进程也需要以阻塞或者非阻塞的状态去等待子进程。

但是子进程死亡的时候不是直接死的,在死之前会发一个SIGCHLD信号(17号)给父进程

这个信号的默认操作是Ign

在学习了信号后,现在有个想法:我是否能通过递达子进程发给我的信号,然后再自定义处理方法,来对子进程进行资源的回收和退出信息的获取

当然可以,前提是使用正确。【并且这样还有好处,之前是直接使用wait或者waitpid以阻塞的方式或者轮询非阻塞的方式进行等待,现在是一直在干自己的事情,等待子进程自己给我发信号,然后再进行进程等待】

写代码之前要小心两个点:

  1. 信号的处理原则:串行处理,而非递归。也就是如果同一时间有很多个子进程退出,那么在处理一个子进程发来的17号信号,会直接自动阻塞17号信号,这样就会导致大量进程无法被回收。因此在进程等待的时候,一定要while循环不断地进程等待
  2. 一定要选择轮询非阻塞等待,因为如果有很多进程,都是隔一段时间结束一个,那么阻塞式等待就会将整个进程都卡在处理17号信号的自定义函数上,父进程无法再做自己的事情。
c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
    pid_t id;
    while ((id = waitpid(-1, NULL, WNOHANG)) > 0)
    {
        printf("wait child success: %d\n", id);
    }
    printf("child is quit! %d\n", getpid());
}
int main()
{
    signal(SIGCHLD, handler);
    pid_t cid;
    if ((cid = fork()) == 0)
    { // child
        printf("child : %d\n", getpid());
        sleep(3);
        exit(1);
    }
    while (1)
    {
        printf("father proc is doing some thing!\n");
        sleep(1);
    }
    
    return 0;
   }

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法**:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。**

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

也就是将signal(SIGCHLD, handler);改为signal(SIGCHLD, SIG_IGN);

此时子进程退出就不在需要父进程的回收,而是直接被OS回收

  • 注意:

系统默认的忽略动作Ign和用户用sigaction函数自定义的忽略SIG_IGN 是不一样的!

这里的Ign就是按照之前的流程,死之前给父进程发个17号信号,然后等待父进程来回收

相关推荐
阿俊仔(摸鱼版)2 小时前
Ubuntu上安装Docker
linux·ubuntu·docker
故事与他6452 小时前
Thinkphp(TP)框架漏洞攻略
android·服务器·网络·中间件·tomcat
yunqi12152 小时前
【负载均衡系列】nginx负载高怎么排查
运维·nginx·负载均衡
IYU_2 小时前
VulnHub-Web-Machine-N7通关攻略
服务器·安全·web安全·网络安全
云上艺旅2 小时前
K8S学习之基础四十七:k8s中部署fluentd
学习·云原生·容器·kubernetes
BigBookX3 小时前
在 Ubuntu 中配置开机自启动脚本并激活 Anaconda 环境
linux·运维·ubuntu
kfepiza3 小时前
netplan是如何操控systemd-networkd的? 笔记250324
linux·网络·笔记·ubuntu
yi个名字4 小时前
Linux中的yum和vim工具使用总结
linux·运维·vim
lmy3477712324 小时前
东软鸿蒙C++开发面经
开发语言·c++
m0_490240674 小时前
qt实现一个简单http服务器和客户端
服务器·qt·http