1. 掌握 Linux 信号的基本概念2. 掌握信号产生的一般方式3. 理解信号递达和阻塞的概念,原理。4. 掌握信号捕捉的一般方式。5. 重新了解可重入函数的概念。6. 了解竞态条件的情景和处理方式7. 了解 SIGCHLD 信号, 重新编写信号处理函数的一般处理机制
目录
一、信号入门
1. 生活角度的信号
2. 技术应用角度的信号
3. 信号区分与说明
4. 信号概念
5. 用kill -l命令可以察看系统定义的信号列表
6. 信号处理常见方式
二、产生信号
1. 通过终端按键产生信号
2. 调用系统函数向进程发信号
3. 由软件条件产生信号
4. 硬件异常产生信号
三、阻塞信号
1. 信号其他相关常见概念
2. 在内核中的表示
3. sigset_t
4. 信号集操作函数
sigprocmask
sigpending
四、捕捉信号
1. 内核如何实现信号的捕捉
2. 信号捕捉函数signal
3. 可重入函数
后记:●由于作者水平有限,文章难免存在谬误之处,敬请读者斧正,俚语成篇,恳望指教!
——By 作者:新晓·故知
一、信号入门
1. 生活角度的信号
在生活中,我们经常会通过一些信息去做相应的事情。这些信息其实就是一种信号。
2. 技术应用角度的信号
- 用户输入命令,在Shell下启动一个前台进程。
- 用户按下 Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程
- 前台进程因为收到信号,进而引起进程退出
3. 信号区分与说明
(1)信号是给进程发送的,进程要具备处理信号的能力!
进程能够识别对应的信号,能够处理对应的信号。对于进程而言,即便是信号还没有产生,进程也已经具备识别和处理这个信号的能力。
(2)信号的产生是异步的,当信号产生的时候,对应的进程可能正在做更重要的事情,进程可以暂时不处理这个信号。
(3)进程是如何记住信号的?
进程对信号的处理有三种方式:
默认动作、忽略、自定义动作。
在进程的PCB的task_struct{}中,有位图这个结构,通过比特位的内容(1 or 0),标记信号。而task_struct{}是内核结构,只有OS能修改!OS是进程的管理者,进程的所有的属性的获取和设置,只能由OS进行。无论信号怎样产生,最终都是OS进行信号设置!
- Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
- Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
- 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
以上发现在前后台混打时,指令顺序打乱,这不影响。根据冯诺依曼体系,输入输出分别在不同的空间,且OS可以回显也可以不回显。
任务管理。
4. 信号概念
信号是进程之间事件异步通知的一种方式,属于软中断。
5. 用kill -l命令可以察看系统定义的信号列表
- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define SIGINT 2
- 编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal
6. 信号处理常见方式
(sigaction 函数稍后详细介绍 ), 可选的处理动作有以下三种 :
- 忽略此信号。
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
man signal
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; void handler(int signo) { cout << "这是一个进程,刚刚获取了一个信号:" << signo << endl; } int main() { cout << "信号回调函数测试:" << endl; signal(SIGINT, handler); sleep(3); cout << "进程已经设置完成!" << endl; sleep(3); while (true) { cout << "这是一个正在运行中的进程:" << getpid() << endl; sleep(1); } return 0; }
#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; void handler(int signo) { cout << "这是一个进程,刚刚获取了一个信号:" << signo << endl; } int main() { cout << "信号回调函数测试:" << endl; signal(SIGINT, handler); // 这里不是调用handler方法,只是设置了一个回调,让SIGINT(2)产生的时候,该方法才会被调用 // 如果不产生SIGINT(2),该方法不会被调用! // Ctrl + c:本质是给前台进程发送2号信号给目标进程,目标进程默认对2号信号的处理,是终止自己的进程 signal(3, handler); // 更改了对2号信号的处理,设置了用户自定义处理方法 sleep(3); cout << "进程已经设置完成!" << endl; sleep(3); while (true) { cout << "这是一个正在运行中的进程:" << getpid() << endl; sleep(1); } return 0; }
一般而言,一个进程的异常都与信号有关。
9号信号,是管理员信号,不能像3、4、5...等被设置为自定义信号。9号信号一般能杀掉大部分进程(D状态信号除外)。
二、产生信号
1. 通过终端按键产生信号
SIGINT 的默认处理动作是终止进程 ,SIGQUIT 的默认处理动作是终止进程并且 Core Dump, 现在我们来验证一下。Core Dump
- 首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误, 事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的, 因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: $ ulimit -c 1024
- ulimit命令改变了Shell进程的Resource Limit,test进程的PCB由Shell进程复制而来,所以也具 有和Shell进程相同的Resource Limit值,这样就可以产生Core Dump了。 使用core文件:
core dump会把进程在运行中对应的异常上下文数据,core dump到磁盘上,方便进行调试。但一般会被关掉,因为若程序出现大量异常,那么将会小号很大存储空间。
2. 调用系统函数向进程发信号
首先在后台执行死循环程序 , 然后用 kill 命令给它发 SIGSEGV信号。#include <iostream> #include <cstdlib> #include <cstring> #include <string> #include <unistd.h> #include <signal.h> #include <sys/types.h> using namespace std; //自己实现一个kill命令 //mykill 9 1234 static void Usage(const std::string &proc) { cerr << "Usage: \n\t"<<proc<<"signo pid"<<endl; } //test3 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); } }
#include <iostream> #include <unistd.h> using namespace std; int main() { while(1) { sleep(1); cout<<"我是一个进程: "<<getpid()<<endl; } }
.PHONY:all all:mykill myproc mykill:mykill.cc g++ -o $@ $^ -std=c++11 myproc:myproc.cc g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f myproc mykill
#include <iostream> #include <cstdlib> #include <cstring> #include <string> #include <unistd.h> #include <signal.h> #include <sys/types.h> using namespace std; void handler(int signo) { cout << "这是一个进程,刚刚获取了一个信号:" << signo << endl; } int main(int argc, char *argv[]) { signal(2,handler); //没有调用对一个的handler方法,仅仅是注册 while(1) { sleep(1); raise(2); } }
- 4568是test进程的id。之所以要再次回车才显示 Segmentation fault ,是因为在4568进程终止掉 之前已经回到了Shell提示符等待用户输入下一条命令,Shell不希望Segmentation fault信息和用 户的输入交错在一起,所以等用户输入命令之后才显示。
- 指定发送某种信号的kill命令可以有多种写法,上面的命令还可以写成 kill -SIGSEGV 4568 或 kill -11 4568 , 11是信号SIGSEGV的编号。以往遇 到的段错误都是由非法内存访问产生的,而这个程序本身没错, 给它发SIGSEGV也能产生段错误
kill 命令是调用 kill 函数实现的。 kill 函数可以给一个指定的进程发送指定的信号。 raise 函数可以给当前进程发送指定 的信号 ( 自己给自己发信号 ) 。#include <signal.h> int kill(pid_t pid, int signo); int raise(int signo); 这两个函数都是成功返回0,错误返回-1。
abort函数使当前进程接收到信号而异常终止。#include <stdlib.h> void abort(void); 就像exit函数一样,abort函数总是会成功的,所以没有返回值。
#include <iostream> #include <cstdlib> #include <cstring> #include <string> #include <unistd.h> #include <signal.h> #include <sys/types.h> using namespace std; void handler(int signo) { cout << "这是一个进程,刚刚获取了一个信号:" << signo << endl; } //test3 int main(int argc, char *argv[]) { signal(2,handler); //没有调用对一个的handler方法,仅仅是注册 signal(SIGABRT,handler); //没有调用对一个的handler方法,仅仅是注册. SIGABRT是6号信号,对其进行捕捉 while(1) { sleep(1); //raise(2); abort(); } }
是谁在推动操作系统做一系列的动作呢?
是硬件,时钟硬件,给OS发送时钟中断。
3. 由软件条件产生信号
SIGPIPE 是一种由软件条件产生的信号 , 在 “ 管道 ” 中已经介绍过了。本节主要介绍 alarm 函数 和 SIGALRM 信号。#include <unistd.h> unsigned int alarm(unsigned int seconds); 调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。打个比方 , 某人要小睡一觉 , 设定闹钟为 30 分钟之后响,20 分钟后被人吵醒了 , 还想多睡一会儿 , 于是重新设定闹钟为 15 分钟之后响 ,“ 以前设定的闹钟时间还余下的时间 ” 就是10 分钟。如果 seconds 值为 0, 表示取消以前设定的闹钟 , 函数的返回值仍然是以前设定的闹钟时间还余下的秒数例 alarm#include <stdio.h> #include <unistd.h> int main() { int count =14; alarm(1); for(;1;count++) { printf("count = %d\n",count); } return 8; }
这个程序的作用是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终止。
#include <stdio.h> #include <unistd.h> using namespace std; int cnt = 0; int main() { int sum = 0; // 统计进程1S内 cnt++多少次 alarm(1); while (1) { printf("hello: %d\n", cnt++); } }
#include <iostream> #include <cstdlib> #include <cstring> #include <string> #include <unistd.h> #include <signal.h> #include <sys/types.h> using namespace std; int cnt = 0; void handler(int signo) { cout << "这是一个进程,刚刚获取了一个信号:" << signo << " cnt: " << cnt << endl; exit(1); } int main() { // 统计进程1S内 cnt++多少次 signal(SIGALRM, handler); alarm(1); while (1) { cnt++; // printf("hello: %d\n", cnt++); } }
那么崩溃的本质是什么呢?
在Linux环境下,其实是进程崩溃。其本质是该进程收到了异常信号!
硬件异常导致OS向目标进程发送信号,进而导致进程终止的现象:
比如:
(1)除零报错
在CPU内部进行计算,有状态寄存器,当进行除0操作时,CPU内部的状态寄存器会被设置成为有报错:浮点数越界。CPU内部的寄存器(硬件),OS就会识别到有报错。通过OS构建信号—>目标进程发送信号—>目标进程在合适的时候处理信号—>然后终止进程。
(2)越界&&野指针报错
我们在语言层面使用的地址(指针),其实都是虚拟地址—>物理地址—>物理内存—>读取对应的数据和代码。
如果虚拟地址有问题,地址转化的工作是由(MMU(硬件)+页表(软件)),转化过程就会引起问题—>表现在MMU上—>OS发现硬件出现问题。
同样,,OS就会识别到有报错。通过OS构建信号—>目标进程发送信号—>目标进程在合适的时候处理信号—>然后终止进程。
崩溃了不一定会导致进程终止!
4. 硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核 , 然后内核向当前进程发送适当的信号。例如当前进程执行了除以0 的指令 ,CPU 的运算单元会产生异常 , 内核将这个异常解释 为 SIGFPE 信号发送给进程。再比如当前进程访问了非法内存地址,,MMU 会产生异常 , 内核将这个异常解释为 SIGSEGV 信号发送给进程。信号捕捉初识#include <stdio.h> #include <signal.h> void handler(int sig) { printf("catch a sig : %d\n", sig); } int main() { signal(2, handler); //前文提到过,信号是可以被自定义捕捉的,siganl函数就是来进行信号捕捉的提前了解一下 while(1); return 0; } [hb@localhost code_test]$ ./sig ^Ccatch a sig : 2 ^Ccatch a sig : 2 ^Ccatch a sig : 2 ^Ccatch a sig : 2 ^\Quit (core dumped) [hb@localhost code_test]$
模拟野指针//默认行为 [hb@localhost code_test]$ cat sig.c #include <stdio.h> #include <signal.h> void handler(int sig) { printf("catch a sig : %d\n", sig); } int main() { //signal(SIGSEGV, handler); sleep(1); int *p = NULL; *p = 100; while(1); return 0; } [hb@localhost code_test]$ ./sig Segmentation fault (core dumped) [hb@localhost code_test]$ //捕捉行为 [hb@localhost code_test]$ cat sig.c #include <stdio.h> #include <signal.h> void handler(int sig) { printf("catch a sig : %d\n", sig); } int main() { //signal(SIGSEGV, handler); sleep(1); int *p = NULL; *p = 100; while(1); return 0; } [hb@localhost code_test]$ ./sig [hb@localhost code_test]$ ./sig catch a sig : 11 catch a sig : 11 catch a sig : 11
由此可以确认,我们在 C/C++ 当中除零,内存越界等异常,在系统层面上,是被当成信号处理的。总结思考一下
- 上面所说的所有信号产生,最终都要有OS来进行执行,为什么?OS是进程的管理者
- 信号的处理是否是立即处理的?在合适的时候
信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
- 一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
- 如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
进程等待部分回顾:
#include <iostream> #include <cstdlib> #include <cstring> #include <string> #include <unistd.h> #include <signal.h> #include <wait.h> #include <sys/types.h> 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); }
ulimit -c 可以设置。
进程处理信号,不是立即处理的!而是当前进程从内核态切换至用户态会进行信号的检测预处理!
一般会有block、pending、递达、信号集(信号屏蔽等)等方式。
三、阻塞信号
1. 信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
2. 在内核中的表示
信号在内核中的表示示意图
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
3. sigset_t
从上图来看 , 每个信号只有一个 bit 的未决标志 , 非 0 即 1, 不记录该信号产生了多少次 , 阻塞标志也是这样表示的。因此, 未决和阻塞标志可以用相同的数据类型 sigset_t 来存储 ,sigset_t 称为信号集 , 这个类型可以表示每个信号的“ 有效 ” 或 “ 无效 ” 状态 , 在阻塞信号集中 “ 有效 ” 和 “ 无效 ” 的含义是该信号是否被阻塞 , 而在未决信号集中 “ 有效” 和 “ 无效 ” 的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask), 这里的 “ 屏蔽 ” 应该理解为阻塞而不是忽略。4. 信号集操作函数
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清零,表示该信号集不包含任何有效信号。
- 函数sigfifillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。
- 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfifillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回 0, 出错返回 -1 。 sigismember 是一个布尔函数 , 用于判断一个信号集的有效信号中是否包含某种 信号, 若包含则返回 1, 不包含则返回 0, 出错返回 -1 。sigprocmask
调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字 ( 阻塞信号集 )。#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>sigpending读取当前进程的未决信号集 , 通过 set 参数传出。调用成功则返回 0, 出错则返回 -1 。 下面用刚学的几个函数做个实验。程序如下 :程序运行时 , 每秒钟把各信号的未决状态打印一遍 , 由于我们阻塞了 SIGINT 信号 , 按 Ctrl-C 将会 使 SIGINT 信号处于未决状态, 按 Ctrl-\ 仍然可以终止程序,因为SIGQUIT信号没有阻塞。#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; static void showPending(sigset_t *pendings) { for (int sig = 1; sig <= 31; sig++) { if (sigismember(pendings, sig)) { cout << "1"; } else { cout << "0"; } } cout << endl; } // test int main() { // 1.不断获取当前进程的pending信号集 sigset_t pendings; while (true) { // 清空信号集 sigemptyset(&pendings); // 获取当前进程(谁调用,获取谁)的pending 信号集 if (sigpending(&pendings) == 0) { // 打印一下当前进程的pending 信号集 showPending(&pendings); } sleep(1); } }
#include <iostream> #include <unistd.h> #include <signal.h> 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++) { if (sigismember(pendings, sig)) { cout << "1"; } else { cout << "0"; } } cout << endl; } // test int main() { sigset_t bsig, obsig; sigemptyset(&bsig); sigemptyset(&obsig); // sigfillset(); for (int sig = 1; sig <= 31; sig++) { // 添加2号信号到信号屏蔽字中 sigaddset(&bsig, sig); } // 设置用户记得信号屏蔽字到内核中,让当前进程屏蔽2号信号 sigprocmask(SIG_SETMASK, &bsig, &obsig); signal(2, handler); // 1.不断获取当前进程的pending信号集 sigset_t pendings; while (true) { // 清空信号集 sigemptyset(&pendings); // 获取当前进程(谁调用,获取谁)的pending 信号集 if (sigpending(&pendings) == 0) { // 打印一下当前进程的pending 信号集 showPending(&pendings); } sleep(1); } }
解除信号屏蔽:
#include <iostream> #include <unistd.h> #include <signal.h> 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++) { if (sigismember(pendings, sig)) { cout << "1"; } else { cout << "0"; } } cout << endl; } // test int main() { cout<<"pid: "<<getpid()<<endl; sigset_t bsig, obsig; sigemptyset(&bsig); sigemptyset(&obsig); // sigfillset(); for (int sig = 1; sig <= 31; sig++) { // 添加2号信号到信号屏蔽字中 sigaddset(&bsig, sig); signal(sig, handler); } // 设置用户记得信号屏蔽字到内核中,让当前进程屏蔽2号信号 sigprocmask(SIG_SETMASK, &bsig, &obsig); // 1.不断获取当前进程的pending信号集 sigset_t pendings; int cnt = 0; while (true) { // 清空信号集 sigemptyset(&pendings); // 获取当前进程(谁调用,获取谁)的pending 信号集 if (sigpending(&pendings) == 0) { // 打印一下当前进程的pending 信号集 showPending(&pendings); } sleep(2); cnt++; if (cnt == 20) { cout << "解除对所有信号的block..." << endl; sigprocmask(SIG_SETMASK, &obsig, nullptr); } } }
四、捕捉信号
1. 内核如何实现信号的捕捉
如果信号的处理动作是用户自定义函数 , 在信号递达时就调用这个函数 , 这称为捕捉信号。由于信号处理函数的代码是在用户空间的, 处理过程比较复杂 , 举例如下 : 用户程序注册了 SIGQUIT 信号的处理函数 sighandler 。 当前正在执行main函数 , 这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的 main 函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复 main 函数的上下文继续执行 , 而是执行 sighandler 函 数 ,sighandler 和main 函数使用不同的堆栈空间 , 它们之间不存在调用和被调用的关系 , 是 两个独立的控制流程。 sighandler 函数返回后自动执行特殊的系统调用sigreturn 再次进入内核态。 如果没有新的信号要递达 , 这次再返回用户态就是恢复main函数的上下文继续执行了。内核态与用户态:自定义捕捉信号的处理过程:
2. 信号捕捉函数signal
sigaction
#include <signal.h> int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
- sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:
- 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
当某个信号的处理函数被调用时 , 内核自动将当前信号加入进程的信号屏蔽字 , 当信号处理函数返回时自动恢复原来的信号屏蔽字, 这样就保证了在处理某个信号时 , 如果这种信号再次产生 , 那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时 , 除了当前信号被自动屏蔽之外 , 还希望自动屏蔽另外一些信号 , 则用 sa_mask 字段说明这些需要额外屏蔽的信号, 当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flflags 字段包含一些选项 , 本章的代码都把sa_flflags 设为 0,sa_sigaction 是实时信号的处理函数 , 本章不详细解释这两个字段,有兴趣的同学可以在了解一下。
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; void handler(int signo) { cout<<"获取到一个信号,信号的编号是: "<<signo<<endl; } int main() { struct sigaction act,oact; act.sa_handler = handler; //自定义方法 //act.sa_handler = SIG_IGN; //忽略信号 //act.sa_handler = SIG_DFL; //默认信号 act.sa_flags = 0; sigemptyset(&act.sa_mask); sigaction(2,&act,&oact); while(true) { sleep(1); } return 0; }
3. 可重入函数
- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
- 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之, 如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一则是不可重入的 :调用了 malloc 或 free, 因为 malloc 也是用全局链表来管理堆的。调用了标准 I/O 库函数。标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。
#include <iostream> #include <signal.h> #include <unistd.h> using namespace std; void handler(int signo) { cout << "获取到一个信号,信号的编号是: " << signo << endl; // 增加handler信号的时间 // sleep(20); // 或者 sigset_t pending; while (true) { cout << "*" << endl; 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_handler = SIG_IGN; //忽略信号 // act.sa_handler = SIG_DFL; //默认信号 act.sa_flags = 0; sigemptyset(&act.sa_mask); sigaction(2, &act, &oact); while (true) { cout << "main running" << endl; sleep(1); } return 0; }
#include <iostream> #include <signal.h> #include <unistd.h> using namespace std; void handler(int signo) { cout << "获取到一个信号,信号的编号是: " << signo << endl; // 增加handler信号的时间 // sleep(20); // 或者 sigset_t pending; while (true) { //模拟永远处理2号信号 cout<<"pid: "<<getpid()<<endl; cout << "*" << endl; 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_handler = SIG_IGN; //忽略信号 // act.sa_handler = SIG_DFL; //默认信号 act.sa_flags = 0; sigemptyset(&act.sa_mask); sigaddset(&act.sa_mask,3); //在拦住2号信号的同时,也拦住3号信号,这就是设置sa_mask的意义 sigaction(2, &act, &oact); while (true) { cout << "main running" << endl; sleep(1); } return 0; }
#include <iostream> #include <signal.h> #include <unistd.h> using namespace std; // 自定义实现信号处理 void Handler2() { cout << "Test Signal 2" << endl; } void Handler3() { cout << "Test Signal 3" << endl; } void Handler4() { cout << "Test Signal 4" << endl; } void Handler5() { cout << "Test Signal 5" << endl; } void Handler(int signo) { cout<<"pid: "<<getpid()<<endl; 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; }
volatile
该关键字在 C 当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下[hb@localhost code_test]$ cat sig.c #include <stdio.h> #include <signal.h> int flag = 0; void handler(int sig) { printf("chage flag 0 to 1\n"); flag = 1; } int main() { signal(2, handler); while(!flag); printf("process quit normal\n"); return 0; } [hb@localhost code_test]$ cat Makefile sig:sig.c gcc -o sig sig.c #-O2 .PHONY:clean clean: rm -f sig [hb@localhost code_test]$ ./sig ^Cchage flag 0 to 1 process quit normal
标准情况下,键入 CTRL - C ,2 号信号被捕捉,执行自定义动作,修改 flag = 1 , while 条件不满足 , 退出循 环,进程退出[hb@localhost code_test]$ cat sig.c #include <stdio.h> #include <signal.h> int flag = 0; void handler(int sig) { printf("chage flag 0 to 1\n"); flag = 1; } int main() { signal(2, handler); while(!flag); printf("process quit normal\n"); return 0; } [hb@localhost code_test]$ cat Makefile sig:sig.c gcc -o sig sig.c -O2 .PHONY:clean clean: rm -f sig [hb@localhost code_test]$ ./sig ^Cchage flag 0 to 1 ^Cchage flag 0 to 1 ^Cchage flag 0 to 1
优化情况下,键入 CTRL - C ,2 号信号被捕捉,执行自定义动作,修改 flag = 1 ,但是 while 条件依旧满足 , 进程继续运行!但是很明显flflag 肯定已经被修改了,但是为何循环依旧执行?很明显, while 循环检查的 flflag ,并不是内存中最新的flflag ,这就存在了数据二异性的问题。 while 检测的 flflag 其实已经因为优化,被放在了CPU寄存器当中。如何解决呢?很明显需要 volatile[hb@localhost code_test]$ cat sig.c #include <stdio.h> #include <signal.h> volatile int flag = 0; void handler(int sig) { printf("chage flag 0 to 1\n"); flag = 1; } int main() { signal(2, handler); while(!flag); printf("process quit normal\n"); return 0; } [hb@localhost code_test]$ cat Makefile sig:sig.c gcc -o sig sig.c -O2 .PHONY:clean clean: rm -f sig [hb@localhost code_test]$ ./sig ^Cchage flag 0 to 1 process quit normal
- volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
#include <stdio.h> #include <signal.h> int flags = 0; void handler(int signo) { flags = 1; printf("更改flags:0——>1\n"); } int main() { signal(2, handler); while(!flags); printf("进程是正常退出的!\n"); return 0; }
编译器会进行优化。
对于while (!flags),当在没有修改时,进行检测,那么编译器就会进行优化。flags是全局变量,本来存储在内存,而while是逻辑运算(在CPU),编译器会优化,将flags的值优化到CPU的寄存器中,再次进行while循环检测时,就会在寄存器中读取,一旦有信号要求修改flags的值,那修改的是内存中的值,但编译器不一定知道,OS程序有多执行流,编译器只能检测语法,不能检测逻辑。所以,flags最终的值检测和程序逻辑造成不一样的结果。
这里,我们更改优化级别:—O2
#include <stdio.h> #include <signal.h> int flags = 0; void handler(int signo) { flags = 1; printf("更改flags:0——>1\n"); } int main() { signal(2, handler); while(!flags); printf("进程是正常退出的!\n"); return 0; }
如何解决?
告诉编译器,不准对flags做任何优化,每次CPU计算的时候,需要从内存中获取数据!
这就是保持内存的可见性!
SIGCHLD 信号进程一章讲过用 wait 和 waitpid 函数清理僵尸进程 , 父进程可以阻塞等待子进程结束 , 也可以非阻 塞地查询是否有子进程结束等待清理( 也就是轮询的方式 ) 。采用第一种方式 , 父进程阻塞了就不 能处理自己的工作了 ; 采用第二种方式 , 父进程在处理自己的工作的同时还要记得时不时地轮询一 下, 程序实现复杂。其实 , 子进程在终止时会给父进程发 SIGCHLD 信号 , 该信号的默认处理动作是忽略 , 父进程可以自 定义 SIGCHLD 信号的处理函数, 这样父进程只需专心处理自己的工作 , 不必关心子进程了 , 子进程 终止时会通知父进程 , 父进程在信号处理函数中调用wait 清理子进程即可。请编写一个程序完成以下功能 : 父进程 fork 出子进程 , 子进程调用 exit(2) 终止 , 父进程自定 义 SIGCHLD 信号的处理函数 , 在其中调用wait 获得子进程的退出状态并打印。事实上 , 由于 UNIX 的历史原因 , 要想不产生僵尸进程还有另外一种办法 : 父进程调 用 sigaction 将 SIGCHLD 的处理动作置为SIG_IGN, 这样 fork 出来的子进程在终止时会自动清理掉 , 不 会产生僵尸进程 , 也不会通知父进程。系统默认的忽略动作和用户用sigaction 函数自定义的忽略 通常是没有区别的 , 但这是一个特例。此方法对于 Linux 可用 , 但不保证在其它UNIX 系统上都可 用。请编写程序验证这样做不会产生僵尸进程。测试代码:#include <stdio.h> #include <stdlib.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("father proc is doing some thing!\n"); sleep(1); } return 0; }
#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) { while (true) { cout << "我是子进程: " << getpid() << endl; sleep(1); } exit(0); } // 父进程 while (true) { cout << "我是父进程: " << getpid() << endl; sleep(1); } }