前言
无人问津也好,技不如人也罢,你都要试着安静下来,去做自己该做的事情,而不是让烦恼和焦虑毁掉你不就不多的热情和定力。心可以碎,手不能停,该干什么干什么,在崩溃中继续努力前行,这才是一个成年人的素养。
--余华
与大家分享余华老师的名言,希望大家能在学习疲惫时调整好心态,继续砥砺前行!那么今日主题进程信号,以信号的产生-信号的保存-信号的处理为时间线进行讲解,后面也从信号中衍生出来的话题,比如可重入函数,volatile关键字等。
信号入门
信号
信号概念
信号是进程之间事件异步通知的一种方式,属于软中断。
在Linux终端中,通过kill -l查看信号,我们发现信号总数并不是64,它的范围是[1-31]和[34-64]。一般把[1-31]的信号称之为普通信号,[34-64]称之为实时信号。
[hongxin@VM-8-2-centos ~]$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8 43)
SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47)
SIGRTMIN+13 48)
SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52)
SIGRTMAX-12 53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57)
SIGRTMAX-7 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62)
SIGRTMAX-2 63) SIGRTMAX-1 64) SIGRTMAX
生活角度的信号
在生活中,我们理解的信号:当一个信号产生时,首先我们知道其意思,并且能产生对应的行为。
举例:红绿灯
1.首先我们能够识别红绿灯,认识它。
我们为什么能够认识信号,这原因是,从小老师的教导,我们记住对红绿灯星号发出后做出相对应的行为。
2.当红绿灯亮起时,红灯停,绿灯行。这是产生行为-走/停。
当绿灯亮起时,我们必须走吗,可以不走,你可以选择下一个的红绿灯,也可以选择跳个舞再走。所以得出一个结论,当信号(随时)产生时,但可以不(立即)执行。
3.当信号产生,时间窗口将它保存后,信号被处理。
如何处理:①可以默认处理(红灯停,绿灯行)②初略处理(当灯亮时,不做出任何行为)③自定义处理(当灯亮时,选择跳舞)。
技术应用角度的信号
将上面的例子和概念迁移到进程中
1.进程的识别:需要先认识(先组织后描述)再产生行为 (处理信号)。
2.进程本身是被程序员编写的属性和逻辑的集合。
3.当进程收到信号时,进程可能正在执行更重要的代码,所以信号不一定会被立即处理。
4.进程本身必须要有对信号的保存能力
5.进程处理信号有三种方式:默认,自定义,忽略【信号被捕捉】
我们知道信号不是被立即处理的,所以信号是需要被保存起来的。那么它是保存在哪里?又是如何保存的呢?
关于信号保存在哪里是不难理解的,因为我们发现信号时发送给进程的,例如我们熟知的kill -9 pid。当进程进入僵尸状态了,我们就可以使用它将其“杀死”。而进程需要识别信号,那么信号是不是应该被保存在PBC(tack_strcut)中的。
对于如何保存,在tack_strcut中建立32位的位图,比特位的位置代表:信号的编号。比特位的内容代表:是否收到信号,0未收到,1收到信号。如图:
发生信号的本质,其实不是发送,而是修改。将位图0置1,进程接受到信号。
谁来维护位图呢?很显然不可能是用户,pbc的数据是不可能让用户随意修改的。只能OS(操作系统),修改位图也只能是OS。
无论未来我们学习多少种信号的发送,本质都是OS向目标进程发送的信号(修改位图)!
回过来,当我们不能直接对PCB进行修改数据,那么当我们发送信号时,OS肯定会提供发送信号处理信号的相关系统调用。
当我们知道信号需要发生,保存,处理。我们可以画出它的生命周期,如图 :
为了更好的观察信号,当用户输入命令,在Shell下启动一个前台进程。用户按下Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程 。
前台进程因为收到信号,进而引起进程退出 。代码如下:
#include <iostream>#include <unistd.h>int main(){ while(true) { std::cout<< "I am process!" << getpid() << std::endl; sleep(1); } return 0;}
终端指令如下:
[hongxin@VM-8-2-centos 2023-4-3]$ make
g++ -o mysignal mysignal.cc -std=c++11
[hongxin@VM-8-2-centos 2023-4-3]$ ll
total 20
-rw-rw-r-- 1 hongxin hongxin 82 Apr 3 21:26 makefile
-rwxrwxr-x 1 hongxin hongxin 9184 Apr 3 21:30 mysignal
-rw-rw-r-- 1 hongxin hongxin 179 Apr 3 21:29 mysignal.cc
[hongxin@VM-8-2-centos 2023-4-3]$ ./mysignal
I am process!25658
I am process!25658
I am process!25658
I am process!25658
I am process!25658
^C
[hongxin@VM-8-2-centos 2023-4-3]$
当按下Ctrl + c时进程终端,其本质是Ctrl + c是一个组合键,是被操作系统识别,Ctrl + c被操作系统解释为2号信号,2) SIGINT 。
如果想了解SIGINT,就可以通过手册查询:man 7 signal
SIGINT 2 Term Interrupt from keyboard
Ctrl + c这里是被默认处理,这里默认处理就是Term->terminal 终止进程。键盘上获取Ctrl + c然后终止进程。我们也讲过自定义处理。提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。信号捕捉函数signal。
通过man手册进行了解signal函数,man 2 signal 进入手册:
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signal参数:
signum //信号数 例如SIGINT 为 2
handler //自定义方法,写一个 handler函数,调用 handler里的方法
返回值:
成功返回信号处理程序的前一个值,或错误时返回SIG_ERR。发生错误时,errno设置为指示原因。
测试现象:当我们没有按下Ctrl + c时,代码一直运行;按下Ctrl + c后信号被捕捉,进程退出。
#include <iostream>#include <unistd.h>#include <signal.h>void myhandler(int signal){ std::cout<< "进程捕捉到了一个信号,信号编号是:"<< signal<< std::endl; exit(0);}int main(){ signal(2,myhandler); while(true) { std::cout<< "I am process!" << getpid() << std::endl; sleep(1); } return 0;}
[hongxin@VM-8-2-centos 2023-4-3]$ ./mysignal
I am process!8099
I am process!8099
I am process!8099
I am process!8099
I am process!8099
I am process!8099
I am process!8099
I am process!8099
I am process!8099
^C进程捕捉到了一个信号,信号编号是:2
所以signal(2,myhandler);这里是signal函数的调用,并不是myhandler的调用,仅仅是设置了对2号信号的捕捉方法,并不代表该方法被调用了,一般这个方法不会执行,除非收到对应的信号!当捕捉到2号信号后才执行myhandler方法。
产生信号
通过终端按键产生信号
在上述介绍的Ctrl + c就是从键盘产生信号,不光Ctrl + c,我们也通过Ctrl + \也能对进程发生信号。
[hongxin@VM-8-2-centos 2023-4-3]$ ./mysignal
I am process!12795
I am process!12795
I am process!12795
I am process!12795
I am process!12795
I am process!12795
I am process!12795
I am process!12795
^\Quit
通过kill -l,我们可以发现, Ctrl + \其实就是3号信号( SIGQUIT)。所以我们通过kill -3 pid将该进程终止。
调用系统函数向进程发信号
下面我们通过代码测试来理解调用系统函数向进程发信号,这里可以用到调用系统函数中其中一个kill函数,通过man 2 kill查看
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
参数:
pid:进程的pid,sig:第几个信号
返回值:
zero is returned. On error, -1 is returned, and errno is set appropriately.
该测试代码,用自己的mian调用kill函数,实现用 mykill向进程发送信号,然后处理。
mysignal.cc
#include <iostream>#include <unistd.h>#include <signal.h>#include <sys/types.h>#include <string>#include <stdlib.h>//调用main参数错误后,打印static void Usage(const std::string &proc){ std::cout<< "/n Usage: " << proc << "pid signo"<< std::endl;}//argc表示程序运行时发送给main函数的命令行参数的个数(包括可执行程序以及传参)//argv[]是字符指针数组,它的每个元素都是字符指针,指向命令行中每个参数的第一个字符。//argv[0]指向可执行程序//argv[1]指向可执行程序后的第一个字符串。//argv[2]指向可执行程序后的第二个字符串。//argv[argc]为NULLint main(int argc ,char *argv[]){ //系统调用向目标进程发送信号 if(argc != 3) { Usage(argv[0]); exit(1); } //将mian第二参数字符串转换成pid_t,得到的pid pid_t pid =atoi(argv[1]); //字符串转成pid_t,signo=几号信号 pid_t signo =atoi(argv[2]); //调用kill函数 int n = kill(pid,signo); if(n != 0) { perror("kill fail"); } // while(true) // { // std::cout<< "I am process !" << getpid() << std::endl; // sleep(1); // } return 0;}
mytest.cc
#include <iostream>#include <sys/types.h>#include <unistd.h>//一直运行的程序,用于测试int main(){ while (true) { std::cout<< "我是一个正在运行的进程,pid: " << getpid() <<std::endl; sleep(1); } }
makefile
.PHONY:allall:mysignal mytestmytest:mytest.ccg++ -o $@ $^ -std=c++11mysignal:mysignal.ccg++ -o $@ $^ -std=c++11 -g.PHONY:cleanclean:rm -f mysignal mytest
代码显示过程:打开两个进程,其中一个运行mytest,让进程一直运行;一个用mykill来“杀死”一直运行的进程。结果是正确运用mykill,将mytest进程“杀死”。
那么同样的,我们也可以用./mykill pid 信号,来调用其他信号。kill()可以向任意进程发送任意信号。
除了kill函数,这里也再介绍一个函数raise。
功能:给自己发任意信号
#include <signal.h>
int raise(int sig);
//如果cnt==5调用信号3,终止程序 int cnt=0; while(cnt <= 10) { printf("cnt :%d\n",cnt++); if(cnt==5) raise(3); }
结果:当打印到5时,调用信号3,退出进程。
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal
cnt :0
cnt :1
cnt :2
cnt :3
cnt :4
Quit
raise(3)的实现,其实也可以用kill替换。kill(getpid(),sig)。
第三个函数abort,通man手册来查看它的用法:
功能:给自己发送指定的信号:6) SIGABRT
#include <stdlib.h>
void abort(void);
代码的实现和结果
int cnt=0; while(cnt <= 10) { printf("cnt :%d\n",cnt++); if(cnt==6) abort(); }
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal
cnt :0
cnt :1
cnt :2
cnt :3
cnt :4
cnt :5
Aborted
为了证实abort给自己发送指定的信号,是不是6) SIGABRT,那么我们可以通过上述写的mytest,将进程运行,然后用kill调用6号信号,看是否一样(Aborted )。结果很是这样的,abort我们也可以直接做封装,kill(getpid,Aborted )。
硬件异常产生信号
看了上述,调用系统函数向进程发信号。我们发现一个问题:信号处理的行为,很多的情况,进程收到大部分的信号,默认处理动作都是终止进程。
那么信号的意义是什么?
举个例子:在刚开始编写程序时,经常会出现各种错误,很多时候的处理方式都是进程终止,但是我们可以通过错误码对应的错误信息,找到错误。
所以说信号的意义:信号的不同,代表不同的事件,但是对事件发生之后的处理动作可以一样!
下面通过代码来理解,我们知道操作系统是不能除0的,其原因不是说操作系统不能算,它是可以进行除0计算的,但是算出是非常大的值(一个错误值),所以系统直接将它设置浮点数错误。这里我们写一段关于除0的代码,观察会出现什么情况。
while(true) { std::cout<< " 我正在运行....."<< std::endl; sleep(1); int a = 10; a /= 0; }
[hongxin@VM-8-2-centos 2023-4-4]$ make
g++ -o mysignal mysignal.cc -std=c++11 -g
mysignal.cc: In function ‘int main(int, char**)’:
mysignal.cc:31:11: warning: division by zero [-Wdiv-by-zero]
a /= 0;
^
g++ -o mytest mytest.cc -std=c++11
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal
我正在运行.....
Floating point exception
不出意外,报了浮点数错误。并且它还将进程终止了,除0后会将进程终止呢?
因为当前进程会受到来自OS系统的信号(告知),8)SIGFPE
为了能够证明,Floating point exception 实质就是向系统发送了SIGFPE,下面通过代码进行证明,代码逻辑:当除0时,操作系统会向进程发送SIGFPE信号,此时通过signal()函数捕捉到SIGFPE时,通过自定义函数catchSig打印出捕捉到的信号。
void catchSig(int signo){ std::cout<< " 获取一个信号吗,信号编号是:" << signo <<std::endl; exit(1);}int main(int argc ,char *argv[]){ //3. 产生信号的方式:硬件异常产生信号 // 信号产生,不一定非得用户显示的发送! signal(SIGFPE,catchSig); while(true) { std::cout<< " 我正在运行....."<< std::endl; sleep(1); int a = 10; a /= 0; } return 0;}
运行结果,也正如上述猜想一样,获取到的信号是8号信号,我再通过kill -l查看8号信号,再次确认8号就是SIGFPE信号。
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal
我正在运行.....
获取一个信号吗,信号编号是:8
当catchSig中不调用exit函数时,会出现什么情况的呢?
现象是:一直打印输出
一直打印是因为在循环中一直出错吗?
我们将 int a = 10; a /= 0; 剥离出while,然后发现不是这个原因造成的结果。所以说,收到信号后不一定会引起进程的退出。
问题又更新了,操作系统如何得知应该给当前进程发送8号信号的?
这个问题就跟硬件相关了,下图是对除0的详细理解。当CPU运行异常后,CPU会通过状态寄存器获取错误,这里是溢出标志位置1,那CPU就清楚错误原因,这个时候就可以向进程发生相对应的信号,这里是除0,寄存器存储不下,溢出错误,发生8号信号。
上述的一直打印输出的问题还未解决。通过对硬件有一定了解后,再来解决该问题。
我们知道CPU内部只有一套,但寄存器中的内容是属于当前进程的上下文中(之前涉猎到的知识)。CPU检查出问题后,是没有能力去修正这个问题的(有时仅仅是编码时的错误)。当进程被切换的时候,就有无数次状态寄存器被保存和回复的过程。每一次恢复时,操作系统就能识别到CPU内部的状态寄存器中的溢出标志位是1。
这里的问题,简单来说:就是CPU识别到问题了,但未解决,状态寄存器中的溢出标志位一直是1,捕捉信号到一直都SIGFPE,操作系统就一直发出该信号。
除了除0问题,还有一个我们也经常遇见对空指针解引用的问题。代码和结果如下:
while(true) { std::cout<< " 我正在运行....." << std::endl; sleep(1); int* p= nullptr; *p = 100; }
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal
我正在运行.....
Segmentation faul
那么野指针报错,操作系统又会向进程发生那一个信号呢?
答:11) SIGSEGV
同样的方式,也能证明它是它是11信号。
hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal
我正在运行.....
获取一个信号吗,信号编号是:11
操作系统怎么知道野指针了呢?
操作系统认为对nullptr地址访问无意义,认为报错。然后在MMU中记录起原因,然后进程就知道错误原因,知道原因后就能做出相应的行为,发送11号信号。
由软件条件产生信号
SIGPIPE是一种由软件条件产生的信号,在“管道”中已经介绍过了。本节主要介绍alarm函数和SIGALRM信号。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动 作是终止当前进程。
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后 响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就 是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数 (自己验证一下?)
int main(int argc ,char *argv[]){ int cnt=0; alarm(1); while(true) { printf("cnt:%d\n",cnt); cnt++; } return 0;}
在一分钟后闹钟响起,操作系统向进程发送信号 14) SIGALRM
这份代码的意义是什么呢?
统计1s左右,我们计算机能够将数据累加多少次!
//多次运行,统计结果
cnt:111965Alarm clock (1)
cnt:124696Alarm clock (2)
cnt:131017Alarm clock (3)
cnt:128791Alarm clock (4)
将代码调整后,观察:首先将cnt调整成全局变量,再设置signal捕捉,循环中只++,signal调用的函数进行打印。
//多次打印后的结果
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal
获取一个信号吗,信号编号是:561830771
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal
获取一个信号吗,信号编号是:563495923
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal
获取一个信号吗,信号编号是:562496315
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal
获取一个信号吗,信号编号是:562106150
为什么第二次++的次数与第一次++的次数相差几乎500倍呢?
因为第一次打印比较多,就会进行多次I/O操作,I/O会花费大量的时间。得出结论:IO非常慢
还有一个很有小细节,当调用的catchSig时,没有写exit函数时,运行后也打印一次。相当于这个闹钟只响一次,一次过后不再响。
void catchSig(int signo){ std::cout<< " 获取一个信号吗,信号编号是:" << cnt <<std::endl; //exit(1);}
如果我们想闹钟一直响,我们可以catchSig中再设置alarm。
void catchSig(int signo){ std::cout<< " 获取一个信号吗,信号编号是:" << cnt <<std::endl; //exit(1); alarm(1);}
如何理解闹钟是由软件条件产生信号?
--"闹钟"其实就是用软件实现的
任意一个进程都可以通过alarm系统调用在内核中设置闹钟,操作系统中可能会存在很多闹钟,此时,操作系统就需要管理这些闹钟。管理闹钟就是需要先描述,再组织。
总结
1.上面所说的所有信号产生,最终都要有OS来进行执行,为什么?
OS是进程的管理者 ,只有OS有权力向目标进程写入信号
2.信号的处理是否是立即处理的?
不是,在合适的时候
3.信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
是需要被保存下来的,被记录在PCB中
4.一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
能知道,当未收到信号时,对信号如何做处理已经被默认程序员写入在代码中的
5.如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
操作系统发送信号,本质是在进程(结构体)的位图(signal)进行修改,将对应位图的信号编号进行置1处理,置1表示操作系统向进程发送信号,如果是0表示未发送信号。
核心转储
关于产生信号的退出问题,大部分信号的执行结构都是终止,但是有两种终止方式:Term,Core。那么他们有什么区别呢?
为了便于操作和理解,这里我们采用11号信号进行测试和观察,观察Core是如终止进程的。代码就是简单数组越界问题。
while (true) { int a[10]; a[10000] = 106; }
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal
Segmentation fault
结果只出现一个错误描述,感觉上与Term正常结束是几乎一样的,因为我们没有看见其他现象,但事实就是如此吗?
首先我这里是使用的云服务器,在云服务器上,默认如果进程是core退出,暂时看不到明显的现象,如果想看到可以输入:ulimit -a 进行观察
通过观察发现,core file size设置为 0,则代表了云服务器默认关闭了core file选项。如果我们想打开此选项:ulimit -c 1024;我们再输入ulimit -a查看:
core file size (blocks, -c) 1024
当我们打开云服务器的core file选项后,再运行当前代码。我们发现不仅多了core dumped,而且还多生成了一个core文件。
[hongxin@VM-8-2-centos 2023-4-4]$ ./mysignal
Segmentation fault (core dumped)
[hongxin@VM-8-2-centos 2023-4-4]$ ll
total 304
-rw------- 1 hongxin hongxin 557056 Apr 5 10:36 core.598
-rw-rw-r-- 1 hongxin hongxin 167 Apr 4 00:29 makefile
-rwxrwxr-x 1 hongxin hongxin 47864 Apr 5 10:20 mysignal
-rw-rw-r-- 1 hongxin hongxin 2734 Apr 5 10:19 mysignal.cc
-rwxrwxr-x 1 hongxin hongxin 9176 Apr 5 10:20 mytest
-rw-rw-r-- 1 hongxin hongxin 259 Apr 4 00:00 mytest.cc
core dumped--核心转储 ;core.598->这个598--引起core问题进程的pid;core.589一般是以core+引起core问题进程pid命名文件,该文件存在磁盘中。
为什么需要有核心转储呢?
目的是为了支持调试,如何支持呢?直接在gdb的上下文中core-file core.xxx
作为对面,还是用上述代码,不报段错误,直接死循环用kill -2 pid终止进程(Term)。然后观察终止后会不会产生core文件,发生核心转储。
SIGINT 2 Term Interrupt from keyboard
结论:
以core退出的可以被核心转储,Term退出是没有被核心转储即为正常退出。核心转储其目的是为了更便于调试。
最后关系信号产生,最后一个问题:
如果将全部信号捕捉,然后自定义处理后,是不是该进程一直被执行,就不能被终止。
为了探究这个问题,下面进行代码测试,实验见证真理。
然后我们发现即使其他进程都被自定义处理后,但是kill -9 还是能将进程终止的。9号信号是管理员信号,在操作系统内是静止对9号信号做捕捉的。
信号的保存--阻塞信号
信号其他相关常见概念
●实际执行信号的处理动作称为信号递达(Delivery)
●信号从产生到递达之间的状态,称为信号未决(Pending)。
●进程可以选择阻塞 (Block )某个信号。
●被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
●注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
对于抵达,未决,阻塞这三个名词是非常有必要了解其意思。相信大家很好理解抵达和未决。对于阻塞是不易理解的。
例:整个这个信号的过程,我们可以用生活中的例子理解,上课时老师发出信号,说:大家把书上例题1,7,13勾画上,有时间去把做了。这个时候由于老师需要继续上课,我们有更要的事情需要处理,就在书上记录(勾画)上题,但是一直没有做。这段时间老师发出的信号就叫做未决。
当回家后我们觉得这个老师讲的知识点很难,我们不想做,该信号阻塞(记录但不做)。但是过一段时间你认为老师很严格,不做后果很严重,就选择把例题做了,该信号抵达。
还有一种情况,老师上课说:把这个例题算出来(信号的产生),大家不需要记录,直接就做。在这个过程不需要保存信号,发出信号直接执行(抵达)。
注意,阻塞和未决是不一样的,阻塞是需要保存这个信号,未决是在发出信号,不管你保不保存信号都是未决状态。
注意,阻塞和忽略也是不同。比如当老师布置了例题(发出信号),我们认为做不做都不影响时,我们直接不记录这些例题(忽略)。在未保存信号下,我们执行的策略是(忽略)。在发出信号到未做这段时间处于未决状态,阻塞是在未决状态之间的。忽略是在未决之后,更是在抵达(如何执行老师的信号)之后。所以说忽略是在递达之后可选的一种处理动作。
在内核中的表示
信号在内核中的表示示意图
●每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针(handler)表示处理动作。
●在代码运行过程中,在用户层调用signal函数,对信号进行捕捉,若有信号被捕捉到,操作系统向进程发送信号,进程中pending位图置1,如果操作系统判断该信号可以立即执行,则不需要保存信号,例如 SIGINT 信号--正常处理(Term )。
●如果是SIGQUIT信号需要保存处理--( Core),一旦产生SIGQUIT信号将被阻塞,当它的处理动作是用户自定义函数sighandler。此时需要接触对该信号的阻塞,然后用内核态转入到用户态,对用户态的handler进行执行。
●如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
地址空间第二讲
关于上述介绍,我们发现程序员写的代码是在用户态那一层,PCB则是在内核态一层。什么时候需要访问内核态呢?内核资源是通过什么访问的呢?
在这个进程中,有两块空间,其中一块是让用户使用的,另外的一块是让操作系统使用的。
例:这好比在学校,作为学生我们只能在自己的班级上课,而不能去其他班级。作为校长就可以随便去哪个班级。我们则是用户,校长就好比操作系统。
用户为了访问内核或者硬件资源都必须通过系统调用。但系统调用往往都比较耗时,因为系统调用会进行大量操作,所以我们应该尽量避免频繁的调用系统操作。
这里介绍了进程中有用户态和内核态,也知道了如果用户态如果想访问操作系统本身的资源或者硬件资源时,需要通过系统调用访问,而且系统调用比较耗时。
系统调用是用户用相关系统调用接口实现访问内核资源,这个调用过程是CPU完成的,所以我们还是不理解,操作系统是如何跟进程联系起来的呢?
我们知道在CPU中有大量的寄存器
1.可见寄存器,如exa,exd等通用寄存器。
2.不可见寄存器,如状态寄存器,CR3等寄存器。
凡是这些寄存器与当前进程相关,进程就会存储寄存器的上下文数据--(保存了程序运行时寄存器的当中的内容:如一个进程在运行过程中被切换出去,上下文信息就保存了寄存器的信息,直到这个进程重新拥有cpu资源)。
在CPU中的诸多寄存器中,有指定寄存器保存task_struct的起始地址实现直接跳转到进程中,也有指定寄存器保存页表起始地址,还有CR3表示当前进程的运行级别的指定寄存器。上下文数据也有专门的寄存器保存。
知道了他们之间的联系,那么他们又是如何执行的呢?比如我是一个进程,怎么就跑到操作系统中去执行方法呢?
如上图,则是进程是如何调用到系统资源的原理图。
●每个进程的数据都被保存到相应的寄存器中,当在用户空间执行程序时,相关上下文的寄存器运行。当系统识别到用户通过系统调用访问内核数据时,在CPU中这个系统调用接口,在起始的位置就会帮你调整进程的运行级别,系统调用接口会通过Int 80-陷入内核(在设计系统接口时就已经编写好的),Int80就会用到CR3寄存器。改变运行级别:将级别0变成1。
●而且每个进程都有3-4G的内核空间,都会共享内核级页表,无论进程如何切换都不会改变3-4的内核数据资源。所以在CPU中的指定寄存器中改变运行级别后,直接在mm_struct直接实现跳转获取相关的内核数据。
sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
sigset_t实质就是结构体封装的一个数组,在c++中bitset也讲过--位图。
# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
#endif
信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统 实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做 任何解释,比如用printf直接打印sigset_t变量是没有意义的 。
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
●函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
●函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
●注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
●这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)--block。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
sigpending
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
面用刚学的几个函数做个实验。程序如下:
#include <iostream>#include <signal.h>#include <unistd.h>#include <vector>//#define BLOCK_SIGNAL 2#define MAX_SIGNUM 31static std::vector<int> sigarr = {2};static void show_pending(const sigset_t &pending){ for(size_t signal=MAX_SIGNUM; signal > 0; --signal) { if(sigismember(&pending,signal)) { std::cout<< "1"; } else { std::cout<< "0"; } } std::cout<<std::endl;} static void myhandler(int signo){ std::cout << signo << " 号信号已经被递达!!" << std::endl;}int main(){ for(const auto &sig : sigarr) signal(sig, myhandler); //1.尝试屏蔽指定的信号 sigset_t block,oblock,pending; //1.1初始化 sigemptyset(&block); sigemptyset(&oblock); sigemptyset(&pending); //1.2添加要屏蔽的信号 //批量化屏蔽 for(const auto &sig : sigarr) sigaddset(&block, sig); //1.3开始屏蔽,设置进内核(进程) sigprocmask(SIG_SETMASK,&block,&oblock); //2.遍历打印pending信号集 int cnt = 10; while(true) { //2.1初始化 sigemptyset(&pending); //2.2获取pending sigpending(&pending); //2.3打印 show_pending(pending); //慢一点 sleep(1); if(cnt-- == 0) { sigprocmask(SIG_SETMASK, &oblock, &block); //一旦对特定信号进行解除屏蔽,一般OS要至少立马递达一个信号! std::cout << "恢复对信号的屏蔽,不屏蔽任何信号\n"; } }}
这里就不将代码的运行结果打印出来了,自己去运行一下对于尝试对结果进行分析,这样学习效果可能会更好。
该代码证明了:信号如果是被block,它是无法被抵达的,只能被pending。
信号的抵达处理--捕捉信号
捕捉的流程
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
为了便于记忆,我们将图简化倒过来的8:
捕捉信号的方法--sigaction(新增)
sigaction函数可以读取和修改与指定信号相关联的处理动作。
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
signum:
指定信号的编号,并且可以是除SIGKILL和SIGSTOP之外的任何有效信号。
act,oldact,sigaction :
若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传 出该信号原来的处理动作。act和oact指向sigaction结构体 :
struct sigaction { |
●将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。 ●sa_sigaction和sa_restorer一般设置为nullptr,sa_flags设置为0,都不管它。
●sa_mask其定义类型sigset_t ,在上诉中已经讲过,本质是数组,用结构体封装的数组。其中可包括定义block,pending位图的信号集。
return val:
returns 0 on success; on error, -1 is returned, and errno is set to indicate the error.
下面通过代码测试来熟悉sigaction,我们代码大致实现的功能:调用sigaction对SIGINT信号进行捕捉,捕捉到SIGINT信号后调用handler方法,在SIGINT信号发生打印确认捕捉,细节睡眠10秒。程序如下:
#include <iostream>#include <signal.h>#include <unistd.h>using namespace std;void Count(int cnt){ while (cnt--) { cout<< cnt<<" "; fflush(stdout); sleep(1); } cout<<endl;}void handler(int signal){ cout<< "get a signal"<<signal <<endl; Count(10);}int main(){ struct sigaction act,oldact; act.sa_flags=0; act.sa_restorer=nullptr; act.sa_handler = handler; sigemptyset(&act.sa_mask); sigaction(SIGINT,&act,&oldact); while(true) sleep(1); return 0;}
在下图运行结果中,发现当我们一直用kill调用SIGINT信号,但sigaction并不是每次都捉,
●现象一:当只用kill一次调用SIGINT信号,只打印一次,睡眠结束后不打印。
●现象二:多次kill调用SIGINT信号,每次最开始打印1次,当睡眠10秒结束后,再打印一次。
有上面现象,我们可以得出:
●当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止(现象二)。
●一般一个信号被解除屏蔽的时候,会自动进行抵达当期屏蔽的信号,如果该信号已经被pending的话,没有就不做任何动作(现象一)。
●如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数。
结论:进程处理信号的原则是串行处理同类型的信号,不允许递归处理。
再度理解sigaction函数的参数sa_mask:
当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中
例:在当前代码上加入,其效果是,不仅能屏蔽信号2,还能屏蔽3;
sigaddset(&act.sa_mask,3); |
可重入函数
下列在主函数中,调用insert时开始插入一半的时候,如果调用了信号捕捉,然后信号捕捉的自定义函数handler中又调用insert。
因为我们知道单链表的插入是头插,第一步:node1->next=head。但此时调用了handler方法,第二步:就从head = node1变成了node2->next=head,最后head会先:head=node2,然后head=node1,在同一步进行了,就会出现head只链接head1的问题。
具体解释如下:
●main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
●像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称 为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的 控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
一般而言,main执行流与信号捕捉是两个执行流
1.如果在main中,和在handler中,该函数被重复进入,出现问题--该函数(insert)不可重入函数
1.如果在main中,和在handler中,该函数被重复进入,未出现问题--该函数(insert)可重入函数
首先我们应该明白不可重入函数不是一个问题,而是在单执行流下的特性。因为在很多场景下我们是在单执行流下调用,该函数的起始目的也不是为了在多执行流下调用的。所以不是不可重入函数出现了问题,而是用户调用是没有想明白而已。
volatile
相信大家在c语言就已经对volatile关键字涉猎了,一段代码中加volatile与不加volatile查看汇编代码后,得出结论是:volatile忽略编译器的优化,保持内存可见性。
在gcc中也有编译器的优化级别,我们通过man gcc查看
name=value -O1,-O2,-O3,-Os,-Ofast |
这里先看不被优化的程序:
#include <stdio.h>#include <signal.h>int quit =0;void handler(int signo){ printf("%d 信号已经被捕捉!\n",signal); printf("quit -> %d\n",quit); quit=1; printf("-> %d\n",quit);}int main(){ signal(2,handler); while(!quit); printf("注意,我是正常退出!\n"); return 0;}
该代码:如果未发送SIGINT,程序一直循环,当发送SIGINT信号,信号被捕捉,进入handler将quit置1,然后正常退出。
[hongxin@VM-8-2-centos 2023-4-7]$ ./mysignal
^C4195520 信号已经被捕捉!
quit -> 0
-> 1
注意,我是正常退出!
当我们将gcc的运行级别改成-O不退出3时,我们再观察发现,改代码
[hongxin@VM-8-2-centos 2023-4-7]$ ./mysignal
^C4195520 信号已经被捕捉!
quit -> 0
-> 1
|
操作系统中与CPU的关系,CPU相当于毛坯房,操作系统是装修。那么对于CPU会进行以下几个步骤:
1.取指令
2.分析指令
3.执行命令
4.将结果写会对应的内存
其原理图如下:
如何解决这个问题呢?我们直接在quit前volatile,程序正常。
volatile int quit =0; |
[hongxin@VM-8-2-centos 2023-4-7]$ ./mysignal
^C4195520 信号已经被捕捉!
quit -> 0
-> 1
注意,我是正常退出!
volatile:保持内存可见性。
由于gcc被优化,在代码中如果需要访问内存数据,就需要加volatile,其目的是为了保持内存的可见性,让寄存器能够访问内存数据。相反,不能不保持内存可见性,那么在用户态中quit的临时数据就不会被改变,也不会向内存中访问被修改的数据。
SIGCHLD信号 - 选学了解
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。
#include <stdio.h>#include <stdlib.h>#include <signal.h>#include <unistd.h>int quit = 0;void handler(int signo){ printf("pid : %d,%d信号,正在被捕捉\n",getpid(),getppid());}void Count(int cnt){ while (cnt) { printf("cnt: %2d\r", cnt); fflush(stdout); cnt--; sleep(1); } printf("\n");}int main(){ // 显示的设置对SIGCHLD进行忽略 signal(SIGCHLD, handler); //signal(SIGCHLD, SIG_DFL); printf("我是父进程, %d, ppid: %d\n", getpid(), getppid()); pid_t id = fork(); if (id == 0) { printf("我是子进程, %d, ppid: %d,我要退出啦\n", getpid(), getppid()); Count(5); exit(1); } while (1) sleep(1); return 0;}
测试结果:
[hongxin@VM-8-2-centos 2023-4-8]$ ./mysignal
我是父进程, 2945, ppid: 21647
我是子进程, 2946, ppid: 2945,我要退出啦
cnt: 1
pid : 2945,21647信号,正在被捕捉
该上面是证明了子进程退出会向父进程发送 SIGCHLD信号,但未对父进程在信号处理函数中调用wait清理子进程,下面就是在handler中wait清理子进程的代码和解释。
void handler(int signo){ //1.有很多子进程,在同一个时刻退出 //--在同一时刻退出也必须依次退出,必须while退完 //2.有很多子进程,在同一时刻只有一步部分退出 //--尽管只有一部分退出,对于系统而言,它是不知道到底有多少个进程需要退出,那么只有退完之后才知道 //--在waitpid中默认的是阻塞是等待,如果没有退出完,就会发生僵尸 //--所以我们将等待改成非阻塞, while (1) { //如果指定了WNOHANG,并且存在一个或多个由pid指定的子(ren),但尚未更改状态,则返回0。出现错误时,返回-1。 pid_t ret = waitpid(-1,NULL,WNOHANG); if (ret == 0) { //ret==0 则就是 waitpid调用成功 && 子进程没退出 //子进程没有退出,我的waitpid没有等待失败,仅仅是监测到了子进程没退出.那么继续等待退出即可 break; } else if(ret > 0) { //waitpid调用成功 && 子进程退出成功 printf("wait child success %d\n ",ret); } printf("child is quit! %d\n",getpid()); } printf("pid : %d,%d信号,正在被捕捉\n",getpid(),getppid());}
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证 在其它UNIX系统上都可 用。请编写程序验证这样做不会产生僵尸进程。
显示的设置对SIGCHLD进行忽略,在signal中将处理动作设置为SIG_IGN,代码段如下:
signal(SIGCHLD, SIG_DFL); |
为了更好的观察,用两个进程来演示,一个用来跑./mysignal,一个用来跑脚本代码,脚本代码如下:
while :; do ps ajx | head -1 | ps ajx| grep mysignal; sleep 1; echo "----------------"; done |
这里需要注意的是,grep本身调用也是一个进程,这里我们不把它参考进来。其主要效果是mysignal运行是有父进程和子进程,但五秒后,子进程被系统自动回收。
最后一个问题,通过man 7 signal查看SIGURG,不是说17号信号本身属性就是lgn吗?为什么调用signal时还要加上SIG_IGN处理方法?
SIGCHLD 20,17,18 Ign Child stopped or terminated
默认设置和手动设置表现出来的特性是不一样的,SIGCHLD就好像是操作系统自动去识别默认,进行默认处理;SIG_IGN是告诉操作系统默认回收。
[ 作者 : includeevey ? [ 日期 : 2023 / 4 / 1 [ 代码 : 卿洪欣 (hong-xin-qing) - Gitee.com
|