1.信号的发送与保存
下面来谈信号的发送与保存,下面说说什么是信号的发送:kill -l看到的普通信号编号是1~31,这个数字很像下标。对于普通信号而言,对于进程而言,我们进程要自己知道有还是没有收到哪一个信号。这个信号是给进程发的,实际上是给进程的pcb发。所以task_struct里只需要维护一个整数,把这个整数当成二进制序列,发一个信号就将对应比特位由0至1:

因此进程要收到信号就要保存,要保存就得管理,因为信号很多。对于进程本身而言也要先描述再组织,只不过进程描述一个信号用一个比特位来描述,用比特位位置表示哪一个信号(用0 1描述信号,位图管理信号)。下面输出一些结论:1.比特位的内容是0还是1,表明是否收到。2.比特位的位置(第几个),表示信号的编号。3.所谓的发信号只是一种表达,本质是写信号,就是操作系统去修改目标进程task_struct的信号位图对应的比特位。我们操作系统要给一个进程发信号,只需找到进程pcb,把里面的字段由0至1,信号发送就完成。为什么必须只能是操作系统向pcb写信号,不能通过键盘等设备?技术上谁写都行,但不能这样做,因为操作系统是进程的管理者,只有它有资格修改task_struct内部的属性。像信号中大部分是退了进程,操作系统为啥不直接干了,还要给进程发个信号,然后剩下的交给进程自己做?技术上肯定可以,但不能,因为上层出异常万一有收尾工作,但操作系统直接干了有影响,这样操作系统就要背锅了。所以操作系统在我们出问题时会通知我们,并给我们一些行为,我们不管它就杀了,我们管它就不杀遵守我们的行为。
下面来谈信号的保存:为什么要对信号做保存?我们说过一个进程可能正在做更重要的事情,这个重要的事情操作系统不知道是什么。当一个进程收到信号时可能不会立即处理这个信号,可能此时在忙自己事情(如做IO等)。一个信号已经产生了,我们还没处理就必须把信号暂时保存起来,要保存就要用数据对象来保存(信号不会被处理,就要有一个时间窗口)。
2.信号其他相关常见概念
下面来认识一些概念:信号是要被处理的,信号的处理方式默认有三种:忽略、默认、自定义。不管是哪种处理方式,我们把实际执行信号的处理动作称为信号递达。信号从产生到信号递达之前,信号在位图中处于简单的保存状态,这种信号没被处理只是简单放在进程pcb中,这种称为信号未决(pending)。其中递达和未决在操作系统中要对应上一些相关结构,未决状态就是pcb携带对应位图结构。要支持信号递达,操作系统内还有个方法叫handler表:

信号本质是1~31的数字,每一种信号都要有自己的一种处理方法,所以在进程内核中还要为每一个信号维护一个handler表,这个handler表的类型是一个函数指针:

也就是一个函数指针数组。这些指针指向一个个对应的方法,这个方法大部分是由操作系统自己提供的,我们可称为默认方法。如果用户自己设定了一个方法,就把该方法对应的地址写到表里面:

用户提供的这个方法在进行特定系统调用设置时,直接可修改数组中特定下标的内容,这就是signal()。它的第一个参数信号可充当数组下标找到某一个位置,第二个参数传入方法地址可把该地址填入到数组中让我们访问。所以进程中要至少存在两张表,一张表叫pending表,已经发生但还没来得及处理的信号我们称信号处于未决状态(pending状态),它是一张位图。还有个函数指针数组handler表,它里面放的指针,要么是SIGDFL、SIGIGN、用户空间中的handler方法,信号产生后根据位图执行对应的handle表中的方法。操作系统发信号其实是修改pending位图中对应的特定位,当调signal对信号进行自定义捕捉就是修改handler表。进程可以选择阻塞某个信号:目前我们进程收到了信号都会去处理,要排队就先去位图中等待,只不过是先后问题。但进程也允许屏蔽掉某些信号,一旦屏蔽了某些信号在该信号没有被解除之前,即使收到了该信号也不会被操作系统递达。一个信号没有产生我们可以屏蔽(阻塞)它吗?可以,屏蔽是一种状态,和当前是否产生没有关系。那么系统是如何做到屏蔽的呢?我们引入第三张表叫block表,它也是一张位图。它和pending表相同点是都是位图,比特位位置决定信号编号,比特位内容为0表示屏蔽,为1表示不屏蔽。也就是我们理解普通信号只需要了解三张表就行:pending表用来记录当前进程是否收到信号及收到了哪些信号;block表用来记录特定信号是否被屏蔽;handler表描述每种信号处理方法。有了上述,可用来实现对普通信号记录、保存相关的管理工作了。继续看:

我们衡量一个信号如何被处理这三张表应该怎么看呢?放一起横着看,比如1号信号没有被pending(没收到),没有被block(阻塞屏蔽),处理1号信号方法是SIG_DFL。我们在内核中对信号的管理采用2个位图+1个函数指针数组,所以信号学习中无论有多少接口,最终所有接口都是围绕这三张表展开的。继续来说:一个信号被阻塞了,用户通过五种方式的任意一种向特定进程发送了指定的信号,该信号不会被递达,直到解除了对该信号的阻塞。也就是说,如果我把信号阻塞了,还是可以给这个进程发送信号的,只不过信号暂时被屏蔽罢了(别人发信号也会pending,只不过这个pending位图不会被递达罢了)。阻塞指信号不会被递达即信号不会被处理,忽略本质是处理信号。下面看一下handler表中的SIG_DFL和SIG_IGN:handler表中会设置特定信号的处理动作:

ctrl+c时发现什么反应都没有。下面转一下定义:

所以SIG_IGN是把数字1强转为函数指针类型。继续看:

使用默认处理方法,此时ctrl+c直接就终止了。DFL和IGN是信号处理三种方式中的两种,就是把0和1强转往对应数组里填,若收到信号没被屏蔽,直接去执行对应的方法。
3.sigset_t
下面说一下sigset_t,我们前面说的三张表都是操作系统里面的内核数据结构,操作系统不相信任何用户,不允许用户直接去修改那三张表,所以未来访问这三张表时必须得有系统调用接口。我们要获取pending表、block表它都是位图,就注定要在用户和内核空间进行来回的数据拷贝,所以要在接口参数设计上设置输入输出型参数。也就是用户想拿到pending表、block表就要求操作系统在应用层给我们设计一种数据类型,可以不设计,但没扩展性,所以它在头文件分装了sigset_t的数据类型,sigset_t类型是位图结构。如果我们将来拿到进程pending表、block表、以及sigset_t这样的位图类型,也决不能自己进行所谓的位操作,这样代码不具备任何可移植性。所以操作系统给我们提供了一种类型叫信号集叫叫sigset_t,表示一堆信号。它对于每种信号用一个比特表示有或无的概念,如果sigset_t类型获取pending表代表有无收到信号,作用于block表代表有无阻塞信号。它是操作系统提供的可在用户层直接使用的数据类型,我们修改sigset_t要使用这批接口:

sigemptyset传的是信号集所对应的地址,作用是清空信号集,把位图全部清零。sigfillset表示设置位图,把整个位图置1。sigaddset表示向指定信号集中添加特定信号。sigdelset是在特定信号集中去除一个信号。sigismember是判断一个信号是否在信号集中。总之信号集是系统给我们提供的一种数据类型,方便未来我们对进程的pending表和block表做操作。下面快速认识一个接口,sigprocmask:信号集将来用sigset_t表示,未来若在应用层想表示pending,就可定义sigset_t称为pending信号集,用pending信号集来对pending表做操作。也可以这样表示block信号集,很多书中不喜欢叫block信号集,给它取名叫信号屏蔽字。sigprocmask可以读取或更改进程的信号屏蔽字:

它可通过传递三个参数达到对信号屏蔽字的相关操作。比如设置,比如获取,到底是什么呢?所以how一共会设置三个选项,只能三选一。SIG_BLOCK相当于把原始信号的block位图按位或写上传入的set,或运算本质是在原来基础上做新增。SIG_UNBLOCK选项是把传入的set信号集按位取反,再按位与上原来的信号,相当于去掉进程block表中传入set集合的信号(解除屏蔽)。SIG_SETMASK相当于把传入的set集直接设置进进程pcb的block表里。第二个参数set是一个输入型参数,将来可对这个位图结构提前做设置,然后设置好后传进去,将来哪个进程调它就把set按照how方式对进程block表新增、移除、覆盖。第三个参数oset是一个输出型参数,当对进程block表做修改,改之前会把老的block表由oset保存起来。下面再看一下sigpending函数,man sigpending:

这个参数是一个输出型参数,作用是把调用进程对应的pending表带出来方便我们未来做检查,返回值0成功,-1失败错误码被设置。
继续看:

内核中有个block、pending、handler表,匹配的方法是sigpromask、sigpending、signal。我们用到了一个sigset_t类型,它和上面几个接口及操作系统内的三张表有什么关系呢?下面先代码后理论:我们先这样,提前把2号信号屏蔽了,一直打印pending表,这样发2号信号时2号信号不会被递达我们可以看到:

此时还没有屏蔽2号信号,仅是在用户层定义了变量,把它清0后并添加了字段,还没到进程的task_struct。以上属于数据预备,我们要调用系统调用调用完成:

(保存老的以便恢复)此时把bset设置进了进程pcb中。继续看(不知道哪个信号在就穷举一下):

下面运行一下:

说明2号信号被屏蔽了,打印效果符合预期。改一下方便看:

2号信号被block了不会递达。那如果我后面解除屏蔽了,我应该观察到2号信号被处理,pending位由1回归到0,来验证一下(把老的信号屏蔽字设回去):

看到解除了二2信号,没看到由1变0的效果,因为2号信号的默认执行动作是终止进程。因此这样处理:

此时看到了变化,2号信号解除屏蔽后被递达,pending位图由1至0(解除后不断ctrl+c执行的是自定义动作)。那此时有个想法,我可以将所有信号都进行屏蔽,信号不就不会被处理了吗?不行,肯定有一些信号是不可被屏蔽的,下面测试:

这样测试下去看到9和19号不可被屏蔽。下面整体说一下:这有对应的操作系统,上层有用户,我们定义了sigset_t set,它是用户层定义的位图。进程在操作系统内有pcb等以及那三张表,可通过sigprocmask、sigpending、signal这样的系统调用接口对三张表设置或获取:

4.信号捕捉处理
下面来谈信号捕捉处理,我们一直在说当一个进程收到一个信号的时候,可能并不会立即处理这个信号,而是在合适的时候处理信号。先说信号如何被处理的,什么时候被处理的:

要处理一个信号前提是要知道收到了信号,要知道收到了信号进程就得在合适的时候查一查自己对应的pending位图、block位图、handler表,它们都属于内核数据结构其他人无法查阅,信号发了后整个信号处理是由进程自己完成的,说明进程一定要处在一种内核状态才能对信号做处理。因此当进程从内核态返回到用户态的时候,进行信号的检测和处理(也就是当进入操作系统执行代码时,操作系统工作完在返回的时候对信号做检测和处理)。下面简单说一下:我们面前的进程在执行的时候,cpu调度我们进程执行代码时不只是执行我们写的代码,我们的代码里会有系统调用还有库函数调用。说明cpu执行我们代码时不仅跑我们写的代码,也在跑库和操作系统曾经写的代码。我们操作系统不相信任何用户,所以很多场景下需要用户做一下身份切换,才允许执行对应的代码。一般在执行我们写的代码和库写的代码都是在用户态直接执行的,我们进程在操作系统、cpu调度下要陷入到操作系统内部来执行对应的任务,比如在我们调用系统调用的时候,不是说调系统调用就陷入进去,首先要有资格陷入到系统调用,系统会在这时把用户身份变成内核身份,然后操作系统把函数执行完,返回时再变回用户身份。总之,当我们调系统调用时操作系统是会自动做身份切换的,这个切换是从用户身份变成内核身份,或者反着来。cpu可以响应来自外部的中断让操作系统执行(如硬件中断),自己也可在自己内部直接产生中断,我们把这种叫int 80。这是一条汇编,也是cpu能认识的指令,一旦int 80就从用户态陷入内核态,这样就有权利访问操作系统代码和数据。
下面谈一下对内核态和用户态的理解,我们重点谈一下地址空间。我们谈进程时我们知道进程要有自己的task_struct,它们指向当前进程所对应的地址空间mm_struct,还有物理内存和页表,做虚拟到物理的转换:

我们把0~3G叫做用户地址空间,把3~4G叫做内核地址空间。目前我们学的大部分内容都是在0~3G之间,都只是地址空间里的一部分。上面1G的空间映射的是操作系统的代码和数据,操作系统是被计算机最先加载的软件,所以一般加载到物理内存靠底侧的位置:

内核空间向物理内存建立映射时需要页表,这个页表称为内核级页表:

所以最终我们也能找到操作系统的代码和数据。用户有自己的用户级页表映射到自己的代码和数据,内核也有内核级页表映射到操作系统的代码和数据。那操作系统有很多进程的时候(比如50个),用户级页表有几份?内核级页表有几份?有几个进程就有几份用户级页表,因为进程具有独立性,内核级页表只有一份,因为每个进程看到的3~4G的东西都是一样的。意味着整个系统中,进程再怎么切换,3~4G的空间的内容是不变的。继续看:

系统调用接口都在这里面,每个进程在正文代码调系统方法时相当于在自己的地址空间中调用该方法,调完后返回到地址空间,如同在自己的地址空间里直接调用。站在进程视角:我们调用系统中的方法,就是在我自己的地址空间中进行执行的。站在操作系统视角:操作系统在任何一个时刻,都有进程执行,我们想执行操作系统的代码,就可以随时执行(随便某个进程都能找到)。
下面谈一下操作系统本质:(每个进程都可找到操作系统)基于时钟中断的一个死循环。计算机硬件中,有一个时钟芯片,每隔很短的时间向计算机发送时钟中断,然后操作系统会执行中断对应的方法。看图:

操作系统启动时把该做的工作完成,往后执行一个死循环一直检查有没有时钟到来,一旦有cpu会执行对应的方法(操作系统被动的由硬件时钟推进运行,操作系统才推着进程再走,所以代码得以推进)。我们调操作系统代码可认为在自己地址空间调,调完再返回。我们以前从正文代码调到动静态库依旧在用户空间。没什么权限问题。但想调操作系统的系统调用,用户不能直接执行操作系统代码。下面再说个概念:一个正在调度的进程有对应的用户级页表,cpu中有cr3寄存器,它直接指向当前进程所对应的用户级页表:

一个进程页表地址是能找到的。cpu中还有个ecs寄存器,最低2位可表示2种权限位,选00表示内核态了,11表示用户态。如果想访问内核态代码,必须想办法把低2位由11变成00,这叫进入内核态,就允许访问操作系统的数据了。cpu提供了int 80来改低2位,这个汇编执行时把寄存器低2位由11变成00:

怎么知道当前只能访问用户代码不能访问操作系统代码?检测ecs低2位。因此内核态允许你访问操作系统的代码和数据,用户态只能访问自己代码和数据。
下面理解一下:

我们执行main函数时要系统调用了进入内核,做完任务准备返回时做检测:遍历pending表,没信号过,有信号看看有没有block。有block这个信号没处理继续下一个,没有block直接执行它的方法:DFL把进程终止、IGN则返回继续往后运行。若是自定义则跳到自定义方法,此时身份变为用户态来执行(操作系统不相信用户,不想执行用户方法,害怕万一用操作系统身份在自定义方法中做一些非法操作)。执行完后用户态用sigreturn继续回到内核,再由操作系统通过sys_sigreturn返回用户态。快速记忆该过程:

信号捕捉的流程,4个焦点证明该过程有4次状态切换。
5.具体捕捉动作
通过上述了解了捕捉信号的过程,下面来谈一下具体捕捉动作:捕捉信号我们已经说过signal了,下面来介绍一下sigaction函数:

它对应的是捕捉特定信号的功能。第一个参数是信号的编号,第二个参数和第三个参数类型是一样的。这有个细节:参数这的sigaction是个结构体,也就是调这个函数要传结构体,还发现结构体类型和函数名是一样的。未来要修改一个信号对应的处理方法,所以第二个参数act是个输入型参数,负责把用户设置的一些自定义捕捉方法通过act接口传递给操作系统。oldact是个输出型参数,在改之前把老的对特定信号处理是什么样子的就会给我们返回(保存是为了恢复,不想保存这设为空就行)。下面看看sigaction结构体:

这个函数接口既能处理普通信号,又能处理实时信号,我们只处理普通信号,所以结构内很多字段不用关心:

sa_handler是将来捕捉信号所对应的处理方法,如果我想捕捉一个信号,快速把sigaction用起来,只需设置一下sa_handler就行。下面来看看:

下面看些问题:1.字段中的sa_mask是什么呢?2.捕捉信号时我怎么知道这个信号在什么时候在pending位图中由1变0,是处理完信号改的还是处理前改的?我们可以试着在handler方法里获取一下当前进程的pending位图,如果获取时2号位图是0,说明在handler处理前变的。若获取到的是1,说明在handler处理完变的。下面来测试:

说明我们正在进行信号处理的时候,我们已经进入到了信号的捕捉代码里,此时先把pending位图由1变0,然后才调用handler方法。sa_handler那可以给自定义方法,也可给SIG_IGN和SIG_DFL。
继续补充:当我们正在处理一个信号时,操作系统会将当前信号pending位图由1清0,同时递达信号之前,也要把信号对应的block表中的比特位置1。当处理完信号返回时,信号处理函数会自动把该信号的屏蔽解除。也就是信号在被捕捉期间,该信号是不可再被抵达的。这样保证在处理某个信号时如果这种信号再次产生,那么它被阻塞到当前处理结束为止(若没有上述支持,handler里又收到信号去执行handle,这样会不断重复调handler)。下面证明一下处理2号信号时再发2号信号不能被递达:

看到2号信号被屏蔽了,有个从0到1的过程。下面看结构中sa_mask是干什么的?它的类型是sigset_t。我们已经知道正在处理2号信号时,2号信号会自动被屏蔽。那如果我还想屏蔽更多信号呢?那把处理2号信号期间想屏蔽的信号添加进sa_mask里面:

当信号处理函数返回时自动恢复原来的信号屏蔽字(处理2号信号期间,就算来10个2号信号,也只记录一次)。
6.可重入函数
下面谈一下可重入函数:如果我今天定义了一张全局链表,此时是这样:

现在头插一个节点NodeA,原则上把NodeA的next指向node1,让head指向NodeA,此时头插完成:

继续看:

头插时p->next=head,head=p这都没问题。但main中调insert时刚把p->next=head这个动作执行完(node1指向结点),此时来了一个信号,这样p->next=head执行完就去执行信号捕捉了。信号捕捉里要执行头插node2,然后执行了p->next=head,head=p。同一个insert方法第一次是main函数执行流在执行insert,后面是handler函数在执行insert。一个insert方法在main函数执行流还没结束时又被重复进入了,我们把这种现象叫函数被重复进入了,简称函数被重入了。node2插入完后信号捕捉完成,然后回到曾经被中断的的地方继续向后运行执行head=p,这样此时node2节点没有指针指向它了,此时node2节点丢失,会导致内存泄露。通过上述我们看到1.insert函数被main和handler执行流重复进入。2.这种情况下会出现节点丢失,导致内存泄露。我们把一个函数再被重复进入的情况下如果出问题,把这样的函数称为不可重入函数,否则叫做可重入函数(目前我们学到的大部分函数都是不可重入的)。
7.volatile
下面基于信号理解c++里的关键字volatile(保持内存可见性):

当我们正在跑不发2号信号时,handler方法不会被调用,flag不会被设置。发了2号信号调handler方法,flag被置1,则会退出循环向后执行。是否这样呢:

一切符合预期。可是在极端情况下,编译器编译代码时发现handler和main是属于不同的执行流的,编译器编译时发现main函数中没有任何地方修改flag,有可能编译器编译时会对flag做优化。因为这只在检测flag,检测的本质也是计算,只要是计算(逻辑判断)是在cpu中判断的,所以在优化条件下flag变量可能被直接优化到cpu内的寄存器中。这样让cpu不用访存,直接从寄存器上读取。cpu会进行算术计算和逻辑计算,逻辑计算进行它时注定要先把flag读到cpu寄存器中计算。main中flag没被修改,还在进行逻辑计算,注定了编译器可能把变量优化到寄存器中。linux中如何进行优化?man g++:

编译器编译时会有若干种优化级别,-O带数字表示不同优化级别(常见的是-OO到-O3)下面测一下(优化级别拉满):

ctrl+c时看到输出语法被打了,说明flag置为1了,while做检测时应该正常退出,可为什么没有退出呢?这是cpu和内存:

不管我们怎么优化,对应的变量必须在内存里进行开辟(flag在内存中是要存在的)。只不过以前计算时,每次把flag从内存读到寄存器进行检测。优化后每次从寄存器中用变量,不用每次从内存拿了:

今天我们信号捕捉那把flag改为了1,但优化后不访存每次只到寄存器里用,因此测试时发现代码在优化情况下退不出来了(因为优化导致我们内存不可见了)。为了防止cpu对这种变量的过度优化:

用volatile修饰flag变量,下面测试:

c+l+c时正常退出。所以volatile关键词作用是,防止编译器过度优化,保持内存的可见性。
8.SIGCHLD信号
下面来说说SIGCHLD信号:我们以前说子进程退出时父进程要等待,要不然子进程会进入僵尸。父进程并不知道子进程什么时候退,所以父进程只能以阻塞或非阻塞轮询方式不断通过waite或waitpid检测子进程是否退出。子进程退出并不是单纯的退了,而是在退出时会向父进程发送信号,发送的信号的编号叫SIGCHLD信号(17号)。下面证明一下:

所以子进程在进行进程等待的时候,我们可以采用基于信号的方式进行等待。先说说等待的好处:1.获取子进程的退出状态,释放子进程的僵尸。2.虽然不知道父子谁先运行,但是我们清楚,一定是father最后退出。通过上述说明,要释放子进程我们仍要调用wait或waitpid这样的接口,基于信号说明父进程必须保证自己是一直在运行的(等着信号)。我们知道子进程退出时会向父进程发送SIGCHLD,我们可以试着把子进程等待写入到信号捕捉函数中:

首先前5s父子同时跑,子进程一退父进程收到17号信号执行信号捕捉。5秒内子进程僵尸,然后回收后只剩父进程一个人:

测试看到符合预期,可以基于信号退出。那如果我们有10个子进程,它们同时退出呢?每个子进程都向父进程发送信号,当我们正在捕捉一个时又收到一大堆信号。因为信号会在处理期间阻塞,这样10个信号中有8个丢失了,可能只执行了两个信号捕捉,所以有多个子进程基于信号我们该如何正确回收呢?带个循环,这样只要收到一个SIGCHLD都是尝试一直回收:

那如果退一半呢?当回收第6个时第6个还没有退出,这样就卡在waitpid这里了,handler不返回了,所以设置为以非阻塞方式等待:

这样未来有多少进程退出都可以。继续看个问题,那必须调用wait等待吗?可不可以将僵尸时直接让系统把它释放了,父进程并不想关心退出码可以吗:

也就是linux中可以直接这样:

下面测一下:

退出时没看到僵尸。继续看:

17号动作是Ign,说明之前父进程收到17号信号是忽略。以前没有signal时默认处理动作是忽略是有僵尸的,现在signal(17, SIG_IGN)为啥没僵尸了?因为以前收到17号执行的是SIG_DFL,它的动作是忽略;现在执行的是SIG_IGN,两者是有差别的。
&spm=1001.2101.3001.5002&articleId=161007347&d=1&t=3&u=feba4b2e52394378949235e2a9b89a56)

被折叠的 条评论
为什么被折叠?



