信号基础
生活中的信号
你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。
总而言之
- 信号没有产生的时候我们已经知道怎么处理这个信号了
- 信号的到来,我们并不清楚具体是什么时候,信号对于我现在正在左的工作是异步产生的。
- 信号产生了我们不一定要立即处理它,而是在合适的时候去处理
- 因为我们不一定会要立即处理它,所以我们要有对信号的保存能力
信号:信号是一种向目标进程发送通知消息的一种机制。
所以进程在收到信号之前已经知道了有哪些信号并且知道对应信号的处理方法。
在Linux中可以通过kill -l 查看所有的信号。
并且在进程能够通过自己的PCB找到一张函数指针数组,数组的下标对应的就是各个信号的编号,数组的内容就是对应信号的处理方法。这么多的信号中1 - 34 号信号为普通信号,剩下的为实时信号,我们只说普通信号。
一个信号的处理方法分为三种:
- 默认行为
- 忽略
- 自定义
我们是可以通过signal修改对于信号的执行方法。 其中9号信号为管理员信号,默认方法不能被修改。
第二个参数设置为SIG_DFL就是默认行为,设置为SIGIGN就是忽略。
假设我们现在修改二号信号的默认行为
#include <iostream>
#include <unistd.h>
#include <signal.h>
void sigcb(int signal)
{
std::cout << "get a singal :" << signal << std::endl;
exit(0);
}
int main()
{
signal(2,sigcb);
while(true)
{
std::cout << "run.." << std::endl;
sleep(1);
}
return 0;
}
信号的产生
在命令行shell中,前台命令(./xxx)只能有一个,后台命令(./xxx &)可以有多个,前台进程是不能被暂停(ctrl + z),如果被暂停,该前台进程要立即被放到后台。OS会自动的把shell自动的提到前台或者后台。ctrl + c一般情况下可以终止一个前台进程。判断是不是前台进程可以看有没有接受用户输入的能力,有就是前台进程。
LInux中可以通过jobs命令查看后台进程,fg + num 可以把一个后台进程提到前台,bg + num 可以启动一个被暂停的后台任务。
OS是怎么知道键盘有数据准备就绪了呢?
CPU其实和外设也是相连的,CPU上有很多针脚,硬件中有一个8269,作为针脚和硬件的中间设备,因为外设很多,CPU的针脚有限,所以可以通过这个设备把多的外设和CPU连接起来,然后当键盘有数据了,会通过针脚产生硬件中断,OS中会有一张中断向量表(函数指针数组),然后每个硬件都有自己的编号,CPU有一个寄存器专门存储硬件的中断号,数组的下标就是对于硬件的编号,数组的内容就是硬件的读取方法,所以CPU接收到了硬件中断,然后直接通过数组下标找到对于的方法,然后把内容加载到内存。
信号产生的方式:
-
可以通过键盘产生
ctrl + c (发送2号信号终止进程)
ctrl + z (暂停进程,发送19号信号)
ctrl + \ (终止进程,发送3号信号) -
通过系统调用
kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
abort函数使当前进程接收到信号而异常终止。 并且abort就算被signal重定义,就算最后我们没有终止进程,它自己最后也会终止进程。
-
异常
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE(8)信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV(11)信号发送给进程。
#include <iostream>
#include <signal.h>
void handler(int signo)
{
std::cout << "run.." << std::endl;
}
int main()
{
signal(8,handler);
int a = 6;
a /= 0;
return 0;
}
这段代码会出现死循环的情况 ,原因就是因为出现除0错误,然后CPU硬件报错,然后处理方法就是让OS给目标进程发信号并且把该进程剥离CPU,但是我们对8号信号进行自定义,没有退出进程,然后当CPU再一次调度这个进程时,接着出错,重复之前的动作。
- 软件条件
管道的一种特性当读端退出,写端无意义,OS就会写端发送SIGPIPE信号,SIGPIPE是一种由软件条件产生的信号。除了这个以外还有alarm函数 和SIGALRM信号。
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
总而言之信号产生的方式多种多样,但是信号发送都是由OS来发送的。
OS中的时间
- 所有的用户行为都是以进程的形式在OS中表现的。
- OS只要把进程管理号就能完成所有的用户任务。
- CMOS会周期性高频的像CPU发送时钟中断。
我们知道我们自己写的代码是由OS来调度执行的,但是OS的代码是谁来调度的呢?
CMOS向CPU发送时钟中断就是让CPU来执行OS的代码的,他会给CPU一个操作数,然后OS通过这个操作数在中断向量表中索引下标,数组的内容就是OS的调度方法,所以OS的执行是基于硬件中断的。。
所以对OS朴素的理解就是OS在电脑开机时完成各种的初始化工作后,开始进入死循环执行自己的调度方法。
信号的保存
信号的其他概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。一旦被阻塞,就不能递达,直到对该信号解除屏蔽。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号在内核中其实是很好表示的,因为我们只需要表示是否收到了某某信号,所以用位图这个数据结构就刚刚好。被阻塞也可以这样表示,都是用位图就可以表示。
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
所以当收到一个信号是,先看block是否被阻塞,如果没有阻塞就会递达,如果阻塞了,就需要等解除阻塞之后再递达。
sigset_t
OS为了我们对信号集进行操作,设置了sigset_t的数据类型,它本质就是一个位图,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
为了对信号集更好的操作,OS也为我们提供了对信号集的操作函数。
- 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
- 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。这几个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
sigpending
可以通过这个函数获取当前进程的pending表。通过set参数传出。
信号的捕捉
发送信号后,信号不会立即递达,而是在合适的时候递达,那什么才算合适的时候呢?
进程从内核态返回用户态时,进行信号的检测和处理。
用户态和内核态
用户态:只能访问自己的0 - 3GB,是一种受控的状态,能访问的资源是有限的。
内核态:可以让用户以OS的身份访问3 - 4GB,是一种OS的工作状态,可以访问大部分资源。
我们之前说的所有的地址空间都是用户空间,里面都是对我们用户自己的代码,对应的还有一张用户级页表,而内核的进程地址空间都是OS的代码数据和数据结结构,其中对应的还有一张内核级页表,因为所有的进程都有自己的进程机地址空间,虽然用户空间的使用情况可能千奇百怪,但是OS只有一个,所以他们所有的内核空间中的数据都是一样的,并且在内存中也只会存在一张内存级页表,所有的进程的内核空间的内容一样,所以都指向同一张内核级页表就可以了,我们平时调用函数实在自己的进程地址空间调用,系统调用也是代码,是OS的代码,他映射在内核级页表中,所以我们普通用户需要进行系统调用一定要发生身份的切换,因为普通用户是不允许访问内核级空间的,CPU中有一个CS寄存器,可以标识当前进程是用户态还是内核态。所以不管是系统调用还是库函数还是自己写的函数都可以在自己的进程地址空间进行跳转和返回,并且无论进程怎么切换,CPU都可以直接找到OS的代码。
在调用自己的方法时,进程是要切换回用户态的,因为如果不切换的用户就可以在自定义方法中利用内核身份做不好的事情了。
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。信号的捕捉过程中,是要进行4次的身份切换的。
sigaction
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signum是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体.
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,把sa_flags设为0就行,sa_sigaction是实时信号的处理函数。
volatile
保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
SIGCHLD
现在我们已经会创建子进程了,子进程在退出的时候什么都没说吗?
答案肯定是不是的,子进程在退出是是会给父进程发送SIGCHLD信号的。
我们会用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进
程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;
第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.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("doing some thing!\n");
sleep(1);
}
return 0;
}
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。