目录
一,常见的信号术语
信号递达(Delivery):
- 信号实际被执行处理的过程;(当一个信号被递达给进程时,该信号的处理动作已经开始执行实际执行信号的处理动作);
信号未决(Pending):
- 信号从产生到递达的中间状态;(信号已经生成但尚未被递达的状态。在此期间,信号处于阻塞状态,直到它被递达给目标进程)。
信号阻塞(Block):
- 进程可以选择阻塞某个信号;(即使信号已经生成,也会保持在未决状态,不会被递达给进程。信号只有在解除阻塞后才能被递达)。
信号忽略:
- 信号被递达后,进程选择不做任何响应。不同于信号阻塞,被忽略的信号已经完成了递达,只是进程对其没有任何反应。
捕获信号:
- 进程通过自定义信号处理程序来处理信号,而不是使用系统的默认动作;
二,信号在内核中的表示
在上篇中介绍了信号是由位图保存的,信号如何产生的;
信号是用户,操作系统,进程交互用的;而进程是如何知道操作系统给它发了信号呢;
信号标志位
- 阻塞 (block):如果设置,该信号会阻塞,不会被递达给进程;
- 未决 (pending):如果设置,表示该信号已生成但未处理;
信号通过三种结构来表示和管理,这些结构存储在进程的task_struct中;
- Pending 位图:表示哪些信号已经生成但尚未被递达或处理;(跟踪信号的未决状态)
- Block 位图:记录了哪些信号被当前进程所阻塞。控制信号是否可以被递达;
- Handler 函数指针:指向一个函数指针,用于指定当信号递达时要执行的处理动作;
Pending表
- 在信号位图(Signal Bitmap)中,每个比特位的位置表示某个信号,比特位的内容代表是否收到该信号(信号是否未决);如果有信号发送给该进程,那么这个位图对应的信号位会置为1,没有就是0;
- 假如给进程发送2号信号,那么该位图的第3位会置位1,但不会立即处理该信号,会在合适的时候处理,这个合适的时候是进程从内核态返回到用户态的时候进行处理;
- Pending表的存在让进程知道接受了哪些信号并准备处理信号;而信号是否要被处理,还要查看该信号是否被阻塞,此时需要查看进程中Block表的内容;
Block表
进程用一张位图表来表示被进程阻塞的信号集;每个比特位的位置代表着某个信号编号,比特位的内容代表着该信号是否被阻塞;内容为1表示对该信号进行阻塞,为0表示没有对该信号进行阻塞。第一个比特位位置代表 1号信号,以此类推(位图结构)
有了上面两张位图表,进程就能知道接受了哪些信号,信号是否阻塞(不需要处理),而信号也有需要处理的;这个就要看handler表;
handler表
进程中这张handler表与上面两张位图表有所不同,这是函数指针数组(sighandler_t handler[32]);数组的下标是信号的编号,数组的内容是函数指针(该信号的处理函数);处理函数(处理动作)包括默认,忽略以及自定义;
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。
- block、pending和handler这三张表的每一个位置是一一对应的;
- 信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,1号信号SIGHUP 未阻塞也未产生过(pending位图对应信号位置标志位为0,block位图对应信号位置标志位为0),当它递达时执行默认处理动作(SIG_DFL)。
- 对于2号信号SIGINT,该信号产生过(pending位图对应信号位置标志位为1,处于未决),但正在被阻塞(block位图对应信号位置标志位为1),所以暂时不能递达。虽然它的处理动作是忽略(SIG_IGN),但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- 3号信号SIGQUIT 未产生过(pending位图对应信号位置标志位为0),该信号正在被阻塞(block位图对应信号位置标志位为1),一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
总结:
一个信号哪怕没有被产生,也不影响该信号被阻塞;
POSIX.1标准
允许系统在信号解除阻塞之前,递送该信号一次或多次给进程。
如果在进程接触对某信号的阻塞之前,该信号产生过多次,该如何处理❓
常规信号(普通信号):
如果一个常规信号在被解除阻塞之前产生了多次,那么仅会递送一次该信号给进程。
实时信号:
实时信号在被解除阻塞之前产生的多次可以被依次放入一个队列中等待递送。
三,sigset_t
由于block和pending 位图是内核内部的数据结构,用户程序无法直接访问和修改它们,OS也不能提供十几个参数的函数给用户使用吧,所以就有了 sigset_t。为了能够修改这些信号集,用户程序需要通过系统调用来进行操作:sigset_t类型的数据结构让用户可以方便地设置和查询信号集的状态;
- 在block,pending位图中,比特位非0即1,只表示信号是否产生;不记录产生了多少次;
- 未决和阻塞标志可以用相同的数据类型 sigset_t 来存储(在用户层);sigset_t 称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。
在阻塞信号集中 :
“有效”状态表示信号被阻塞,“无效”状态表示信号未被阻塞。
在未决信号集:
“有效”状态表示信号处于未决状态,“无效”状态表示信号没有被生成或已被处理。
信号集操作函数
操作系统提供了一些函数来管理信号集(sigset_t,包括Block和pending表),以下是常见的信号集操作函数(都包含在signal.h头文件中):
sigemptyset
int sigemptyset(sigset_t *set);
- 将set指向的信号集清空
- 成功返回0,否则返回-1.
sigfillset
初始化一个满的信号集,跟sigemptyset函数相反,让信号集中每一个比特位都置为1。
int sigfillset(sigset_t *set);
- 将set指向的信号集填满
- 成功返回0,否则-1.
sigaddset
从信号集中添加一个指定的信号。
int sigaddset(sigset_t *set, int signum);
- 给set指向的信号集中添加一个signum信号
- 成功放回0,否则-1.
sigdelset
从信号集中删除一个指定的信号
int sigdelset(sigset_t* set,int signum);
- 从set指向的信号集中删除一个signum信号
- 成功返回0,失败则返回-1.
sigismember
检查一个指定的信号是否在信号集中
int sigismember(const sigset_t *set,int signum);
- 如果set指向的信号集中有signum信号就返回1,不在返回0,查看失败返回-1。
sigprocmask
检查或者修改信号屏蔽字(阻塞信号集)。用来阻塞或者解阻信号。
跟上面的sigaddset和sigdelset函数不一样的是,sigprocmask可以修改进程的Block表。
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数:
how:指定操作类型。以下是常见的操作类型:
SIG_BLOCK:将set指向的信号集中的信号添加到阻塞信号集中
SIG_UNBLOCK:从阻塞信号集中移除set指向的信号集中的信号
SIG_SETMASK:将set指向的信号集设置为新的阻塞信号集
set:指向一个sigset_t类型的对象,用于修改阻塞信号集。如果该参数为NULL,则表示不修改阻塞信号集。
oldset:指向一个sigset_t类型的对象,用于存储被set修改之前的阻塞信号集。
成功返回0,失败返回-1.
sigpending
获取当前进程的未决信号集(pending表)
int sigpending(sigset_t *set);
- 将set指向的信号集重置为当前进程的未决信号集
- 成功返回0,否则-1.
信号集实验
#include <signal.h>
#include <unistd.h>
// 定义一个函数来打印信号集的内容
void PrintSet(sigset_t *set)
{
// 循环遍历信号集中的每个信号位
for (int i = 31; i >= 1; i--) //只看默认的
{
// 如果信号集中的信号i被设置,则输出1
if (sigismember(set, i))
{
putchar('1');
}
// 否则输出'0'
else
{
putchar('0');
}
}
// 输出换行符
puts("");
}
int main()
{
sigset_t s, p;
// 初始化信号集s为空
sigemptyset(&s);
// 将 SIGINT (2) 添加到信号集s中
sigaddset(&s, SIGINT);
// 设置信号屏蔽字,将信号集s中的信号设置为阻塞状态
// SIGINT将不会被进程接收
sigprocmask(SIG_BLOCK, &s, NULL);
// 主循环
while (1)
{
// 查询当前进程的未决信号集,并将其存储在p中
sigpending(&p);
// 打印未决信号集p的内容
PrintSet(&p);
// 让进程休眠一秒
sleep(1);
}
return 0;
}
按下ctrl+c后程序没有被终止,说明这个信号确实被阻塞了,然后未决表显示该信号一直出于未决状态,没有被递达
四,深入理解捕捉信号
实际上进程地址空间由内核空间和用户空间组成;
用户所写的代码和数据位于用户空间,操作系统(OS)的代码和数据都位于内核空间
用户空间大小:[0, 3]GB;
内核空间大小:[3, 4]GB;
用户空间对应的页表映射到物理地址,内核空间也有自己的页表映射(内核级页表);
但是不同的是,每个进程都有一份用户级页表,对应着进程的独立性,但是内核级页表只有一份,所有进程共享;
内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系
前文提过当进程pending改变时(收到未决信号)表示有进程要处理,这个合适的处理时间就是从内核态到用户态;
很多时候我们使用系统调用时,都在访问OS内核数据,而OS不相信任何人,按理说是不能这样做的;
当我们使用系统调用时,系统会自动进行身份切换,user->kernel。OS是如何识别现在的状态是用户态还是内核态的❓先从CPU起。
CPU
首先先简单介绍一下CPU内存在大量的寄存器,寄存器分为两类:
- 可见寄存器
- 不可见寄存器
这些进程都是和当前进程强相关的,寄存器内的数据都是该进程的上下文数据,有的寄存器指向进程的task_struct,另一个寄存器指向进程的用户级页表,所以CPU可以找到当前进程的task_struct和用户级页表(OS可以从寄存器的相关数据和使用的页表来判断是内核态还是用户态,因为进程共享一份内核页表,可以通过权限提升访问),又因为页表的MMU(内存管理单元)集成在CPU里面,所以在CPU这里虚拟地址到物理地址的转换就直接完成了
其中最重要的是:
CPU内有一个寄存器:CR3,这个寄存器用于表示当前进程的运行级别,0表示进程处于内核态级别,3表示处于用户态级别
内核态和用户态
进程如果访问的是用户空间的代码,此时的状态就是用户态,如果访问的是内核空间,此时的状态就是内核态;处于用户态的程序只能访问用户空间的内存地址,而无法访问内核空间的地址。内核态可以访问整个虚拟空间地址,包括用户空间和内核空间。
- 内核态通常用来执行操作系统的代码,是一种权限非常高的状态
- 用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态
捕捉信号
五,sigaction函数
sigaction和signal都是用来处理信号的函数,但是sigaciton是一种更为强大和灵活的喜好处理机制,提供了更多的选项和更高的可靠性。
头文件:#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数:
第一个参数signum代表指定信号的编号
第二个参数act是一个结构体指针变量,输入型参数,若act指针非空,则根据act修改该信号的处理动作
第三个参数oldact也是一个结构体指针变量,输出型参数,若oldact指针非空,则通过oldact传出该信号原来的处理动作
返回值
调用成功返回0,失败返回-1,错误码被设置
sigaction本身是一个结构体
结构体的第一个成员变量: sa_handler
- 将sa_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
- 将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
- 将sa_handler赋值为一个函数指针,表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数
注:该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用
结构体的第二个成员变量: sa_sigaction
- sa_sigaction是实时信号的处理函数,我们不使用,这里我们只谈普通信号,直接设置为空即可或者不理会
结构体的第四个成员变量: sa_flags
第五个成员变量是 sa_restorer
- sa_flags 直接设置为0即可,sa_restorer也是不使用,设置为空即可
结构体的第三个成员变量:sa_mask
sa_mask 是信号集,
注:如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,可以把需要屏蔽的信号加入到 sa_mask中,当信号处理函数返回时,自动恢复原来的信号屏蔽字所以,在这个结构体里面,只需关心 sa_handler 和 sa_mask 即可,其他无需关心
当我们传递某个信号期间,同类信号无法被递达;因为当该信号被正在递达时,OS将当前信号假如信号屏蔽字中(block),只有当前信号完成动作,OS才会自动解除对该信号的屏蔽;
进程处理信号的原则是串行的处理同类型的信号,不允许递归
这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止
当信号递达了,pending位图对应信号相应的内容就会由1置0,如果这时又检测到了信号,pending位图对应信号相应的内容就会由0置1,当上一个递达动作完成,OS会自动解除对该信号的屏蔽,也就是说该信号被解除屏蔽后,OS会自动递达当前屏蔽的信号所以在上面的测试中发送了多次2号信号,第一次信号处理动作完成后,2号信号还会被捕捉一次并递达。如果没有检测到信号,pending位图对应信号相应的内容依旧是0,是0就不做任何动作
六,可重入函数
当一个带头单链表进行头插,主函数中调用insert函数向链表中插入结点node;
在这时,如果某信号的处理函数也调用了insert函数向链表插入结点node2
首先,当main函数调用inset函数进行向链表插入时,操作分为两步;当刚做完第一步(p->next = head)的时候,因为硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到信号处理动作(sighandler)。示意图如下;
进入信号处理动作(sighandler)后,该处理函数也调用了insert函数,向该链表head中插入结点node2,sighandler函数调用的insert函数也完成了插入节点的第一步,(p->next = head) ;
sighandler函数调用的 insert函数插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态
返回到用户态之后,从main函数调用的 insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。
结果显然,程序中mian函数和信号处理函数sighandler都调用了insert函数来向同一个全局链表中插入节点。在这种情况下,如果insert函数被不同控制流同时调用,可能会导致重入问题,进而引发内存泄漏。
像上例这样,insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
一般,main执行流,和信号捕捉执行流是两个执行流
- 如果在main中,和在handler中,该函数被重复进入,出现问题,该函数是不可重入函数
- 如果在main中,和在handler中,该函数被重复进入,没有出现问题,该函数是可重入函数
如果一个函数符合以下条件之一则是不可重入的:
- 调用了 malloc或 free,因为 malloc也是用全局链表来管理堆的
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构
七,再次理解C语言关键字volatile
volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性
为什么要有volatile关键字?
编译器的优化机制:
编译器在优化代码的时候,会大概推断出哪些变量在特定的范围内不会改变。对于这样的变量,编译器会把它们的值缓存在寄存器中,这也就不用再去内存上访问,需要的时候就在寄存器上读取。但是,如果一个变量的值是通过硬件中断、另一个线程或信号处理函数来改变的,那么编译器之前缓存在寄存器中的值就不准确了,从而导致程序出现错误的行为。
//全局变量
int flag = 0;
void handler(int signo)
{
cout<<"信号:"<<signo<<":号被捕获"<<endl;
cout<<"flag:"<<flag<<endl;
flag = 1;
cout<<"flag:"<<flag<<endl;
cout<<"flag 0 -> 1 "<<endl;
}
int main()
{
//屏蔽信号
signal(2,handler);
while(!flag)
{
cout<<"执行中..."<<endl;
sleep(1);
}
cout<<"进程结束"<<endl;
return 0;
}
上述代码中当按下 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 , while
条件不满足,退出循环,进程退出。
而如果我们编译的时候带上-O3选项,优化编译代码,即使按下Ctrl+C也不会退出循环。这是为什么呢❓
其中 -o3是最高的优化等级,上面的代码使用 -O3 进行编译
在编译器优化级别较高的时候,编译器可能会把flag直接设置进寄存器里面,这个寄存器保存的只是临时的数据。由于编译器优化,while循环检查quit的值,直接看寄存器内的值,并不会到内存里面查看 也就是说, while循环检查的flag,并不是内存中最新的flag。从 handler执行流返回main执行流时,内存中的 flag值已经更新(由0置1),但是寄存器内的值并没有更新。因为寄存器的值只是临时数据,改了没有意义,只是把flag更新到内存。 结果就出现了存在数据二异性的问题,while循环检查的是寄存器里面的值,寄存器里面的值依旧是0,没有更新,所程序依旧死循环不退出 由于寄存器的存在,遮盖了内存中的更新后数据,所以while循环眼里只有寄存器,没有内存,这是编译器优化产生的问题
虽然执行了handler,也成功修改了flag的值,但是由于编译器优化,早就将flag的值拷贝到寄存器中了,CPU只会在寄存器中读取flag的值。即使后来通过执行handler函数修改了flag的值。
C语言的关键字 volatile 的作用就是:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
八,SIGCHLD
用于通知父进程其一个子进程已经终止或停止。如果没有设置信号处理函数,SIGCHLD信号的默认行为是忽略;
父子进程最容易出现僵尸进程, 使用wait和wiatpid函数可以清理僵尸进程;
父进程可以阻塞等待子进程结束,也可以非阻塞查询是否有子进程结束等待清理(轮询方式);
这两种都麻烦;当子进程结束时,其实会向父进程发送SIGCHLD信号(17)父进程可以自定义该信号处理动作,这样父进程可以专心处理自己的工作,不需要关心子进程退出;
void handler(int signo)
{
pid_t id;
while ((id = waitpid(-1, NULL, WNOHANG)) > 0)// WNOHANG非阻塞式等待
{
printf("wait child success, pid: %d\n", id);
}
printf("child is quit! %d\n", getpid());
//处理子进程退出时触发的信号
// pid_t pid = waitpid(-1,nullptr,WNOHANG); // 非阻塞等待
//加上循环可以回收多个子进程
/* while(true)
if(pid >0) //等待成功
{
cout<<"wiat childprocess sucess!"<<endl;
}
else{
cout<<"fail!"<<endl;
}*/
}
int main()
{
//将子进程退出触发的信号自定义处理
signal(SIGCHLD,handler);
//创建子进程
pid_t id = fork();
if(id == 0)
{
int cnt = 5;
while(cnt--)
{
cout<<"i am child process!"<<endl;
sleep(1);
}
//子进程结束 退出
_exit(1);
}
//father
while(true)
{
cout<<"father process do something!"<<endl;
sleep(1);
}
return 0;
}
说明:
SIGCHLD属于普通信号,记录该信号的pending位只有一个,如果在同一时刻有多个子进程同时退出,那么在handler函数当中实际上只清理了一个子进程,因此在使用waitpid函数清理子进程时需要使用while不断进行清理
使用waitpid函数时,需要设置 WNOHANG 选项,即非阻塞式等待;否则当所有子进程都已经清理完毕时,由于while循环,会再次调用waitpid函数,此时就会在这里进行阻塞;
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction将 SIGCHLD的处理动作置为 SIG_IGN,这样 fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
系统默认的忽略动作和用户用 sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用注意:这里手动传的SIG_IGN,与系统中的默认SIG_IGN不一样,系统中的默认SIG_IGN与Term、Core的流程一样,手动传的SIG_IGN的特性是:子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程
int main()
{
signal(SIGCHLD, SIG_IGN);//显示的设置对SIGCHLD进行忽略
pid_t cid;
if ((cid = fork()) == 0)
{
// child
printf("child pid: %d\n", getpid());
sleep(3);//3秒后子进程退出
exit(1);
}
// parent
printf("parent pid: %d\n", getpid());
while (1)
{
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}