目录
1、信号入门
生活角度的信号
生活中有很多的信号:诸如红绿灯、铃声、闹钟、旗语……。对于这些不同场景下的信号及其对应表达的含义我们能够识别,是因为有人教过我们。我们早就知道信号产生之后要做什么,即便当前信号还没有产生。
- 能够识别信号 && 提前知道这个信号的处理方法 ——》我们称之为具备处理信号的能力
看如下的快递的例子:
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(打开快递,使用商品)2. 执行自定义动作(快递是零食,停下手头的游戏先干饭)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
技术应用角度的信号
信号是给进程发送的,进程要具备处理信号的能力:
- 该能力一定是预先早就已经有了的
- 进程能够识别对应的信号
- 进程能够处理对应的信号
进程这种识别与处理信号的能力是程序员写的,OS提供的。对于进程来讲,即便是信号还没有产生,我们进程已经具有识别和处理这个信号的能力了。
前台进程 && 后台进程
前台进程:
- 是在终端中运行的命令,那么该终端就为进程的控制终端,一旦这个终端关闭,这个进程也随之消失。
我们编写以下程序并运行:
#include<iostream> #include<cstdio> #include<unistd.h> using namespace std; int main() { while (1) { printf("hello world\n"); sleep(1); } return 0; }
此进程在当前状态下就是前台进程。我们知道该程序的运行结果就是死循环地进行打印,而对于死循环来说,最好的方式就是使用Ctrl+C(前台进程适用)对其进行终止。
后台进程:
- 后台进程也叫守护进程(Daemon),是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。 不受终端控制,它不需要终端的交互;Linux的大多数服务器就是使用守护进程实现的。比如Web服务器的httpd等。
我们在./myproc命令后加上 & 符号,就可以把此命令放到后台执行,也就是切换至后台进程:
从图中可以看出在命令后面加了&后,会发现按ctrl+c无法终止。但是按命令是有效果的,因为这是后台进程,不会影响命令。如果想要查看该后台进程,可以按jobs,看到当前后台进程:
如果想要将后台进程变为前台进程,可以按fg [jobs中对应的号],这里对应的是1,就是fg 1。这时,按ctrl+c就可以结束进程了。
下面我们去掉打印,并多启动几个后台进程
#include<iostream> #include<unistd.h> #include<cstdio> using namespace std; int main() { while (1) { // printf("hello world\n"); sleep(1); } return 0; }
我们可以通过jobs命令查看所有创建的后台进程,这里把3号进程转换为前台进程(fg 3),并ctrl + c结束进程:
如果现在把5号进程提到前台(fg 5),但是我如果后悔了,我们就可以先按ctrl + z键,此时会发现5号进程处于Stopped暂停状态,再输入bg 5命令,即可将5号进程重回running状态,并且是后台进程:
现在来总结前台、后台进程的相关指令:
指令 作用 &
这个用在一个命令的最后,可以把这个命令放到后台执行 jobs 查看当前有多少在后台运行的命令 fg 将后台中的命令调至前台继续运行 ctrl+z 可以将一个正在前台执行的命令放到后台,并且暂停 bg 将一个在后台暂停的命令,变成继续执行 ctrl+c 将产生的信号发给前台进程,并退出该进程 注意:
- Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
- Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
- 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方,任何时刻,都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
问:为什么使用Ctrl+C后,该进程就终止了?
用户按下Ctrl-C,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。
信号概念
信号是进程之间事件异步通知的一种方式,属于软中断。
用kill -l命令察看系统定义的信号列表
我们可以使用kill -l命令查看Linux当中的信号列表:
- Linux系统中, 一共有62个信号。其中1~31号信号是普通信号,34~64号信号是实时信号。
- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define SIGINT 2
- 编号34以上的是实时信号,我们且看编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明:man 7 signal
信号处理的方式
看如下的场景:
- 假设你正在家打游戏,此时你的朋友张三打电话喊你出去玩但是你现在在忙,还得等一会,很快,你的外卖到了,同样的,你表示再忙,让外卖小哥等一会,当你忙完后,取了外卖,随后去找张三玩。
在上述这个场景中,外卖小哥到来、你的朋友给你打电话,这都叫信号到来,但你不是立马开门的,也不是立马去找张三的,因为你在忙更重要的事情,没空。
- 类比信号,因为信号产生是异步的,所以当信号产生的时候,对应的进程可能正在做更重要的事情,我们的进程可以暂时不处理这个信号。进程暂时不处理这个信号,不代表这个信号不会被处理,OS是一定会记住这个信号的。
问1:当该处理信号时,该如何处理呢?
- 当你忙好手头的事情,你去开门取了外卖,默认情况下你拆外卖,然后吃外卖,这就是对信号处理的默认动作。
- 当你开门取了外卖后,突然又想开黑一把,此时你并没有处理,吃这个外卖,对应的就是对信号的忽视。
- 当你开门取了外卖后,你同样是没有吃外卖,但是你把这个外卖给了你的弟弟,你处理了此外卖,不过是交给别人处理的。这对应的就是对信号的自定义操作。
总结:信号的捕捉,递达处理动作(信号的处理)三种方式:
- 默认动作
- 忽视
- 自定义动作
问2:如何理解上述信号产生是异步的?
- 假设一个班级正在上课,有一位同学拉肚子去上厕所了,老师表示让我们一起等这位同学回来再继续上课,这就是同步的表现。
- 同样的,一个班级正在上课,有一位同学拉肚子去上厕所了,老师表示我们继续上课,落下来的课程自己想办法补吧。这就是异步的表现。
信号的产生在进程的任何时段都能产生,信号如何产生,产生的过程是什么,进程都毫不关心,你跑你的,我走我的,相互独立,这就是异步。
2、信号产生前
用户层产生信号的方式
上文说到信号是会被进程记住的(有没有产生 + 什么信号产生)。实际上,当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块PCB中的。我们都知道进程控制块本质就是一个结构体变量,而对于信号来说我们主要就是记录某种信号是否产生。因此,我们可以用一个32位的位图来记录信号是否产生:
task_struct { uint32_t sig; //位图 };
- 其中,比特位在位图中的位置代表了什么信号产生,比特位在位图中的内容代表是否收到对应的信号,如上第5个比特位是1就表明收到了5号信号。
task_struc是内核的数据结构,所以只有OS操作系统有直接修改这个task_struct的数据位图的权利。因为OS是进程的管理者,进程的所有的属性的获取和设置只能由OS来操作。所以无论信号怎么产生,最终一定只能是OS来进行信号的设置。
3、产生信号
问:信号是如何产生的?
- 一个进程收到信号,本质就是该进程内的信号位图被修改了,也就是该进程的数据被修改了,而只有操作系统才有资格修改进程的数据,因为操作系统是进程的管理者。也就是说,信号的产生本质上就是操作系统直接去修改目标进程的task_struct中的信号位图。
注意: 信号只能由操作系统发送,但信号发送的方式有多种。
3.1、通过终端按键产生信号
当面对下面的死循环程序时,我们都知道可以按ctrl-c来终止该进程。
#include<iostream> #include<unistd.h> #include<cstdio> using namespace std; int main() { while (1) { printf("hello world\n"); sleep(1); } return 0; }
但实际上除了按ctrl-c之外,按ctrl-\也可以终止该进程:
按Ctrl+C实际上是向进程发送2号信号SIGINT,而按Ctrl+\实际上是向进程发送3号信号SIGQUIT。在具体展开说明之前,来看下信号的接口函数:
#include <signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);
- sighandler_t:返回值为void,参数为int的一个函数指针
- signum:对哪个信号设置捕捉信号
- handler:是一个函数指针,这个函数允许用户自定义对信号的处理动作
下面用如下的程序验证键盘是可以产生信号的:
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; void handler(int signo) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; } int main() { //SIGINT:2号信号 //这里不是调用handler方法,这里只是设置了一个回调,让SIGINT(2)产生的时候,该方法才会被调用 //如果不产生SIGINT(2),该方法不会被调用! signal(SIGINT, handler); sleep(3); cout << "进程已经设置完了" << endl; sleep(3); while (true) { cout << "我是一个正在运行中的进程:" << getpid() << endl; sleep(1); } return 0; }
这里的SIGINT就是2号信号:
结果为:
- 上述的signal函数不是调用handler方法,这里只是设置了一个回调,让SIGINT(2)产生的时候,该方法才会被调用,如果不产生SIGINT(2),该方法不会被调用!
- ctrl + c本质就是给前台进程发送2号信号给目标进程,上述结果中我们每按一次ctrl-c,就获得一个2号信号,目标进程默认对2号信号的处理,是终止自己,但是现在我们更改了对2号信号的处理,这就是我们设置了用户自定义处理动作。为了终止该进程,我们使用了ctrl-\来终止该进程。
- 上述测试结果也足矣说明键盘是可以产生信号的!
下面再测试下3号进程SIGQUIT:
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; void handler(int signo) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; } int main() { //SIGINT:2号信号 //这里不是调用handler方法,这里只是设置了一个回调,让SIGINT(2)或3号信号产生的时候,该方法才会被调用 //如果不产生SIGINT(2)或3号信号,该方法不会被调用! signal(SIGINT, handler); signal(3, handler); sleep(3); cout << "进程已经设置完了" << endl; sleep(3); while (true) { cout << "我是一个正在运行中的进程:" << getpid() << endl; sleep(1); } return 0; }
此时会发现无论是按ctrl-c还是ctrl-\都无法结束该进程,原因是都会去调用自定义处理动作。为了杀掉此进程,此时我们可以先使用如下的命令查看进程的pid信息:
[xzy@ecs-333953 date27]$ ps ajx | grep myproc
随后直接kill -9 pid即可:
问:根据上述现象,如若我把9号信号也给自定义捕捉了,那么该进程是否是一个刀枪不入的进程了?谁也杀不死?
- 答案肯定不是的,看如下的测试:
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; void handler(int signo) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; } int main() { for (int sig = 1; sig <= 31; sig++) { signal(sig, handler);//设置所有的信号的处理动作,都是自定义动作 } sleep(3); cout << "进程已经设置完了" << endl; sleep(3); while (true) { cout << "我是一个正在运行中的进程:" << getpid() << endl; sleep(1); } return 0; }
如上我使用了一个for循环,设置所有的信号的处理动作,都是自定义动作,是否就能让该进程刀枪不入谁也杀不死呢?
- 结果显然易见,即使把9号信号也给自定义处理了,也并不影响kill -9杀死进程的作用,这其实就是OS决定的,9号信号永远都不能自定义设置捕捉动作。所以9号信号几乎可以杀死所有进程,除了先前讲到的d状态的进程。
总结用户层产生信号的方式:键盘产生
- 注意这个是键盘产生的信号,不是键盘发送的信号,是OS发送的信号。
问:OS是如何发送信号的?
- OS能找到每个进程的take_struct,也能找到当前显示器上前台进程的take_struct,每一个进程的take_struct内部都有一个位图,OS在拿到了对应的信号后,将这个对应的位置由0设为1,OS就完成了信号的发送(OS发送信号,也可以说成是写入信号)
3.2、核心转储core dump
先前我们在学习进程等待的时候,说到过进程等待时,父进程在必要时需要获取子进程的退出状态,我们需要用到status参数,调用waitpid函数完成:
pid_t waitpid(pid_t pid, int *status, int options);
waitpid函数的第二个参数status是一个输出型参数,用于获取子进程的退出状态。status是一个整型变量,但status不能简单的当作整型来看待,status的不同比特位所代表的信息不同,具体细节如下(只关注status低16位比特位):
其中次低8位代表的是进程退出时的退出状态(进程退出码),低7位代表的是进程是否收到信号(异常终止),其中有一个标记位(第8位)叫核心转储core dump。在具体展开讨论前,先来写如下的一个异常代码:
#include <iostream> #include <signal.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <cstdlib> using namespace std; int main() { pid_t id = fork(); if (id == 0) { // 子进程 int *p = nullptr; *p = 1000; // 野指针问题 exit(1); } // 父进程 int status = 0; waitpid(id, &status, 0); printf("exitcode: %d, signo: %d, core dump flag: %d\n", \ (status >> 8) & 0xFF, status & 0x7F, (status >> 7) & 0x1); return 0; }
通过我们实际的测试得知,core dump竟然是0,记住这个现象,在解释此现象之前,先来解释下core dump:
- 我们使用如下的指令来查看更详细的信号手册:
[xzy@ecs-333953 date27]$ man 7 signal
Term和Core都代表着终止进程,但是Core在终止进程的时候会进行一个动作:核心存储(core dump)。
问:什么是核心存储core dump?
- 在云服务器中,核心转储core dump是默认被关掉的,我们可以使用 ulimit -a 命令查看当前资源限制的设定:
其中,第一行显示的core文件大小为0,即表示核心转储是被关闭的。所以这也就是为什么我上面获得的core dump为0,因为我core文件默认是关闭的为0啊。但是我们可以通过 ulimit -c size 命令来设置core文件的大小:
core文件的大小设置完毕后,就相当于将核心转储的功能打开了。此时我们再来运行刚刚的程序,结果如下:
此时会发现core dump的结果是1,且用ll指令查看更多信息时会发现多了一个core.22218文件(后面的数字代表此进程pid),并且用vim打开此文件会发现是乱码。我们可以使用如下指令查看此core文件的大小(KB):
总结:
- 当我们一个进程在异常退出时,如果收到了某些信号, 某些异常是系统的,为了便于调试,它会在你异常退出时,触发core dump核心转储机制,像一些内部的错误,进程在异常终止后,core dump标记位给你置1,并且会在当前路径下给你生成一个大文件,上面的core.22218文件就是的(后面的数字代表此进程pid),像一些外部的错误则跟我没关系了
- core dump会把进程在运行中,对应的异常上下文数据,core dump核心转储到磁盘上,方便调试,并且会把当前退出的status的core dump标志位给置为1。
问:core dump有何用呢?
看如下的代码测试:
#include <iostream> using namespace std; int main() { cout << "begin ..." << endl; int *p = nullptr; *p = 1000; cout << "end ..." << endl; return 0; }
很明显,上述代码发生了野指针错误,测试如下:
此时我们在当前目录下可以看到核心转储时生成的core文件:
现在我们使用gdb来调试此可执行程序,然后直接使用 core-file core文件 命令加载core文件,即可判断该程序在第7行发生了段错误,并在进曾终止时收到了11号信号,且定位到了产生该错误的具体位置的代码:
注意:事后用调试器检查core文件以查清错误原因,这种调试方式叫做事后调试。如上就是core dump的好处(便于调试)
问:为什么core dump一般默认是关掉的?
- 虽然core dump的好处很明显(便于调试,直接定位错误),但是假象一下,如果有一天你的代码本身发生了错误,不是外部错误,万一有些解决策略就是把服务不断重启,那么就会出现一个问题,一运行就挂,每次重启就core dump一下,且赠送你一个几百kb左右大小的core文件,若重启了一晚上,那么你的磁盘全是core文件,磁盘上全是垃圾文件,那么OS就可能收到影响。若扩大到企业级那风险可就大了,即使你限制了core文件的大小,但这些垃圾文件总归是不好的。
3.3、调用系统函数想进程发送信号
kill函数
当我们要使用kill命令向一个进程发送信号时,我们可以用 kill -信号名 进程ID 的形式进行发送:
也可以用 kill -信号编号 进程ID 的形式进行发送:
实际上kill命令是通过调用kill函数实现的,kill函数可以给指定的进程发送指定的信号,kill函数的函数原型如下:
#include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig);
kill函数用于向进程ID为pid的进程发送sig号信号,如果信号发送成功,则返回0,否则返回-1。我们可以用kill函数模拟实现一个kill命令,实现逻辑如下:
- mykill.cc代码:
#include<iostream> #include<cstdlib> #include<cstring> #include<signal.h> #include<unistd.h> #include<sys/types.h> using namespace std; //我写一个kill命令 //mykill 9 1234 static void Usage(const string& proc) { cerr << "Usage:\n\t" << proc << "signal pid" << endl; } int main(int argc, char* argv[]) { if (argc != 3) { Usage(argv[0]); exit(1); } if (kill(static_cast<pid_t>(atoi(argv[2])), atoi(argv[1])) == -1) { //调用失败 cerr << "Kill: " << strerror(errno) << endl; exit(2); } return 0; }
- myproc.cc代码:
#include<iostream> #include<unistd.h> using namespace std; int main() { while (1) { sleep(1); cout << "我是一个进程: " << getpid() << endl; } return 0; }
- makefile代码:
.PHONY:all all:myproc mykill myproc:myproc.cc g++ -o $@ $^ -std=c++11 mykill:mykill.cc g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f mykill myproc
结果如下:
通过运行我们的进程,并且输入对应的参数,就可以实现kill指令的作用。
raise函数
raise函数可以给当前进程发送指定信号,即自己给自己发送信号,raise函数的函数原型如下:
#include <signal.h> int raise(int sig);
raise函数用于给当前进程发送sig号信号,如果信号发送成功,则返回0,否则返回一个非零值。
- 例如,下面的代码中用raise函数每隔一秒向自己发送一个2号信号:
#include<iostream> #include<cstdlib> #include<cstring> #include<signal.h> #include<unistd.h> #include<sys/types.h> using namespace std; void handler(int signo) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; } int main() { signal(2, handler);//没有调用handler方法,仅仅是注册 while (1) { sleep(1); raise(2); } return 0; }
上述使用raise函数每隔1s对2号信号进行自定义动作的捕捉,所以我按ctrl-c对此死循环进程是没有用的,但是按ctrl-\是可以的,因为其对应的是3号信号,没有被捕捉,它也能终止进程。
abort
abort函数可以给当前进程发送SIGABRT信号(6号信号),使得当前进程异常终止,abort函数的函数原型如下:
#include <stdlib.h> void abort(void);
abort函数是一个无参数无返回值的函数。
- 例如,下列代码当中每隔一秒向当前进程发送一个SIGABRT信号。
#include<iostream> #include<cstdlib> #include<cstring> #include<signal.h> #include<unistd.h> #include<sys/types.h> using namespace std; void handler(int signo) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; } int main() { signal(SIGABRT, handler);//对6号信号做捕捉 while (1) { sleep(1); abort();//等同于exit() } return 0; }
与之前不同的是,虽然我们对SIGABRT6号信号做了捕捉,并且在收到SIGABRT信号后执行了我们给出的自定义方法,但是当前进程依然是异常终止了。这就体现了和9号信号的区别,9号信号不能被捕捉,6号虽然可以被捕捉,但是捕捉后进程依旧会退出!!!
注意:
- abort函数的作用是异常终止进程,exit函数的作用是正常终止进程,而abort本质是通过向当前进程发送SIGABRT信号而终止进程的,因此使用exit函数终止进程可能会失败,但使用abort函数终止进程总是成功的。
3.4、由软件条件产生信号
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发送SIGALRM14号信号,该信号的默认处理动作是终止当前进程,alarm函数的函数原型如下:
#include <unistd.h> unsigned int alarm(unsigned int seconds);
alarm函数的返回值:
- 若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置
- 如果调用alarm函数前,进程没有设置闹钟,则返回值为0
例如,我们可以用下面的代码,测试自己的云服务器一秒时间内可以将一个变量累加到多大:
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; int cnt = 0; int main() { alarm(1); //统计一下我们的进程在1s钟,cnt++多少次 while (1) { printf("hello : %d\n", cnt++); } return 0; }
运行代码后,可以发现我当前的云服务器在一秒内可以将一个变量累加到3万多次左右。
但实际上我当前的云服务器在一秒内可以执行的累加次数远大于两万,那为什么上述代码运行结果比实际结果要小呢?
- 主要原因有两个,首先,由于我们每进行一次累加就进行了一次打印操作,而与外设之间的IO操作所需的时间要比累加操作的时间更长,其次,由于我当前使用的是云服务器,因此在累加操作后还需要将累加结果通过网络传输将服务器上的数据发送过来,因此最终显示的结果要比实际一秒内可累加的次数小得多。
为了尽可能避免上述问题,我们可以先让cnt变量一直执行累加操作,对SIGALRM14号信号进行捕捉,在1s后进程收到SIGALRM信号后再打印累加后的数据:
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; int cnt = 0; void handler(int signo) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << "cnt: " << cnt << endl; exit(1); } int main() { signal(SIGALRM, handler); alarm(1); while (1) { cnt++; // printf("hello : %d\n", cnt++); } return 0; }
上述代码我对SIGALRM14号信号进行捕捉,在1s后(期间cnt变量不断++),自动调用handler回调方法,输出cnt累加后的数据,随后将进程exit终止。
此时可以看到,cnt变量在一秒内被累加的次数变成了4亿多,由此也证明了,与计算机单纯的计算相比较,计算机与外设进行IO时的速度是非常慢的。
3.5、由硬件异常产生信号
我们都遇到过程序由于异常而崩溃的情况,诸如除0错误,非法访问野指针,越界错误等:
所谓的崩溃本质是什么呢?看如下的测试:
#include <iostream> #include <signal.h> #include <cstdlib> using namespace std; int cnt = 0; void handler(int signo) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; exit(1); } int main() { for (int sig = 1; sig <= 31; sig++) { signal(sig, handler); } // int b = 10; // b /= 0; // int *p = nullptr; // *p = 100; int a[10]; a[10000] = 100; return 0; }
依次测试下面注释的内容:
根据测试结果,我们可以看到,第一个除0错误是8号信号,第二个野指针错误和第三个越界错误都是11号信号,其对应的具体如下:
综上,进程崩溃的本质就是进程在运行过程中收到了操作系统发来的信号而被终止。那么操作系统是如何识别到一个进程触发了某种问题呢?下面展开讨论:
除零:
- 我们都知道cpu内部有一堆寄存器,当我们在做类似除0这类算数运算的时候,我们是先将这两个操作数分别放到cpu的寄存器中,然后进行算术运算并把结果写回寄存器当中。此外,cpu当中还有一组寄存器叫做状态寄存器,它是用来表征本次计算是否出现问题,来标记当前指令执行结果的各种状态信息,如有无进位,有无溢出等待。而OS是软硬件资源的管理者。如果本次计算出现了问题,那么状态寄存器当中特定的标记位会被置位。也就是说,当我们除0的时候,cpu内部的状态寄存器会被设置成为:有报错,浮点数越界。此时OS就会马上识别到当前cpu内部有报错啦(谁干的 && 是什么报错)
- 此时OS就将识别到的硬件错误包装成信号——》向目标进程发送信号——》目标进程在合适的时候处理信号——》默认终止进程。
越界 && 野指针:
- 根据我们先前的学习依然知道在语言层面使用的地址(指针),其实都是虚拟地址——》最后被转换成物理地址——》才能访问物理内存——》才能读取对应的数据和代码。如果虚拟地址有问题,地址转化的工作是由MMU内存管理单元(硬件)+ 页表(软件)做的。转化过程就会引起问题,表现在硬件MMU上,此时OS就发现硬件出现了问题(谁干的 && 是什么报错)
- 此时OS就将识别到的硬件错误包装成信号——》向目标进程发送信号——》目标进程在合适的时候处理信号——》默认终止进程
问:崩溃了,一定会导致进程终止吗?
不一定,之前我们的代码中是每次捕捉到信号后,就exit终止进程,现在把exit函数注释掉再来看看:
可以发现,虽然进程崩溃了,但是一直在运行,现在只有kill -9 pid才能杀死进程:
出现上述现象的原因很简单,原信号的默认动作是出现异常崩溃直接终止进程,但是现在我对其进行自定义捕捉,修改了默认动作,所以进程没有退出。如果我们不对其进行捕捉而采用默认动作,那么进程还是会退出的。
- 因此,出现了异常,进程是否被终止,并不是完全由OS决定的,是由用户来决定的。而C++的异常中,我们学到过try catch捕获异常,throw抛出异常,我们一般抛出异常后就直接调用exit,abort等函数终止进程,那么如果我们不退出会怎么样呢?看如下的代码:
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdlib> using namespace std; int cnt = 0; void handler(int signo) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; // exit(1); } int main() { try { throw 10; while (true) { int a = 10; a /= 0; } } catch (int &e) { cout << e << endl; cout << "oops, 我出异常啦!" << endl; sleep(4); // abort();//异常后终止进程 } while (1) { cout << "........" << endl; sleep(2); } return 0; }
- 通过测试,c++抛异常后,进程不会终止,需要我们手动对进程进行终止。
总结:C/C++程序会崩溃,是因为程序当中出现的各种错误最终一定会在硬件层面上有所表现,进而会被操作系统识别到,然后操作系统就会发送相应的信号将当前的进程终止。但是进程崩溃后不会终止,需要我们手动对进程进行终止。
4、阻塞信号
信号其它相关概念
- 递达:实际执行信号的处理动作称为信号递达(Delivery),也叫信号处理(执行默认动作,忽略,自定义捕捉)
- 未决:信号从产生到递达之间的状态,称为信号未决(Pending)
- 阻塞:进程可以选择阻塞 (Block )某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
信号在内核中的表示
信号在内核中的表示示意图如下:
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针数组(handler)表示处理动作。
- block:阻塞信号集,和pending都是位图,对应的比特位为1,就会拦截对应的信号去递达对应的方法,即使pending为1收到了信号也没用。
- pending:用来识别信号中对应信号的位置,若为1,就说明收到信号,为0,说明没收到信号。
- handler:用来处理信号,信号的编号就作为这个函数指针的数组下标,直接可以访问到对应的自定义的方法,或者系统默认的处理方法。
这三个应该先看pending位,如果pending为1,再去看block,如果block为0,再去看handler。
问:这里提到了阻塞信号,而我上面提到过忽略信号,阻塞和忽略有什么区别呢?
- 忽略信号:是处理信号的一种,只不过处理的方式是忽略,什么都不做,将pending位图由1置0
- 阻塞信号:不是处理信号,是拦截信号,不允许去处理信号
解释上图:
- 信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号的pendging位为1,说明收到2号信号。但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会在改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,但一旦产生SIGQUIT信号,该信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前,这种信号产生过多次,POSIX.1允许系统递达该信号一次或多次。Linux是这样实现的:普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,这里只讨论普通信号。
注意:
- 假设我把2号信号给阻塞了(block为1),且我收到了2号信号(pending为1),但现在我如果收到了多个2号信号呢?pending比特位只能记住1个,也就意味着未来只有一个2号信号会被递达,剩下的信号直接被丢弃掉。这也就是为什么在遇到死循环进程时,若我们连续按多次ctrl-c,它只会把这一个进程终止,原因就在于其它的信号直接被丢弃了。
sigset_t
根据信号在内核中的表示方法,每个信号只有一个bit的未决标志,非0即1,不会记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,在我当前的云服务器中,sigset_t类型的定义如下:(不同OS实现sigset_t的方案可能不同)
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int))) typedef struct { unsigned long int __val[_SIGSET_NWORDS]; } __sigset_t; typedef __sigset_t sigset_t;
sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。
- 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞
- 而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
block阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
信号操作函数
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置位,表示该信号集的有效信号包括系统支持的所有信号。
- sigaddset函数:在set所指向的信号集中添加某种有效信号。
- sigdelset函数:在set所指向的信号集中删除某种有效信号。
- sigemptyset、sigfillset、sigaddset、sigdelset函数都是成功返回0,出错返回-1。
- sigismember函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。
注意: 在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号处于确定的状态。
- 例如,我们可以按照如下方式使用这些函数:
#include <stdio.h> #include <signal.h> int main() { sigset_t s; //用户空间定义的变量 sigemptyset(&s); sigfillset(&s); sigaddset(&s, SIGINT); sigdelset(&s, SIGINT); sigismember(&s, SIGINT); return 0; }
注意:上述定义的sigset_t类型的变量s,与我们平常定义的变量一样都是在用户空间定义的变量,所以后面我们用信号集操作函数对变量s的修改只是对用户空间的变量s做了修改,并不会影响进程的任何行为。因此,我们还需要通过系统调用,才能将变量s的数据设置进操作系统。
sigprocmask
sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集),该函数的函数原型如下:
#include <signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数说明:
- 如果oset是非空指针,则读取进程当前的信号屏蔽字通过oset参数传出
- 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改
- 如果oset和set都是非空指针,则先将原来的信号屏蔽字被分到oset里,然后根据set和how参数更改信号屏蔽字
假设当前的信号屏蔽字为mask,下标说明了how参数的可选值及其含义:
选项 含义 SIG_BLOCK set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask = mask | set SIG_UNBLOCK set包含了我们希望从当前信号屏蔽字中接触阻塞的信号,相当于mask = mask & ~set SIG_SETMASK 设置当前信号屏蔽字位set所指向的值,相当于mask = set 返回值说明:
- sigprocmask函数调用成功返回0,出错返回-1。
注意: 如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask函数返回前,至少将其中一个信号递达。
sigpending
sigpending函数可以用于读取进程的未决信号集,该函数的函数原型如下:
#include <signal.h> int sigpending(sigset_t *set);
sigpending函数读取当前进程的未决信号集,并通过set参数传出。该函数调用成功返回0,出错返回-1。
来看如下的实验:不断的获取当前进程的pending信号集,以2号信号为例,如果我们直接获取2号信号,会因为递达的太快,我们还没等看到打印出来,就已经结束了,所以这里我们利用信号屏蔽字,把2号信号屏蔽,具体操作如下:
- 先用上述的函数将2号信号进行屏蔽(阻塞)
- 使用kill命令或组合按键向进程发送2号信号
- 此时2号信号会一直被阻塞,并一直处于pending(未决)状态
- 使用sigpending函数获取当前进程的pending信号集进行验证
#include <iostream> #include <signal.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <cstdlib> using namespace std; void handler(int signo) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; exit(1); } // 打印信号集 static void showPending(sigset_t *pendings) { for (int sig = 1; sig <= 31; sig++) { // 检测特定的信号在不在此pending集合里 if (sigismember(pendings, sig)) cout << "1"; else cout << "0"; } cout << endl; } int main() { // 3、屏蔽2号信号 sigset_t bsig, obsig; sigemptyset(&bsig); sigemptyset(&obsig); // 3.1、添加2号信号到信号屏蔽字中 sigaddset(&bsig, 2); // 3.2、设置用户及的信号屏蔽字到内核中,让当前进程屏蔽掉2号信号 sigprocmask(SIG_SETMASK, &bsig, &obsig); // 2、signal signal(2, handler); // 1、不断的获取当前进程的pending信号集 sigset_t pendings; while (true) { // 1.1、清空信号集 sigemptyset(&pendings); // 1.2、获取当前进程(谁调用,谁获取)的pending信号集 if (sigpending(&pendings) == 0) { // 1.3、打印一下当前进程的pending信号集 showPending(&pendings); } sleep(1); } return 0; }
根据测试结果得知:程序刚刚运行时,因为没有收到任何信号,所以此时该进程的pending表一直是0,当我们使用kill命令向该进程发送2号信号后,由于2号信号是阻塞的,因此2号信号一直处于未决状态,所以我们看到pending表中的第二个数字一直是1。
问:如果我们把信号全部屏蔽了会怎么样呢?
#include <iostream> #include <signal.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <cstdlib> using namespace std; void handler(int signo) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; exit(1); } // 打印信号集 static void showPending(sigset_t *pendings) { for (int sig = 1; sig <= 31; sig++) { // 检测特定的信号在不在此pending集合里 if (sigismember(pendings, sig)) cout << "1 "; else cout << "0 "; } cout << endl; } int main() { // 3、屏蔽所有的信号 sigset_t bsig, obsig; sigemptyset(&bsig); sigemptyset(&obsig); // sigfillset();//把信号全部置位 for (int sig = 1; sig <= 31; sig++) { // 3.1、添加sig号信号到信号屏蔽字中 sigaddset(&bsig, sig); } // 3.2、设置用户及的信号屏蔽字到内核中,让当前进程屏蔽掉sig号信号 sigprocmask(SIG_SETMASK, &bsig, &obsig); // 2、signal signal(2, handler); // 1、不断的获取当前进程的pending信号集 sigset_t pendings; while (true) { // 1.1、清空信号集 sigemptyset(&pendings); // 1.2、获取当前进程(谁调用,谁获取)的pending信号集 if (sigpending(&pendings) == 0) { // 1.3、打印一下当前进程的pending信号集 showPending(&pendings); } sleep(1); } return 0; }
上述我们屏蔽了1~31号的信号,测试结果如下:
- 从1开始屏蔽,一直到8都可以成功,但是到9号信号的时候,就会直接把进程killed,而不会被屏蔽掉。
如上我确实看到了一些信号被屏蔽了,没有被递达,一直处于pending状态,那如果我现在想要恢复呢(pending位图由1恢复至0)。我们把设置信号的处理动作全部统一成一个方法,并且定义一个计数器cnt变量,让它printf跑上个20s钟,在这20s内,相当于信号都是被屏蔽的,20s后,我们把指定的2号信号解除屏蔽。代码如下:
#include <iostream> #include <signal.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <cstdlib> #include <cstring> #include <string> using namespace std; int cnt = 0; void handler(int signo) { cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl; // exit(1); } // 打印信号集 static void showPending(sigset_t *pendings) { for (int sig = 1; sig <= 31; sig++) { // 检测特定的信号在不在此pending集合里 if (sigismember(pendings, sig)) cout << "1 "; else cout << "0 "; } cout << endl; } int main() { cout << "pid: " << getpid() << endl; // 3、屏蔽所有的信号 sigset_t bsig, obsig; sigemptyset(&bsig); sigemptyset(&obsig); // sigfillset();//把信号全部置位 for (int sig = 1; sig <= 31; sig++) { // 3.1、添加sig号信号到信号屏蔽字中 sigaddset(&bsig, sig); // 2、signal signal(sig, handler); } // 3.2、设置用户及的信号屏蔽字到内核中,让当前进程屏蔽掉sig号信号 sigprocmask(SIG_SETMASK, &bsig, &obsig); // 1、不断的获取当前进程的pending信号集 sigset_t pendings; while (true) { // 1.1、清空信号集 sigemptyset(&pendings); // 1.2、获取当前进程(谁调用,谁获取)的pending信号集 if (sigpending(&pendings) == 0) { // 1.3、打印一下当前进程的pending信号集 showPending(&pendings); } sleep(1); cnt++; if (cnt == 20) { cout << "解除对2号信号的block..." << endl; sigset_t sigs; sigemptyset(&sigs); sigaddset(&sigs, 2); sigprocmask(SIG_UNBLOCK, &sigs, nullptr); } } return 0; }
此时就可以看到,进程收到2号信号后,该信号在一段时间内处于未决状态,当解除2号信号的屏蔽后,2号信号就会立即递达,执行我们所给的自定义动作,此时对应的pending位也被置为了0。
注意:
- 在解除2号信号后,2号信号的自定义动作是在打印“恢复信号屏蔽字”之前执行的。因为如果调用sigprocmask解除对当前若干个未决信号的阻塞,则在sigprocmask函数返回前,至少将其中一个信号递达。
5、捕捉信号
内核态 && 用户态
进程处理信号,不是立即处理的时候,是在合适的时候处理的。那么这个合适的时候是什么时候呢?
- 当当前进程从内核态,切换到用户态的时候,进行信号的检测与处理!
问1:何为内核态?何为用户态?
- 在解答此问题前,先来理解下内核空间和用户空间:
首先,每一个进程都有自己的进程地址空间,该进程地址空间由内核空间(3G~4G)和用户空间(0~3G)组成:
- 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
- 内核空间存储的实际上是OS代码和数据,通过内核级页表与物理内存之间建立映射关系。
内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是OS的代码和数据,所有进程看到的都是一样的内容。用户级页表(0~3G)每一个进程都有一份,而且大家的用户级页表都是不一样的。内核级页表(3G~4G)所有进程共享,只有一份,前提是你有权利访问。一个进程是如此,多个进程亦然如此:
综上,无论进程怎么切换,我们都可以找到内核的代码和数据,前提是你只要有权利访问。
问2:如何理解进程切换?
- 在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。
- 执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。
回到一开始的问题:何为内核态与用户态?
- 内核态通常用来执行操作系统的代码,是一种权限非常高的状态。
- 用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态。
进程收到信号之后,并不是立即处理信号,而是在合适的时候,这里所说的合适的时候实际上就是指,从内核态切换回用户态的时候。
问3:当前进程如何具备权利,访问这个内核页表,乃至访问内核数据呢?
要进行身份切换:
- 进程如果是用户态的——只能访问用户级页表
- 进程如果是内核态的——就可以访问内核级和用户级的页表
我怎么知道自己是用户态还是内核态呢?
- CPU内部有对应的状态寄存器CR3,用比特位标识当前进程的状态。0为内核态,3为用户态。
那么用户态什么时候能转到内核态呢?
- 需要进行系统调用时。
- 当前进程的时间片到了,导致进程切换。
- 产生异常、中断、陷阱等。
与之对应的,从内核态切换为用户态有如下几种情况:
- 系统调用返回时。
- 进程切换完毕。
- 异常、中断、陷阱等处理完毕。
其中,由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核的时,本质上是因为我们需要执行操作系统的代码,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态。
内核态和用户态的区别是什么呢?
- 内核态:可以访问所有的代码和数据(不是意味着它一定要访问所有的)—— 具备更高权限
- 用户态:只能访问自己的
我们的程序,会无数次直接或间接的访问系统软硬件资源(管理者是OS),本质上,你并没有自己去操作这些软硬件资源,而是必须通过OS -> 无数次的陷入内核(1、切换身份;2、切换页表)-> 调用内核的代码 -> 完成访问的动作 -> 结果返回给用户(1、切换身份;2、切换页表)-> 得到结果。
- 即使是像while(1)这样的代码也是会进行内核态和用户态 切换的。因为它也有自己的时间片 -> 时间片到了的时候 -> 切换至内核态,更换内核级页表 -> 保护上下文,执行调度算法 -> 选择了新的进程 -> 恢复新进程的上下文 -> 切换至用户态,更换成用户级页表 -> cpu执行的就是新进程的代码!
内核如何实现信号的捕捉
- 进程的信号在被合适的时候进行处理 —— 从内核态切换到用户态的时候 —— 先检测信号,如果有必要再处理
看如下的图:
- 当cpu在执行你的代码时,一定会因为某些原因由用户态进入内核态(如上的open调用),执行完此代码后,理论上应该直接返回,但是现在是直接去进程PCB里查看其信号列表(pending & block), 如果pending和block均为0,则没有信号需要处理直接返回;
- 若pending为1,而block为0,且handler为自定义方法,此时OS就会从内核态切换到用户态(注意:这里一定要切换,虽然内核态可以完成用户态的操作,但是如果用户写的是一段恶意代码,那么因为内核态的权限过大,无论什么代码都会执行,就会导致OS受到恶意攻击,而切换成用户态就可以因为权限小而不会去执行该代码),以用户态的身份执行此自定义handler方法,执行完自定义方法后不能直接返回给我的代码,而是返回到先前在内核检测信号的位置,然后通过特定的系统调用再返回给我的代码。
总结上述过程:
- 当我们在执行主控制流程的时候,可能因为某些情况而陷入内核,当内核处理完毕准备返回用户态时,就需要进行信号pending的检查。(此时仍处于内核态,有权力查看当前进程的pending位图)在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理。如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
- 但如果待处理信号是自定义捕捉的,即该信号的处理动作是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作,执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。
上述信号捕捉的过程较为复杂,我们可以借助下图来帮助我们记忆:(∞)
其中,该图形与直线有几个交点就代表在这期间有几次状态切换,而箭头的方向就代表着此次状态切换的方向,图形中间的圆点就代表着进行信号检测。
sigaction
捕捉信号除了用前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉(设置对特定信号的特定处理的动作),sigaction函数的函数原型如下:
#include <signal.h> int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数说明:
- signum:对哪个信号实施信号自定义捕捉
- act:若act指针非空,则根据act修改该信号的处理动作
- oldact:若oldact指针非空,则通过oldact传出该信号原来的处理动作
其中,参数act和oldact都是结构体指针变量,该结构体的定义如下:
struct sigaction { void(*sa_handler)(int); void(*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; void(*sa_restorer)(void); };
结构体的第一个成员sa_handler:
- 将sa_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
- 将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
- 将sa_handler赋值为一个函数指针,表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。
注意:所注册的信号处理函数的返回值为void,参数为int,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然这是一个回调函数,不是被main函数调用,而是被系统所调用。
结构体的第二个成员sa_sigaction:
- sa_sigation是实时信号的处理函数。
结构体的第三个成员sa_mask:
- 当某个信号的处理函数被调用,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
- 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。
结构体的第四个成员sa_flags:
- sa_flags字段包含一些选项,这里直接将sa_flags设置为0即可。
结构体的第五个成员sa_restorer:
- 该参数没有使用
示例:(下面我们用sigaction函数对2号信号进行了捕捉,对2号信号的处理动作依次进行测试)
- 给sa_handler赋值为函数指针,表示自定义捕捉信号:
- 给sa_handler复制SIG_INT,表示忽略动作:
- 给sa_handler赋值为SIG_DFL,表示采取默认动作:
根据我们上面对sa_mask的了解,得知当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,这里来测试下:
#include <iostream> #include <signal.h> #include <unistd.h> using namespace std; void handler(int signo) { cout << "获取到一个信号,信号的编号是: " << signo << endl; sigset_t pending; // 增加handler处理的时间,永远都会处理2号信号 while (true) { cout << "." << endl; sigpending(&pending); for (int i = 1; i <= 31; i++) { if (sigismember(&pending, i)) cout << "1 "; else cout << "0 "; } cout << endl; sleep(1); } } int main() { struct sigaction act, oact; act.sa_handler = handler; act.sa_flags = 0; sigemptyset(&act.sa_mask); sigaction(2, &act, &oact); while (true) { cout << "main running" << endl; sleep(1); } return 0; }
这里虽然pending中有了,但是被屏蔽了,无法发送信号,现在再把3号信号加进去:
#include <iostream> #include <signal.h> #include <unistd.h> using namespace std; void handler(int signo) { cout << "获取到一个信号,信号的编号是: " << signo << endl; sigset_t pending; // 增加handler处理的时间,永远都会处理2号信号 while (true) { cout << "." << endl; sigpending(&pending); for (int i = 1; i <= 31; i++) { if (sigismember(&pending, i)) cout << "1 "; else cout << "0 "; } cout << endl; sleep(1); } } int main() { struct sigaction act, oact; act.sa_handler = handler; act.sa_flags = 0; sigemptyset(&act.sa_mask); sigaddset(&act.sa_mask, 3); sigaction(2, &act, &oact); while (true) { cout << "main running" << endl; sleep(1); } return 0; }
- 可以看到此时ctrl-\也因为被屏蔽而无法退出了。我们可以直接使用killall mysignal命令来终止此进程。
以后我们可能会对很多的信号做捕捉处理,而信号太多了,我们可以使用如下的方法来完成:
#include <iostream> #include <signal.h> #include <unistd.h> using namespace std; void Handler2() { cout << "hello 2" << endl; } void Handler3() { cout << "hello 3" << endl; } void Handler4() { cout << "hello 4" << endl; } void Handler5() { cout << "hello 5" << endl; } void Handler(int signo) { switch(signo) { case 2: Handler2(); break; case 3: Handler3(); break; case 4: Handler4(); break; case 5: Handler5(); break; default: break; } } int main() { signal(2, Handler); signal(3, Handler); signal(4, Handler); signal(5, Handler); while (1) { sleep(1); } return 0; }
6、可重入函数
先前我们学习链表的时候,都清楚链表头插的过程:(如下带哨兵位头节点的单链表)
下面主函数中调用insert函数向链表中插入节点node1,此时某信号处理函数也调用了insert函数向链表中插入节点node2,乍一看好像没什么问题:
下面我们来分析一下,对于下面这个链表:
1、首先,main函数中调用了insert函数,想将结点node1插入链表,但插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到sighandler函数:
2、而sighandler函数中也调用了insert函数,将结点node2插入到了链表中,插入操作完成第一步后的情况如下:
3、当结点node2插入的两步操作都做完之后从sighandler返回内核态,此时链表的布局如下:
4、再次回到用户态就从main函数调用的insert函数中继续往下执行,即继续进行结点node1的插入操作:
最终结果是,main函数和sighandler函数先后向链表中插入了两个结点,但最后只有node1结点真正插入到了链表中,而node2结点就再也找不到了,造成了内存泄漏。
- 总览过程如下:
- 像上例这样,insert函数被不同的控制流调用(main函数和sighandler函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立的控制流程),有可能在第一次调用还没返回时就再次进入该函数,我们将这种现象称之为重入。
- 而insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数我们称之为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称之为可重入(Reentrant)函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标志I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
7、volatile
volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。
- 在下面的代码中,我们对2号信号进行了捕捉,当该进程收到2号信号时会将全局变量flags由0置1。也就是说,在进程收到2号信号之前,该进程会一直处于死循环状态,直到收到2号信号时将flags置1才能够正常退出。
#include<stdio.h> #include<signal.h> int flags = 0; void handler(int signo) { printf("更改flags:0->1\n"); flags = 1; } int main() { signal(2, handler); while (!flags); printf("进程正常退出的!\n"); return 0; }
这里的结果和我i们的预期是相同的,因为发送了2号信号,就将flags由0置1了。但是这时在gcc编译器中,如果在别的编译器(高优化级别的)中就不一定是这种效果了。
- 这里我们更改一下编译器的编译层度(让编译器优化级别增高):
此时我们增加了编译器的优化级别,flags就很可能会被设置进寄存器里头,从此while循环检测的时候,其只从寄存器中读取数据,但是我们后续修改的flags是内存中的,不是寄存器中的,所以寄存器中的flags恒为0,此进程就会一直陷入死循环。
结果如下:
面对这种情况(编译器把flags优化到寄存器),我们就可以使用volatile关键字对flags变量进行修饰,告诉编译器,对flags变量的任何操作都必须真实的在内存中进行(不准对flags做任何优化),即保持了内存的可见性:
#include<stdio.h> #include<signal.h> volatile int flags = 0; void handler(int signo) { printf("更改flags:0->1\n"); flags = 1; } int main() { signal(2, handler); while (!flags); printf("进程正常退出的!\n"); return 0; }
此时就算我们编译代码时携带
-O2
选项,当进程收到2号信号将内存中的flags变量置1时,main函数执行流也能够检测到内存中flags变量的变化,进而跳出死循环正常退出。
8、SIGCHLD信号
子进程退出的时候,不是默默的退出,会自动给父进程发送SIGCHLD信号:
下面我们来验证一下:
- 让父进程对SIGCHLD信号的自定义捕捉,父子进程创建后把子进程杀掉,若杀掉后看到了父进程的自定义捕捉方法,则说明父进程收到了子进程发出的SIGCHLD信号,代码如下:
#include <iostream> #include <signal.h> #include <unistd.h> using namespace std; void handler(int signo) { cout << "子进程退出啦,我确实收到了信号:" << signo << " 我是:" << getpid() << endl; } int main() { signal(SIGCHLD, handler); pid_t id = fork(); if (id == 0) { //child while (true) { cout << "我是子进程:" << getpid() << endl; sleep(1); } exit(0); } //parent while (true) { cout << "我是父进程:" << getpid() << endl; sleep(1); } return 0; }
可以看到,子进程被杀掉之后,确实向父进程发送了SIGCHLD17号信号。其实,不只是子进程退出的时候会发送SIGCHLD信号,暂停和唤醒都会发送:
这里的 kill -19就是暂停,kill -18就是运行,都发送了SIGCHLD信号。
问:子进程给父进程发送SIGCHLD信号有什么用?
- 为了避免出现僵尸进程,父进程需要使用wait或waitpid函数等待子进程结束,父进程可以阻塞等待子进程结束,也可以非阻塞地查询的是否有子进程结束等待清理,即轮询的方式。采用第一种方式,父进程阻塞就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
之前我们所写的父子进程的进程等待,父进程都是要自己主动等待的(效率低):
#include <iostream> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <cstdlib> using namespace std; int main() { pid_t id = fork(); if (id == 0) { //子进程 int cnt = 10; while (cnt) { cout << "我是子进程, pid: " << getpid() << "当前的cnt: " << cnt-- << endl; sleep(1); } cout << "子进程退出, 进入僵尸状态" << endl; exit(0); } //父进程 if (waitpid(id, nullptr, 0) > 0) { cout << "父进程等待子进程成功" << endl; } return 0; }
我们使用如下监控脚本来帮助我们观察:
[xzy@ecs-333953 date29]$ while :; do ps axj | head -1 && ps axj | grep mytest; sleep 1; done
这里的父进程是一直在主动等待子进程退出的,那么我们如何才能让父进程在等待子进程的时候做自己的事情呢?
- 前面得知子进程退出的时候会给父进程发送SIGCHLD信号,那么我们就自定义捕捉此信号,直接让父进程干自己的事情,当子进程退出时会给我父进程发送信号,随后执行自定义捕捉动作。
#include <iostream> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <signal.h> #include <cassert> #include <cstdlib> using namespace std; void FreeChld(int signo) { assert(signo == SIGCHLD); pid_t id = waitpid(-1, nullptr, 0); if (id > 0) { cout << "父进程等待成功,chld pid:" << id << endl; } } int main() { signal(SIGCHLD, FreeChld); pid_t id = fork(); if (id == 0) { //子进程 int cnt = 10; while (cnt) { cout << "我是子进程, pid: " << getpid() << " 当前的cnt: " << cnt-- << endl; sleep(1); } cout << "子进程退出, 进入僵尸状态" << endl; exit(0); } while (true) { cout << "我是父进程,我正在运行:" << getpid() << endl; sleep(1); } return 0; }
刚开始父子进程都在运行,当子进程退出后,发送SIGCHLD信号,随后父进程依然在运行:
按照上面的写法,是可以做到父进程不只是在等待子进程的,但是这种写法是有bug的,如果多个进程同时运行,就会出现错误:
#include <iostream> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <signal.h> #include <cassert> #include <cstdlib> using namespace std; void FreeChld(int signo) { assert(signo == SIGCHLD); pid_t id = waitpid(-1, nullptr, 0); if (id > 0) { cout << "父进程等待成功,chld pid:" << id << endl; } } int main() { signal(SIGCHLD, FreeChld); for (int i = 0; i < 10; i++) { pid_t id = fork(); if (id == 0) { // 子进程 int cnt = 10; while (cnt) { cout << "我是子进程, pid: " << getpid() << " 当前的cnt: " << cnt-- << endl; sleep(1); } cout << "子进程退出, 进入僵尸状态" << endl; exit(0); } // sleep(1); } while (true) { cout << "我是父进程,我正在运行:" << getpid() << endl; sleep(1); } return 0; }
如上我for循环让父进程同时创建10个子进程。当子进程跑完后会一起退出,但是子进程退出时会给父进程发送SIGCHLD信号,10个子进程推出后均会发送SIGCHLD信号。根据上面的学习我们得知linux在涉及信号捕捉的时候,当前正在处理的信号默认是被block的,如果在block期间也收到了很多子进程的信号,那么就有可能导致一些信号的丢失,进而导致子进程无法退出。
- 我们使用如下的监控脚本来观察现象:
[xzy@ecs-333953 date29]$ while :; do ps axj | head -1 && ps axj | grep mytest | grep -v grep; sleep 1; done
此时,我们子进程正常是应该已经全部退出了的,但是右边的通过监控脚本得知:有几个子进程处于僵尸Z状态,这就是因为在同时退出的时候,一下发送了很多个SIGCHLD信号,当正在处理某一个信号时,其它一些信号就可能会被丢掉,导致bug。
那么如何调整才正确呢?
- 我们只对调用waitpid那做修改即可:可以使用while (true)语句,在其内部不断调用waitpid,直至所有的子进程退出完。
但是上述这样做还有问题,我们对代码在原有的基础上再做调整,让前7个进程先退,后4个进程(包括父进程)后退,代码如下:
#include <iostream> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <signal.h> #include <cassert> #include <cstdlib> using namespace std; void FreeChld(int signo) { assert(signo == SIGCHLD); while (true) { pid_t id = waitpid(-1, nullptr, 0); if (id > 0) { cout << "父进程等待成功,chld pid:" << id << endl; } else { cout << "父进程等待子进程结束" << endl; break; } } } int main() { signal(SIGCHLD, FreeChld); for (int i = 0; i < 10; i++) { pid_t id = fork(); if (id == 0) { // 子进程 int cnt = 10; if (i < 7) cnt = 5; else cnt = 100; while (cnt) { cout << "我是子进程, pid: " << getpid() << " 当前的cnt: " << cnt-- << endl; sleep(1); } cout << "子进程退出, 进入僵尸状态" << endl; exit(0); } // sleep(1); } while (true) { cout << "我是父进程,我正在运行:" << getpid() << endl; sleep(1); } return 0; }
理论上看到的结果是前7个进程退出后,应该还剩下3个子进程和1个父进程在运行,可是我们看到的结果竟然是如下:
我前7个进程确实如约退出了,后3个子进程确实还在运行,但是为何我父进程不再运行了?
- 因为waitpid在等待子进程的时候,把前7个信号全部回收了,当它回收第8个进程时,第8个进程并没有退出,这时调用waitpid会被阻塞住。
这里我们可以给waitpid传入WNOHANG(非阻塞等待),然后再进行修改,即可处理:
#include <iostream> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <signal.h> #include <cassert> #include <cstdlib> using namespace std; void FreeChld(int signo) { assert(signo == SIGCHLD); while (true) { pid_t id = waitpid(-1, nullptr, WNOHANG); if (id > 0) { cout << "父进程等待成功,chld pid:" << id << endl; } else if (id == 0) { //还有子进程,但是现在还没有退出 cout << "还有子进程,但是现在没有退出,父进程要去忙自己的事情了" << endl; break; } else { cout << "父进程等待子进程结束" << endl; break; } } } int main() { signal(SIGCHLD, FreeChld); for (int i = 0; i < 10; i++) { pid_t id = fork(); if (id == 0) { // 子进程 int cnt = 10; if (i < 7) cnt = 3; else cnt = 6; while (cnt) { cout << "我是子进程, pid: " << getpid() << " 当前的cnt: " << cnt-- << endl; sleep(1); } cout << "子进程退出, 进入僵尸状态" << endl; exit(0); } // sleep(1); } while (true) { cout << "我是父进程,我正在运行:" << getpid() << endl; sleep(1); } return 0; }
如果我们不想麻烦,不想获取状态,也不想自己去管,想让子进程退出时直接退出僵尸进程,该怎么办呢?
- 事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用signal或sigaction函数将SIGCHLD信号的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用signal或sigaction函数自定义的忽略通常是没有区别的,但这是一个特列。此方法对于Linux可用,但不保证在其他UNIX系统上都可用。
例如,下面代码中调用signal函数将SIGCHLD信号的处理动作自定义为忽略。
#include <iostream> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <signal.h> #include <cassert> #include <cstdlib> using namespace std; int main() { // signal(SIGCHLD, FreeChld); signal(SIGCHLD, SIG_IGN); for (int i = 0; i < 10; i++) { pid_t id = fork(); if (id == 0) { // 子进程 int cnt = 10; while (cnt) { cout << "我是子进程, pid: " << getpid() << " 当前的cnt: " << cnt-- << endl; sleep(1); } cout << "子进程退出, 进入僵尸状态" << endl; exit(0); } // sleep(1); } while (true) { cout << "我是父进程,我正在运行:" << getpid() << endl; sleep(1); } return 0; }
问:既然子进程退出的时候,默认的信号处理就是忽略,那我们调用signal / signaction SIG_IGN的意义何在呢?
- SIG_IGN手动设置还是与默认的不一样的,默认的可能还会给父进程发送信号,但是手动设置,让子进程退出时,就不会给父进程发送信号了,并且会自动释放。