文章目录
- 1. 信号
- 2. 信号的产生
- 2.1 键盘产生
- 2.2 系统指令产生
- 2.3 系统调用产生
- 2.4 软件条件产生
- 2.5 异常产生信号
- 3. 信号的保存
- 3.1 信号其它概念
- 3.2 信号操作函数
- 4. 信号的处理(捕捉)
- 4.1 原理
- 4.1.1 信号处理的流程(用户态与内核态)
- 4.1.2 硬件中断
- 4.1.3 时钟中断
- 4.1.4 软中断
- 4.1.5 如何理解内核态和用户态
- 4.2 操作
- 5. 可重入函数
- 6. SIGCHLD信号
1. 信号
在生活中,我们可以见过很多信号,例如红绿灯、闹钟、铃声等等,当我们收到信号时,我们会做出相应的处理。
在计算机中,信号是一种用户、OS、其它进程,向目标进程发送异步事件的一种方式。
- 可是我们是怎么识别的信号呢? - - 我们已经认识信号了。进程认识信号,是程序员内置的特性
- 我们怎么处理已经识别的信号呢? - - 在信号产生之前,信号的处理方法已经有了。
- 什么时候处理信号呢? - -合适的时候。 但是需要记录未处理的记号
- 怎么处理信号? - - a、默认行为 b、忽略信号 c、自定义动作
进程信号: 是一种软件中断,用于通知进程某个事件已经发生,需要打断进程当前的操作去处理这个事件。每个信号都对应一个特定的事件,这样进程在收到信号后就能知道是什么事件发生了,并据此执行相应的处理动作。
所以,在学习信号时,我们主要按照下面的顺序:
2. 信号的产生
2.1 键盘产生
在我们以前所写的程序中,只要程序跑起来不停,屏幕上就一直在输出,我们就无法继续输入命令,这种进程我们叫做前台进程。
在运行程序时,我们在后面跟上 & 符号,它就变成了后台进程,我们就可以输入指令了。
但是,我们的前台进程可以使用Ctrl + C终止掉,后台进程不可以,它必须使用 kill 命令终止或者 fg+作业号将后台进程提到前台,然后再使用Ctrl + c终止掉。
nohup 可执行 & :后台进程不再输出到屏幕上,而是输出至nohup.output文件
当我们按下键盘上的Ctrl+C时,操作系统就将其解释为2号信号,然后发送给进程,终止进程。
下面列出常用的信号
查看信号的默认动作:man 7 signal
下面介绍一个系统调用signal
,该方法可指定相应信号对信号的处理方式(自定义方法),即信号的捕捉(系统中的某些信号无法被捕捉,例如9号)
此时要想终止,可以使用Ctrl + \
,其实是3号信号。
在上面,对于signal方法,我们只做了关于自定义方法的捕捉,那如何忽略和使用默认方法呢?
对2号信号进行忽略与默认signal(2,SIG_IGN); //忽略,本身就是一种信号捕捉的方法,动作是忽略signal(2,SIG_DFL); //默认
那键盘是如何终止进程的呢?
- 本质上是操作系统终止进程,因为OS是软硬件资源的管理者。当键盘上按下某些键时,操作系统能够识别到,然后转换为相应的信号,然后再让进程执行相应的信号处理方法。
- 但有时进程无法立即处理信号,那就需要将信号保存。由于我们的普通信号只有31个,所以进程的PCB中有一个位图即可,0表示无,1表示有信号。
- 那么发送信号的本质就是:写入信号,即OS修改目标进程PCB中的信号位图,0 -> 1。
- 因为OS是所有task_struct的管理者,所以
信号发送的任务只能由操作系统能够完成
。
可是OS怎么知道键盘上有数据了呢?
- 这里肯定不能再让OS去轮询检测了,否则它就太忙了,所以在硬件上一般要有硬件中断。
- 根据冯诺尼曼体系结构,硬件不能和CPU直接打交道,但是一般输入设备好了,首先会给CPU发送一个硬件中断信号,然后CPU会告诉操作系统有外设准备好了,OS才会把外设上的数据拷贝到内存中,在此之前,OS只需要静静的等。
- 如果OS需要硬件做某些事情的时候,它只需告诉外设自己要什么,然后OS就忙自己的,外设忙外设的,当外设准备好了,就给CPU发中断信号,然后OS在从外设上拷贝数据到内存中,此时硬件与OS就可以并行执行了。
既然OS可以通过中断的方式管理硬件,那能不能也用这种思想来管理进程呢? - -于是就有了信号
(信号是纯软件的,模拟了中断的行为)
2.2 系统指令产生
kill + -signal number + pid
2.3 系统调用产生
kill
:向指定进程发送指定信号
既然kill也是一个系统调用,那么我们就可以实现一个自己的kill命令
raise
:谁调用,就给调用者发送指定信号
abort
:给调用者发6号信号
2.4 软件条件产生
之前我们在学习管道的时候,如果管道读端关闭,写端再写就会被OS杀掉,通过13号SIGPIPE信号。
管道是文件,文件就是软件,管道文件写入条件不具备,其实就是软件条件不具备。SIGPIPE就是一种由软件条件产生的信号。
下面介绍一种软件条件的系统调用
alram
:second秒后,调用者收到14 sigalrm
信号。‘
返回值:返回上一个闹钟的剩余时间,如果上一个闹钟已经响了,则剩余0。
闹钟在底层是操作系统先描述再组织的软件数据结构,当软件条件就绪时(例如闹钟超时),OS就可向目标进程发送信号。所以闹钟本身是属于软件条件满足或不满足而触发的向OS发信号的条件, 这种策略叫做软件条件。
2.5 异常产生信号
在以前我们写程序时,每当程序野指针或除0时程序会崩溃,为什么呢?
在Linux中,一旦野指针,则会触发段错误,OS发送了11 SIGSEBV
信号
可是操作系统怎么知道我们的程序出错了?而且程序为什么会一直死循环的执行handle方法呢?
对于除0操作:
对于野指针操作:
CPU中,还存在一个MMU寄存器。
MMU寄存器存储了从虚拟地址到物理地址的映射信息,这主要是通过页表来实现的。当CPU访问一个虚拟地址时,MMU会查找页表,将虚拟地址转换为对应的物理地址,然后访问物理内存。如果无法完成虚拟到物理的转换,MMU就会报错了。
当MMU报错后,OS也会知道,接下来的操作就跟除0操作类似了
为什么有的信号行为是core,有的是term呢?
term:正常退出,无需debug。
core:核心转储。当程序崩溃时,将进程在内存中的部分信息保存起来,形成一个core的文件,方便以后调试。
但是在云服务器上,该功能一般都是关闭的,可使用ulimit -a
查看
使用ulimt -c + 大小
可启用。
开启该功能,就可以事后调试了,使用gdb调试可执行程序,然后在gdb中输入core-file + core文件名,即可定位到出错位置。
我们上面谈的都是自己的进程,那如果是子进程出错了呢?
在进程等待那里,status参数可获得子进程的退出码和退出信号,当时由一个core dump标志没有说,这里的core dump 就是我们上面讲的核心转储。
子进程会不会出现core,取决于:退出信号是否是core && core功能是否开启。
3. 信号的保存
3.1 信号其它概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号(屏蔽)。
被阻塞的信号将保持在未决状态(保存)
,直到进程解除对此信号的阻塞,才执行递达的动作。 - 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
那上述概念在OS中是如何实现的呢?
每个进程在PCB中都维护了三张表:block、pending、handler。
该三张表就可完成信号的识别、保存和处理工作;handler表中的初始内容,就是程序员内置的特性(信号处理方式)。
因此,关于信号的所有操作,都是围绕这三张表开展的,要横着看这三张表。
3.2 信号操作函数
- 操作sigset_t 类型的数据
- 注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号了。
- 这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
sigset_t类型的定义如下:
未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),
- 设置进程的block表
sigprocmask
:读取或更改进程的信号屏蔽字(阻塞信号集)。
对于参数how,有以下三个选项:
- 如果oldset是非空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。
- 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
- 如果oldset和set都是非空指针,则先将原来的信号屏蔽字备份到oldset里,然后根据set和how参数更改信号屏蔽字。
- 获取当前进程的pending信号集
sigpending
:读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
下面我们做一个简单的例子来验证上面的函数:
- 先将2号信号屏蔽
- 打印pending信号集
- 发送2号信号,但信号不递达
你是否有这样的疑问: 为什么只给我提供了操作block信号集的方法,没有提供pending与handler表的方法呢?
在信号的产生那里,操作的全是pending表;signal(signum,handler)函数操作的handler表。
4. 信号的处理(捕捉)
4.1 原理
在之前我们谈过,在信号产生后可能不是立即被处理的,它首先会被写入到pending表中,然后等待一个合适的时机再处理。
这个合适的时机是什么时候呢?
4.1.1 信号处理的流程(用户态与内核态)
进程在从内核态切换到用户态时,检查pending表与block表,决定是否执行handler方法处理对应的信号。
简化一下就是下图:
那到底什么叫做内核态与用户态呢? - - 要想搞清楚,我们需要先了解一下操作系统是怎么运行的。
4.1.2 硬件中断
由外部设备触发的,中断系统运行流程,叫做硬件中断
。
其实如果外部设备要被操作系统访问到,不能让操作系统主动定期去轮询外设的状态。所以一般外设数据准备好时,会发起中断。由于外设很多,所以外设物理上并没有直连CPU,而是连在了中断控制器上,然后中断控制器再与CPU直连,从而完成中断的发送。
操作系统为了能够处理每一个设备,在编码的时候,就提供了一个表结构:中断向量表。中断向量表是一个函数指针数组,数组的下标是中断号,元素是指向处理该中断的函数。
CPU有特定的针脚,接收中断信息,当中断信号到达CPU的特定引脚时,CPU会暂停当前正在执行的任务(保护现场)
,并根据中断号(即记录发送中断信号的针脚编号的寄存器中的值)调用中断向量表中的相应函数来处理外设请求。
- 中断向量表是操作系统的一部分,启动就加载到内存中了
- 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或轮询了。
4.1.3 时钟中断
- 如果外部设别没有触发中断呢?有没有自己定期触发的设备?
- 进程可以在操作系统的指挥下,被调度、执行,那操作系统被谁指挥、被谁推动执行呢?
操作系统,在硬件上,有一个时钟源(一个可以定期触发时钟中断的东西)。这个时钟源间隔很短的时间,固定周期一直给CPU发送中断。
那如果给时钟源固定一个中断号n,中断向量表n中设置为进程调度的中断服务,那操作系统不就可以一直被时钟中断推着就行调度了吗?
所以,操作系统就是基于中断向量表进行工作的。
当代CPU确实已经将时钟源集成到其内部,这个时钟源通常被称为主频,主频越快,CPU越快。
如果是这样,操作系统自己就可以不做任何事情,需要什么功能,就像中断向量表中添加方法即可,所以操作系统的本质:就是一个死循环。
我们之前说进程是基于时间片调度的,那什么是时间片呢?
在前面的学习中,我们知道一旦有硬件中断到来了,就会基于中断向量表进行调度,但是调度不等于切换。
我们之前一直说时间片到了进程就怎么怎么样,可是OS怎么知道时间片到了呢?
因为时钟中断的触发是有固定时间间隔的,那能不能给进程task_struct设置一个时间片(计数器)。
当时钟中断到来时(调度),对进程的时间片减一
。如果时间片没有减到0,那就什么也不做;如果时间片减到0了,则基于调度算法进行进程切换
。
内核源代码
4.1.4 软中断
上面所说的硬件、时钟中断,都是需要硬件设备触发的;那在软件层面上,能不能触发上面的逻辑呢?- - 能。
下面以系统调用为例:
为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int 0x80 或者 syscall),可以让CPU内部触发中断逻辑。
这种通过汇编指令让CPU触发中断逻辑的过程,我们叫做软中断
。
可是有下面几个问题:
- ⽤⼾层怎么把系统调⽤号给操作系统? - 寄存器(⽐如EAX)
- 操作系统怎么把返回值给⽤⼾?- 寄存器或者⽤⼾传⼊的缓冲区地址
所以:系统调用也是通过中断完成的,执行流程如下:
- 当程序发起系统调用时,它会执行一个特殊的指令(如INT指令)来触发软中断。
- CPU在接收到软中断信号后,会暂停当前正在执行的任务,并保存其状态。
- 然后,CPU会根据中断向量(在软中断的情况下,这个向量通常与系统调用号相关联)从中断向量表中
查找中断处理程序的入口地址
。- 在找到中断处理程序后,CPU会执行该程序,该程序会根据提供的系统调用号在系统调用表中查找对应的系统调用函数,并执行该函数以完成系统调用。
缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进⾏申请内存,填充页表,进⾏映射的。有的是用来处理内存碎⽚的,有的是用来给⽬标进⾏发送信号,杀掉进程等等。
所以,操作系统就是躺在中断处理例程上的代码块!
CPU内部的软中断,⽐如int 0x80或者syscall,我们叫做陷阱
CPU内部的软中断,比如除零/野指针等,我们叫做异常
4.1.5 如何理解内核态和用户态
用户级页表,每个用户都有一份;内核级页表,整个系统中只有一份。所以,对于任何一个进程,无论如何调度,都能找到同一个操作系统,即操作系统系统调用的执行,是在自己的地址空间中执行的。
- 用户态就是执行用户[0,3]GB时所处的状态,访问自己写的代码
- 内核态就是执行内核[3,4]GB时所处的状态,访问操作系统的代码
- 区分就是按照CPU内的CPL决定,CPL的全称是Current Privilege Level,即当前特权级别。
Linux系统下,CPU中标志字段CPL标志着线程的运行状态,用户态为3,内核态为0。
- CPU中标志字段CPL为0:被叫做内核态,完全在操作系统内核中[3,4]GB运行
- 执行内核空间的代码,具有ring 0保护级别,有对硬件的所有操作权限,可以执⾏所有 CPU指令集 ,访问任意地址的内存,在内核模式下的任何异常都是灾难性的,将会导致整台机器停机。
- CPU中标志字段CPL为3:被叫做用户态,在应用程序中[0,3]GB运行
- 在用户模式下,具有ring 3保护级别,代码没有对硬件的直接控制权限,也不能直接访问地址的内存,程序是通过调用系统接口(System Call APIs)来达到访问硬件和内存,在这种保护模式下,即时程序发⽣崩溃也是可以恢复的,在电脑上⼤部分程序都是在用户模式下运行的。
什么情况会导致用户态到内核态切换??
- 系统调用 :用户态进程主动切换到内核态的⽅式,用户态进程通过系统调⽤向操作系统申请资源完成⼯作,例如 fork()就是⼀个创建新进程的系统调⽤。
- 操作系统提供了中断指令int 0x80来主动进⼊内核,这是用户程序发起的调⽤访问内核代码的唯⼀⽅式。调⽤系统函数时会通过内联汇编代码插⼊int 0x80的中断指令,内核接收到int 0x80中断后,查询中断处理函数地址,随后进⼊系统调⽤。
- 异常 :当 CPU在执⾏⽤⼾态的进程时,发⽣了⼀些没有预知的异常,这时当前运⾏进程会切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺⻚异常。
- 中断 :当 CPU 在执⾏⽤⼾态的进程时,外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执⾏下⼀条即将要执⾏的指令,转到与中断信号对应的处理程序去执⾏,也就是切换到了内核态。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执⾏后边的操作等。
4.2 操作
信号捕捉的操作:
- signal,前面说过了
- sigaction
思考一个问题: 我们记录信号使用的是pending位图,信号只能记录一次。如果我提供的信号捕捉方法,它是一个死循环/陷入内核的操作。但如果在信号还没有被递达之前,我再次收到该信号,那是不是会再次执行信号捕捉方法呢?
是的,如果收到很多该信号的话,那捕捉方法则一直会被调用,最终导致栈溢出。
- 为了避免这种问题,操作系统不允许信号处理方法进行嵌套,当一个信号正在被处理时,OS会把信号对应的block位置为1,信号处理完成,会自动解除。
- 即当正在处理某个信号时,该信号就不可被递达了。
void PrintBlock()
{sigset_t set,oset;sigemptyset(&set);sigemptyset(&oset);sigprocmask(SIG_BLOCK,&set,&oset);std::cout << "cur block list:";for (int i = 31; i > 0; i--){if (sigismember(&oset, i) == 1)std::cout << 1;elsestd::cout << 0;}std::cout << std::endl;
}void handler(int signo)
{int count = 6;while (count){PrintBlock();sleep(1);count--;}
}int main()
{struct sigaction act, oldact;act.sa_handler = handler;::sigaction(2, &act, &oldact); // 设置捕捉方法while (true){PrintBlock();pause();}return 0;
}
在sigaction中,它有一个sa_maks的属性,该属性可设置在捕捉指定信号后,需要一块屏蔽的信号。指定信号处理完成后,屏蔽的信号都会恢复。
在信号的pending为1时,那什么时候将pending置为0呢?执行处理方法前还是执行后呢?
执行前。 若为执行后,则无法分辨是新到来的信号还是旧信号了。
5. 可重入函数
- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核。
- 再回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2。
- 插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行
- 先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了, node2就找不到了,造成了内存泄漏。
像上例这样,一个函数被多个执行流同时进入了,这称为该函数被重入了(重复进入)。
- 当函数被重入后出现了问题,则该函数是不可重入函数;
- 如果函数被重入后没出问题,则该函数是可重入函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
6. SIGCHLD信号
在之前用过的wait和waitpid函数清理僵尸进程,父进程可以阻塞等待⼦进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。
采用第⼀种方式,父进程阻塞了就不能处理自己的⼯作了;采用第⼆种方式,父进程在处理自己的⼯作的同时还要记得时不时地轮询⼀ 下,程序实现复杂。
其实,子进程在终⽌时会给⽗进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的⼯作,不必关心子进程了,子进程终⽌时会通知⽗进程,父进程在信号处理函数中调用wait清理⼦进程即可。
验证:父进程会收到SIGCHLD信号
既然这样,父进程是不是不需要专门在主流程中写代码去等待子进程了,只需要在收到SIGCHLD信号后,再回收子进程即可了呢。
void handler(int signo)
{std::cout << "get a signo:" << signo <<" i am:" << getpid() << std::endl;pid_t rid = waitpid(-1,nullptr,0);if(rid > 0)std::cout << "回收子进程成功" << std::endl;elsestd::cout << "回收失败子进程" << std::endl;
}int main()
{signal(SIGCHLD,handler);pid_t id = fork();if(id == 0){sleep(5);exit(0);}while(true){sleep(1);}return 0;
}
我们知道进程的pending表是位图,每次只能记录一个信号,最多能同时“处理”两个信号(一个pending,一个handler),如果现在父进程创建了n个子进程,那还能不能回收成功呢?
所以,在handler回收信号时,我们需要循环并且非阻塞的去回收,当waitpid的返回值为0时,表示没有退出的进程,即退出的进程已全部回收完毕。返回值为-1时,则表示没有进程需要被回收,出错。
#include <iostream>#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <wait.h>void handler(int signo)
{while (true){pid_t rid = waitpid(-1, nullptr, WNOHANG);if (rid > 0)std::cout << "回收子进程成功,pid:" << rid << std::endl;else if (rid == 0){std::cout << "退出子进程都已回收完毕" << std::endl;break;}else // rid < 0{std::cout << "wait error" << std::endl;break;}}
}int main()
{signal(SIGCHLD, handler);for (int i = 0; i < 10; i++){pid_t id = fork();if (id == 0){//一部分先退if (i % 2 == 0){sleep(2);std::cout << "子进程退出" << std::endl;exit(0);}//一部分后退else{sleep(4);exit(0);}}}while (true){sleep(1);}return 0;
}
事实上,由于UNIX 的历史原因,要想不产⽣僵⼫进程还有另外⼀种办法:父进程调用signal将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产⽣僵⼫进程,也不会通知⽗进程。
所以,到目前为止,已经知道了四种处理子进程僵尸状态的方式:
- 阻塞等待
- 非阻塞等待
- SIGCHIL循环回收
- 直接忽略