什么是信号?
现实生活中,我们经常会遇到下面这种场景,比如:快递员小哥给你发个短信,通知你19点前,到XXX取走快递,快递编号XXX;在这个例子中,快递小哥就是操作系统,你就是一个进程,而这个短信就是信号。
我们先来演示一个常见的信号使用方式:
#include <iostream>
#include <unistd.h>
int main(){
// 死循环
while(1){
std::cout << "This is a endless loop, You should use ctrl + c to terminate it!\n";
sleep(2);
}
return 0;
}
编译运行,如下:
可以看到这里是一个进程,它完成的功能是不停的打印一句话,想要把它停下来,必须使用ctrl + c来结束它。这里我们按下ctrl + c相当于是操作系统向该进程发送了一个信号,让该进程结束。
同样是这段代码,我们重新运行一下:
从上面的例子,我们可以看出,使用ctrl + c也无法终止该进程。
原因如下:
- ctrl + c产生的信号只能发给前台进程。一个命令后面加上&表示将这个进程放到后台运行,这样shell不必等待进程结束就可以接受新的命令,启动新的进程。
- shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到ctrl + c这种控制键产生的信号。
- 前台进程在运行过程中用户随时可能按下ctrl + c而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能受到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步。
那么操作系统中都有哪些信号呢,我们可以通过kill -l来查看所有的信号:
[sss@aliyun ~]$ kill -l
从上面可以看到,一共有62个信号。其中,
- 1~31号继承自Unix(每个都有各自对应的事件),非可靠信号,非实时信号。
- 这些信号对应的事件,可以通过man手册来查看,man 7 signal。
- 34~64是后加的31个信号(后序添加的信号,没有对应的事件),可靠信号,实时信号。
- 每个信号都有一个编号和一个宏定义名称,我们可以通过下面命令来查看这些宏定义。
[sss@aliyun ~]$ vim /usr/include/asm/signal.h
信号的产生
按键产生信号
我们前面使用的ctrl + c相当于是发送了SIGINT信号,SIGINT信号的处理动作是终止进程并且CoreDump,我们来验证一下:
什么是Core Dump?
一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令设置允许产生core文件的大小。
- 首先,我们使用ulimit -a命令来看一下默认core文件大小:
可以看到,默认产生的core文件大小为0,也就是不允许产生core文件。 - 我们使用ulimit -c 1024将core文件大小调整为1024K:
- 我们还是使用前面那个死循环程序,运行起来,另开一个终端查看其进程id,使用ctrl + \退出它:
可以发现,core文件的命名就是core + .pid。 - 我们进入gdb,将corefile加载进来:
总结:ctrl + c产生终止信号(SIGINT)(2)、ctrl + \产生退出信号(SIGQUIT)(3)、ctrl + z产生停止信号(SIGTSTP)(20)。
命令、函数产生信号
我们可以使用kill -signo -pid对进程ID为pid的进程发送一个signo号信号,演示如下,还是使用前面死循环程序:
- 首先,运行程序:
- 另开一个终端,查看该进程的进程ID,然后对该进程发送一个2号信号SIGQUIT:
- 可以看到,进程退出了,信号发送成功:
下面介绍几个可以产生信号的函数:
// 头文件:signal.h
// 功能:向进程pid发送sig信号
int kill(pid_t pid, int sig);
/*
* 参数:
* pid:进程ID;
* sig:信号编号。
* 返回值:成功返回0,失败返回-1。
*/
代码演示:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdio.h>
int main(){
int cnt = 5;
// 循环5次
while(cnt >= 0){
std::cout << "I will kill myself, count down: "
<< cnt << std::endl;
// 休眠1s
sleep(1);
--cnt;
}
// 向自己发送SIGKILL信号
int ret = kill(getpid(), SIGKILL);
if(ret < 0){
// 信号发送失败
perror("kill error");
return -1;
}
return 0;
}
编译运行程序,效果如下:
// 头文件:signal.h
// 功能:给调用进程或调用线程发送指定信号
int raise(int sig);
/*
* 参数:
* sig:信号。
* 返回值:成功返回0,失败返回非0。
*/
代码演示:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdio.h>
int main(){
int cnt = 5;
// 循环5次
while(cnt >= 0){
std::cout << "I will kill myself, count down: "
<< cnt << std::endl;
// 睡眠1s
sleep(1);
--cnt;
}
// 杀死自己
int ret = raise(SIGKILL);
if(ret != 0){
// 信号发送失败
perror("raise error");
return -1;
}
return 0;
}
// 头文件:stdlib.h
// 功能:使当前信号收到信号而异常终止
void abort(void);
代码演示:
#include <iostream>
#include <stdlib.h>
int main(){
std::cout << "This is a abort test code!\n";
// 异常终止
abort();
return 0;
}
编译运行,效果如下:
// 头文件:unistd.h
// 功能:设置一个定时器,seconds秒后给调用进程发送SIGALARM信号。
// seconds为0表示取消闹钟
unsigned int alarm(unsigned int seconds);
/*
* 参数:
* seconds:定时的秒数。
* 返回值:
* 如果调用此alarm()前,进程已经设置了闹钟时间,则返回上一个闹钟时间的剩余时间;
* 否则返回0;失败返回-1。
*/
代码演示:
#include <iostream>
#include <unistd.h>
int main(){
// 设置一个5s的闹钟
alarm(5);
int i = 0;
// 死循环
while(1){
std::cout << "seconds: " << ++i << std::endl;
sleep(1);
}
return 0;
}
硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送合适的信号。例如当前进程执行了除0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
下面我们来测试一下:
非法内存地址:
#include <iostream>
int main(){
int a = 3;
int b = 0;
// 除0
int div = a / b;
std::cout << div << std::endl;
return 0;
}
编译运行,效果如下:
下面我们来捕捉SIGFPE信号:
#include <iostream>
#include <signal.h>
#include <unistd.h>
// 信号捕获函数
void sig_handler(int signo){
// 休眠1s
sleep(1);
std::cout << "div zero!\n";
}
int main(){
int a = 3;
int b = 0;
// 信号捕获
signal(SIGFPE, sig_handler);
// 除0
int div = a / b;
return 0;
}
编译运行程序,效果如下:
非法地址访问:
#include <iostream>
#include <signal.h>
#include <unistd.h>
int main(){
// 非法地址访问
int* p = NULL;
*p = 10;
return 0;
}
编译运行,效果如下:
下面我们来捕获一下SIGSEGV信号:
#include <iostream>
#include <signal.h>
#include <unistd.h>
// 信号处理程序
void sig_handler(int signo){
// 休眠1s
sleep(1);
std::cout << "invalid address!\n";
}
int main(){
// 信号捕获
signal(SIGSEGV, sig_handler);
// 非法地址访问
int* p = NULL;
*p = 10;
return 0;
}
编译运行,效果如下:
从上述演示可以看出,在C/C++中除0、内存越界等异常,在系统层面上,都是被当成信号处理的。
信号的阻塞
- 实际执行信号的处理动作称为信号递达(delivery)。
- 信号从产生到递达之间的状态称为信号未决(pending)。
- 进程可以选择阻塞(block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号在内核中的表示:
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号为阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有接触阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号位产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
信号的注册:
- 非实时信号在递达之前产生多次只计一次。
- 实时信号在递达之前产生多次可以依次放在一个队列里。
信号的注销:
- 非实时信号:因为非实时信号结点只有一个,因此删除结点,位图直接置0。
- 实时信号:因为实时信号结点可能会有多个,若还有相同信号结点,则位图依然置1,否则置0。
信号集操作接口介绍:
头文件:signal.h
功能:阻塞set集合中的信号,将原来阻塞的信号保存到oldset中,
不关心旧的, 可以将oldset设置为NULL。
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数:
how:
SIG_BLOCK:阻塞set中的信号 mask = mask | set。
SIG_UNBLOCK:对set中的信号解除阻塞 mask = mask & (~set)。
SIG_SETMASK:将set中的信号设置为阻塞信号 mask = set。
返回值:成功0,失败-1。
头文件:signal.h
功能:将所有信号添加到set集合中。
int sigfillset(sigset_t *set);
返回值:成功0,失败-1。
头文件:signal.h
功能:将指定信号添加到set中。
int sigaddset(sigset_t *set, int signum);
返回值:成功0,失败-1。
头文件:signal.h
功能:获取当前进程的未决信号集合。
int sigpending(sigset_t *set);
返回值:成功0,失败-1。
头文件:signal.h
功能:判断signo是否在集合set中。
int sigismember(const sigset_t *set, int signo);
返回值:包含返回1,不包含返回0,出错返回-1。
头文件:signal.h
功能:将signum从set集合中移除。
int sigdelset(sigset_t *set, int signum);
返回值:成功0,失败-1。
功能:清空信号集合set。
int sigemptyset(sigset_t *set);
返回值:成功0,失败-1。
sigset_t结构体
我们到signal.h中看一下sigset_t的定义:
[sss@aliyun ~]$ vim /usr/include/signal.h
发现这里没有,我们再去查找一下:
我们使用下面命令打开sigset.h头文件:
[sss@aliyun ~]$ vim /usr/include/bits/sigset.h
可以看到sigset_t是一个位图。
代码演示:
#include <stdio.h>
#include <signal.h>
// 信号处理函数
void myHandler(int signo){
printf("recv signo: %d\n", signo);
}
int main(){
struct sigaction act;
act.sa_handler = myHandler;
act.sa_flags = 0;
// 信号捕获
sigaction(SIGINT, &act, NULL);
sigaction(SIGQUIT, &act, NULL);
sigaction(SIGRTMIN+5, &act, NULL);
sigset_t set, old_set;
// 清空set
sigemptyset(&set);
// 将所有信号添加到set集合
sigfillset(&set);
// 阻塞set中的信号
sigprocmask(SIG_BLOCK, &set, &old_set);
// 按下回车解除阻塞
printf("press ENTER to unblock!\n");
// 获取一个回车
getchar();
sigset_t pending;
// 获取当前进程的未决信号集
sigpending(&pending);
// 将未决信号打印出来
for(int i = 1; i <= 64; ++i){
if(sigismember(&pending, i)){
printf("1 ");
}
else{
printf("0 ");
}
}
printf("\n");
// 解除阻塞
sigprocmask(SIG_UNBLOCK, &set, NULL);
return 0;
}
首先,我们编译运行程序:
此时,我们另开一个终端,发送几个信号:
回到运行程序中断,按下回车,效果如下:
从上述运行结果,也可以看出实时信号和非实时信号在信号注册是的区别。
注意:
- 有两个信号SIGKILL 9 和 SIGSTOP 19无法被阻塞,无法自定义,无法被忽略。
- Linux中init进程属于特例,内核会忽略发送给该进程的SIGKILL信号,换句话说,内核在运行时无论如何都不会init进程都不会被终止,它的生命周期与系统的生命周期相同。
信号的捕获
内核是如何实现信号捕捉的?
- 如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
- 由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:用户程序注册了SIGQUIT信号的处理函数sig_handler。当前正在执行main函数,这时候发生中断或异常切换到内核态。在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。内核决定返回用户态后不是回复main函数的上下文继续执行,而是执行sig_handler函数,sig_handler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sig_handler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
接口介绍:
头文件:signal.h
功能:使用handler函数替换signum信号的处理方式。
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数:
signum: 信号编号。
handler: 函数指针, 有两个宏: SIG_IGN(忽略处理方式), SIG_DFL(默认处理方式)。
返回值:
代码演示:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 信号处理函数
void sig_handler(int signo){
printf("recv signo: %d %s\n", signo, "SIGINT");
}
int main(){
// 信号捕获
signal(SIGINT, sig_handler);
// 死循环
while(1){
printf("This is a endless loop!\n");
// 睡眠2s
sleep(2);
}
return 0;
}
编译运行,效果如下:
头文件:signal.h
功能:使用act动作替换signum原有的处理动作, 并将原有处理动作拷贝到oldact中。
int sigaction(int signum,
const struct sigaction *act, struct sigaction *oldact);
参数:
signum:信号编号。
act:根据act修改该信号的处理动作。
oldact:保存该信号原处理动作。
返回值:成功返回0,失败返回-1。
下面,我们来看一下sigaction结构体:
// struct sigaction结构体
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
sigaction各成员作用如下:
- sa_handler是一个函数指针,保存信号处理函数,可以给其传SIG_IGN和SIG_DFL。也可以穿自定义的信号处理函数。保存的信号处理函数是一个回调函数,不是被main函数调用,而是被系统调用。
- sa_mask添加需要额外屏蔽的信号。当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。如果调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽额外的信号,则可以将信号添加到sa_mask中,同样的当信号处理函数返回时自动回复原来的信号屏蔽字。
- sa_flags字段指定对信号进行处理的各个选项,默认置0。
代码演示:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
struct sigaction act, old_act;
// 信号处理函数
void sig_handler(int signo){
printf("recv: %d %s\n", signo, "SIGINT");
// 信号捕获
sigaction(SIGINT, &old_act, NULL);
}
int main(){
act.sa_handler = sig_handler;
act.sa_flags = 0;
// 信号捕获
sigaction(SIGINT, &act, &old_act);
while(1){
printf("This is a endless loop!\n");
// 睡眠2s
sleep(2);
}
return 0;
}
编译运行,效果如下:
SIG_CHLD
- 我们知道,如果子进程先于父进程退出,并且父进程没有读取到子进程退出返回代码时,操作系统不会完全释放子进程的资源,子进程就成为僵尸进程,僵尸进程的危害时很大的。
- 当然也有解决方法,父进程使用wait或waitpid进行等待可以避免僵尸进程的产生,但是wait和waitpid都是有代价的,如果是阻塞等待子进程,父进程就不能处理自己的工作了,如果是非阻塞等待子进程,还需要轮询查看一下,这是很不方便的。
- 其实,子进程在退出时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait()清理子进程即可。
- 由于UNIX的历史原因,要想不产生僵尸进程还有另外一种方法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其他UNIX系统上都可用。
代码演示:
首先我们先来生成一个僵尸进程:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(){
// 创建进程
pid_t pid = fork();
if(pid < 0){
// 进程创建失败
perror("fork error");
return -1;
}
else if(pid == 0){
// 子进程
while(1){
printf("child process is running...\n");
// 睡眠5s
sleep(5);
}
exit(0);
}
// 父进程
while(1){
printf("parent process is running...\n");
// 睡眠5s
sleep(5);
}
return 0;
}
编译运行程序,同时另开一中断,杀死子进程,效果如下:
下面,我们对代码进行修改,使用自定义信号处理函数来对进程进行等待:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
// SIGCHLD信号处理函数
void sig_handler(int signo){
while(!waitpid(-1, NULL, WNOHANG));
}
int main(){
struct sigaction act;
act.sa_handler = sig_handler;
act.sa_flags = 0;
// 信号捕获
sigaction(SIGCHLD, &act, NULL);
// 创建进程
pid_t pid = fork();
if(pid < 0){
// 进程创建失败
perror("fork error");
return -1;
}
else if(pid == 0){
// 子进程
while(1){
printf("child process is running...\n");
// 睡眠5s
sleep(5);
}
exit(0);
}
// 父进程
while(1){
printf("parent process is running...\n");
// 睡眠5s
sleep(5);
}
return 0;
}
编译运行程序,另开一终端杀死子进程,观察子进程是否称为僵尸进程:
可以看到,子进程并没有成为僵尸进程。
最后,我们再来试一下将SIG_CHLD的信号处理函数修改为SIG_IGN是否有效:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
int main(){
struct sigaction act;
// 信号处理函数为忽略
act.sa_handler = SIG_IGN;
act.sa_flags = 0;
// 信号捕获
sigaction(SIGCHLD, &act, NULL);
// 创建进程
pid_t pid = fork();
if(pid < 0){
// 进程创建失败
perror("fork error");
return -1;
}
else if(pid == 0){
// 子进程
while(1){
printf("child process is running...\n");
// 睡眠5s
sleep(5);
}
exit(0);
}
// 父进程
while(1){
printf("parent process is running...\n");
// 睡眠5s
sleep(5);
}
return 0;
}
编译运行程序,另开一终端杀死子进程,观察子进程是否称为僵尸进程:
可以看到,子进程并没有成为僵尸进程。
竞态条件
因运行时序而造成数据竞争,导致数据二义性。
可重入与不可重入:
不可重入函数:函数中所完成的操作并非原子操作,并且操作的数据是一个全局数据。并且这个操作不受保护,则称这个函数是一个不可重入函数。不可在多个运行时序中重复调用(重复调用有可能造成数据二义性)。如:malloc,free是不可重入函数。因为它们操作了全局链表,并且这些操作不受保护。
可重入函数:可以在多个时序运行中重复调用,不会造成数据二义性。
volatile关键字
先来看一段代码:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 全局变量
int i = 1;
// 信号处理函数
void sig_handler(int signo){
i = 0;
printf("recv: %d %s\n", signo, "SIGINT");
}
int main(){
struct sigaction act;
act.sa_handler = sig_handler;
act.sa_flags = 0;
// 信号捕获
sigaction(SIGINT, &act, NULL);
// 死循环
while(i){
}
return 0;
}
从代码逻辑看,按下ctrl + c后会将i的值置为0,从而退出while死循环,我们来编译运行测试一下:
从上面的运行结果,可以看出,结果和我们的预期相差甚远,这是为什么呢?
我们对代码略作修改,在来看一下效果:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 全局变量
volatile int i = 1;
// 信号处理函数
void sig_handler(int signo){
i = 0;
printf("recv: %d %s\n", signo, "SIGINT");
}
int main(){
struct sigaction act;
act.sa_handler = sig_handler;
act.sa_flags = 0;
// 信号捕获
sigaction(SIGINT, &act, NULL);
// 死循环
while(i){
}
return 0;
}
这里,可以看到当我们按下ctrl + c确实结束了循环,这是为什么呢?
- 这是因为g++ -O2对代码进行了优化,将i的值放到了寄存器上,修改i的值只是修改内存中的值,寄存器中i一直是1,想要解决这个问题,需要使用volatile关键字。
- volatile关键字的作用是保持内存可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。