文章目录
- 1. 阻塞信号
- 1.1 信号其他相关常见概念
- 1.2 在内核中的表示
- 2. sigset_t
- 3. 信号集操作函数
- 3.1 sigprocmask
- 3.2 sigpending
- 3.3. 实例演示
- 4. 信号的处理
- 4.1. sigaction
- 4.2 多个信号的处理
- 5. 可重入函数
- 6. volatile
- 7. SIGCHLD信号
1. 阻塞信号
1.1 信号其他相关常见概念
1.实际执行信号的处理动作称为信号递达(默认,忽略,自定义捕捉)。
2.信号从产生到递达之间的状态,称为信号未决。
3.进程可以选择阻塞 (Block)某个信号。
4.被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞才执行递达的动作。
5.阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
1.2 在内核中的表示
信号在内核中的示意图:
这里的pending就是前面说的位图,用来识别信号。handler就是函数指针数组,用来处理信号的。block(阻塞方法集)也是位图,它和pending的结构是一样的,几号bit位代表几号信号,表示该信号是否阻塞。
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号。因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
2. sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储。sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
3. 信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现。从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做
任何解释,比如用printf直接打印sigset_t变量是没有意义的。
1.函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
2.函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位设为1,表示该信号集的有效信号包括系统支持的所有信号。
3.函数sigaddset是添加某个信号到这个信号集中。
4.函数sigdelset是删除某个信号。
5.函数sigismember是判断某个信号是否在信号集中。
注意:在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
3.1 sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集,也就是block表)。
假设当前的信号屏蔽字为mask,下表说明了how参数的可选值:
这里第二个参数set就是我们用户需要输入的,第三个参数是OS返回给用户的。如果oset是非空指针,则读取进程的当前信号屏蔽字(原来的)通过oset参数传出。
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
3.2 sigpending
这是一个输出型参数,读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
3.3. 实例演示
当我们获取当前信号集的时候,我们把它打印出来。
打印函数我们要自己写一下:
我们先看一下运行结果:
我们可以看到一开始都是0,现在我们给这个进程发2号信号:
可以看到它并没有把对应的位置打印出来,直接结束进程了。那么我们就需要将2号信号给捕捉一下:
现在我们再看一下运行情况:
这里收到了信号,但是对应的pending信号集里还是没有,因为CPU很快就处理了这个信号,我们看不到。
所以,当我们发送2号信号时,让2号信号block:
这里可以看到,2号信号被阻塞了,但发送了2号信号,对应的pending信号集已经变成1了。
既然我们给它阻塞了,那么我们就需要恢复它。
运行结果如下:
4. 信号的处理
之前说过,进程处理信号,不是立即处理,是在合适的时候,那么到底是什么时候呢?
当当前进程从内核态,切换回用户态的时候,进行信号的检测与处理。
那么什么是内核态和用户态呢?
前面我们说过,进程的会通过虚拟地址空间和页表把用户空间(3G)映射到物理内存。那么这里的页表叫做用户级页表,每一个进程,都有一份,但是大家的用户级页表都是不一样的。
其实在OS还有一份页表叫做内核级页表,它映射的是内核空间(1G),这个页表所有进程共享一份。
我们知道无论进程怎么切换,我们都可以找到内核的代码和数据,前提是你要有权利访问。
那么我们怎么知道当前进程如何具备权利,访问这个内核页表,乃至访问内核数据呢?
要进行身份切换,进程如果是用户态,那么只能访问用户级页表,进程如果是内核态,可以访问内核级和用户级页表。
那么怎么知道当前进程是用户态还是内核态呢?
CPU内部有对应的状态寄存器,可以用bit位标识当前进程的状态0代表内核态,3代表用户态。
什么时候进入到内核态呢?
系统调用的时候或者时间片到了,进程间切换。
我们的程序会无数次直接或者间接的访问系统软硬件资源(管理者是OS),本质上,你并没有自己去操作这些软硬件资源,但是通过了OS,从而无数次的陷入内核(1.切换身份,2.切换页表),然后调用内核的代码,从而完成访问动作,把结果返回给用户(1.切换身份,2.切换页表)。
举个例子:
在我们的代码中写了一个系统调用open,当执行到open时,就会进入内核态,当执行完open函数时,OS会检测进程的PCB,检测PCB里面的信号(block&&pending),假设block为0,pending为1,那么就会去执行对应的handler,假设handler是我们自己定义的方法:
但是当我们执行完自己的handler方法时,它会直接去执行open后面的代码吗?
答案是:不会。原因是:我们是执行open函数时遇到信号了,当处理完信号后,open并没有返回值,所以我们不能直接执行open后面的代码。而是先回到内核态通过处理再回到用户态执行open后面的代码。
那么这里是以谁的身份去执行这个handler呢?
可能大家会认为是内核身份,虽然内核态可以去执行,但是它不愿意。这里只能用户级身份来执行。原因是:这个handler是我们自己写的,如果在里面我们写了一段恶意代码,让内核去执行,那么就可能造成问题。
完整过程:
4.1. sigaction
sigaction函数可以读取和修改与指定信号相关联的处理动作,也就是获取和修改handler表。调用成功则返回0,出错则返回- 1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体。
这个函数和signal功能是差不多的,但是signal比较简单。
那么我们先看一下sigaction的结构体:
我们暂时先考虑这两个参数,其它的暂时不考虑。
代码演示:
这里我们设置了一个自定义捕捉动作。
当收到信号时,完成了自定义捕捉动作。
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字。这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
举个例子:
运行结果:
首先执行的是主函数里面的sleep。
当我们发送2号信号时,它就会一直处理。
当我们再次发送2号信号,它就会屏蔽了。
那么这个sigaction里面的maks到底是什么呢?
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
运行结果:
现在我们想要结束进程,我们可以使用kill -9,也可以使用killall 进程名。
4.2 多个信号的处理
方法一:
这样就可以调用同一个函数来执行不同的方法。然后我们把捕捉方法写好就行:
测试结果:
方法二:
这里可以使用一个vector来把所有方法push进去,但是我们需要自己把所有函数捕捉方法写好。所以我们更推荐 unordered_map。
5. 可重入函数
这里是一个单链表,在main函数里面调用插入函数,并且在信号捕捉里也调用了这个插入函数。
我们执行main函数里面的插入时,假设时间片到了,只完成了第一步,没有完成head=p,那么此时用户就会从用户态切换成内核态。那么在内核态就会检测信号,然后就会执行我们的handler方法。
那么handler方法没人打扰,就会执行完。
执行完后,再去我们自己的代码执行head=p。
insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。
insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数。反之,如果一个函数只访问自己的局部变量或参数,则称为可重入函数。
如果一个函数符合以下条件之一则是不可重入的:
1.调用了malloc或free,因为malloc也是用全局链表来管理堆的。
2.调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
6. volatile
该关键字在C++当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下:
在没有收到2号信号的时候一直在循环里判断,当收到2号信号的时候,就退出循环。
这是正常的理想状态下。
这里O2是告诉编译器进行优化。
还是同样的代码,但是现在循环并没有退出。
这里的优化是把flags的值放到了CPU的寄存器中,在逻辑计算的时候,就不会从内存中取数据,而是直接从CPU寄存器里取数据。那么为了防止这种情况,我们可以加个volatile。
又可以正常退出了。
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
7. SIGCHLD信号
在子进程退出的时候,自动给父进程发送SIGCHLD信号。
证明如下:
我们对子进程暂停或者退出,看父进程能否捕捉信号。
现在我们暂停子进程:
我们再恢复子进程:
我们再删除子进程:
我们可以看到以上三种情况,父进程都可以收到SIGCHLD信号。
那么子进程给父进程发送信号有什么作用呢?
这是我们之前写的一个父进程等待子进程,但是都是父进程要自己主动等待。
现在我们可以让父进程去做自己的事情,不主动等待:
我们让父进程做自己的事情,并且捕捉信号。
我们可以看到:父进程和子进程一开始都在运行,然后子进程退出的时候,父进程一直做自己的事情,并且自动等待成功。
但是这个代码有一些bug:
如果我们一次性创建多个进程,当进程退出时给父进程发送信号,就可能造成多个进程同时退出。而Linux处理一次信号时,其它信号可能就被阻塞了,那么其它进程就不会被等待回收,一直就是僵尸进程。
我们循环不断waitpid去等待子进程,当所有子进程都没了,就退出循环。
但是这里还存在一些问题:
这里的意思是:前8个运行5秒,后2个子进程运行20秒。这种情况会造成后两个子进程在运行时,父进程会在waitpid被阻塞等待。
那么父进程就不能执行自己的代码了。我们需要改成WNOHANG
当参数为WNOHANG时,id等于0的时候,说明子进程还有没退出完的。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction或者signal将SIGCHLD的处理动作置为SIG_IGN。这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
设置过后,父进程就不需要任何处理了,OS就会把所有僵尸状态的子进程都给忽略掉。
子进程退出的时候,默认的信号处理就是忽略,那么调用signal/sigaction设置为SIG_IGN,,意义在哪里呢?
SIG_IGN手动设置,让子进程退出,不要给父进程发送信号了,并且自动释放。