一、什么是信号
1.1 进程信号的定义
进程信号是软件中断的一种形式,用于通知进程发生了某个事件,需要打断进程当前的操作去处理这个事件。它允许一个进程向另一个进程发送一个消息,该消息通常表示某个请求或通知,接收方进程可以据此执行相应的操作。
1.2 生活中的信号
- 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时, 你该怎么处理快递。也就是你能“识别快递”
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那 么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不 是一定要立即执行,可以理解成“在合适的时候去取”。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知 道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
1.3 查看信号
Linux系统下提供了多种信号,每种信号都对应一个特定的事件或请求。可以通过 kill -l 来查看系统定义的信号列表,其中1~31是非可靠信号,34~64是可靠信号
- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义 #define SIGINT 2
- 编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下 产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal
1.4 信号处理方式概括
- 忽略此信号。
- 执行该信号的默认处理动作。
- 自定义动作:提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉 (Catch)一个信号。
二、信号产生的方式
1.通过终端按键产生信号
例如 ctrl + c 会给进程发送2号信号SIGINT, ctrl + \ 会给进程发送3号信号SIGQUIT
证明:首先我们先了解一下signal函数(信号捕捉)
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
//第一个参数为捕捉信号的编号、第二个参数是一个函数指针
我们先写一个死循环的代码,默认情况下按ctrl +c会终止进程,因为2号信号的默认动作就是终止进程,若我们对2号信号进行捕捉,当进程再次受到2号进程后就会执行hander函数
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void hander(int signal)
{
cout << "捕捉到了一个信号: " << signal << endl;
}
int main()
{
signal(2, hander);
//signal(SIGINT, hander); // 这种写法也可以
while(true)
{
cout << "pid: " << getpid() << endl;
sleep(2);
}
return 0;
}
要注意信号捕捉只是改变了收到信号之后的行为动作,并不会直接调用我们自定义的函数,若信号捕捉以后一直没有产生对应的信号,那么对应的自定义函数永远也不会被调用
2.通过指令kill给指定进程产生信号
kill -signum pid
3. 通过函数调用产生信号
kill函数与raise函数
kill函数是给指定进程发送指定信号,而raise函数是给调用者发送指定信号,kill指令其实就是由kill函数调用实现的( raise函数相当于 kill ( getpid(), signum ) )
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
int raise(int signo);
#这两个函数都是成功返回0,错误返回-1。
利用kill系统调用实现mykill指令:
#include<iostream>
#include <sys/types.h>
#include <signal.h>
using namespace std;
//./mykill -2 pid
int main(int argc,char* argv[])
{
if(argc!=3)
{
cout<<"usage: "<<"signum + pid "<<endl;
exit(0);
}
int signum=stoi(argv[1]);
pid_t id=stoi(argv[2]);
kill(id,signum);
return 0;
}
abort函数
#include <stdlib.h>
void abort(void);
abort函数会给调用进程发送6号信号SIGABRT,6号进程默认动作是终止进程,就像exit函数一样,abort函数总是会成功的,所以没有返回值。
4.由软件条件产生
再讲管道的时候,我们讲过若读进程退出,写进程在写入数据就没有意义了,此时操作系统就会给写进程发送SIGPIPE的信号来终止写进程,SIGPIPE就是由软件条件产生的信号。
本文我们在谈一下alarm函数 和SIGALRM信号。
alarm函数的作用是在未来设定一个闹钟,当时间到达后,会产生SIGALRM信号
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
当我们设定一个一秒以后得闹钟,在这一秒内,会持续向屏幕输出信息,当到达时间后,闹钟就会发送信号终止进程
int main()
{
//设定一个1秒后的闹钟
alarm(1);
while(true)
{
cout << "pid: " << getpid() << endl;
}
return 0;
}
alarm的返回值
int main()
{
alarm(5);
sleep(2);
int n=alarm(0);
cout<<n<<endl;
return 0;
}
//结果为3
int main()
{
alarm(5);
sleep(4);
int n=alarm(0);
cout<<n<<endl;
return 0;
}
//结果为1
alarm(0)的作用是取消上一个闹钟,通过观察我们可以发现alarm函数的返回值是举例闹钟的秒数
5.异常
在写代码时,若我们出现除0或者访问野指针时,程序会发生崩溃,这是为什么呢?
这是因为非法操作或非法访问导致OS发送信号导致,除0时会给进程发送SIGFPE信号,访问野指针时会发送SIGSEGV信号,而这两个信号的默认动作就是终止进程。
如果我们将信号进行捕捉会发生什么呢?
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
sleep(1);
cout << "收到了一个信号: " << signum << endl;
}
int main()
{
signal(SIGFPE, handler);
int a = 100;
a /= 0;
while(1)
sleep(1);
return 0;
}
可以发现程序一直在执行我们的自定义动作,这是什么原因呢?
一般的算数运算与逻辑运算都是在cpu内部执行的,而cpu内部存在状态寄存器,用于记录运算的状态,0表示正常,1表示溢出,若运算正常,就会返回寄存器中的值,若发生异常就会发送对应的信号。当进行除零运算时,OS检测到此次运算发生了异常,就会给进程发送SIGFPE信号,用来终止该进程。
那为什么程序会发生死循环呢?
这是因为我们改变了信号的默认动作,导致进程并不会退出,寄存器中一直保存着错误的数据,进程一定是被不断调度的,当调度到我们的进程是时,OS又会检测到寄存器中的异常数据,就会发送信号继续调用函数,所以会发生死循环。所以建议将产生异常的进程终止掉。
三、核心转储
我们发现信号的默认动作一般为Core、Term、Ign,Ign表示忽略,而Core与Term都表示终止进程,那两者有什么区别吗?
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是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
ps:一般情况下云服务器默认是把这个功能关闭了的,因为在一些旧版本的系统下,生成的core文件一般为 core.进程pid,如果服务器上的项目因为异常挂掉的话,就会生成一个core文件,此时服务器应该自动重启,如果重启还不能解决问题,每当服务挂掉,服务器就会一直重启,这样就会在磁盘上生成很多的core文件,可能就会导致系统直接挂掉。
int main()
{
int a=10;
int b=0;
a/=b;
return 0;
}
上述程序存在除零的问题,如果我们打开核心转储,操作系统除了会给进程发送SIGFPE信号外,还会在当前目录产生一个core文件
通过生成的core文件进行debug
验证core dump标记位
在学习进程等待的时候,我们讲status的0-7保存的是终止信号,8-15位为退出码,其中core dump就是标识当前是否产生了core文件,如果产生了该标志位会被置为1,否则置为0
int main()
{
pid_t id = fork();
if (id == 0)
{
cout << "I am child" << endl;
int a = 10;
int b = 0;
a /= b;
exit(-1);
}
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
cout << "wait succcess" << endl;
cout << "err code:" << ((status >> 8) & 0xFF) << " quit signal:" << (status & 0x7F) << " core dump:" << ((status >> 7) & 1) << endl;
}
else
{
cout << "wait false!" << endl;
}
return 0;
}
四、阻塞信号
4.1 信号其他相关概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
4.2 在内核中表示
当一个进程收到一个信号,如果这个进程很忙的话,这个信号应该先被保存起来,等合适的时间在进行处理,那信号应该保存在哪里呢?
系统为了管理好信号的阻塞、未决及递达等状态,内核中其实存在了三张表
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子 中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前 不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可 以依次放在一个队列里。
4.3 sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。 阻塞信号集也叫做当 前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。我们可以简单的把sigset_t理解为操作系统给我们定义的位图类型。
4.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清零,表示该信号集不包含 任何有 效信号。
- 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
- sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含 某种信号,若包含则返回1,不包含则返回0,出错返回-1。
- 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
参数:第二个参数是一个输入型参数,第三个参数为输出型参数,会将旧的block位图返回给它
返回值:若成功则为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 <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
void PrintPending(sigset_t &pending)
{
std::cout << "curr process[" << getpid() << "]pending: ";
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
{
std::cout << 1;
}
else
{
std::cout << 0;
}
}
std::cout << "\n";
}
int main()
{
// 屏蔽2号信号
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
sigaddset(&block_set, SIGINT);
// 设置进入进程的Block表中
sigprocmask(SIG_BLOCK, &block_set, &old_set);
int cnt = 15;
while (true)
{
// 2. 获取当前进程的pending信号集
sigset_t pending;
sigpending(&pending);
// 3. 打印pending信号集
PrintPending(pending);
sleep(1);
}
}
五、信号捕捉
5.1 用户态与内核态
每一个进程都存在自己独立的地址空间,一般0~3G是用户空间,3~4G为内核空间,内核空间储存着操作系统的代码和数据,每一个进程都存在一个用户级页表,用于和物理内存中自己的代码和数据构建映射关系,而OS在开机时一定是第一个加载到物理内存的软件,内核空间也存在自己的内核级页表,不过内核级页表只有一张,所有的进程共享这个页表。
- 用户自己的代码或数据被访问或执行的时候处于的状态是用户态
- 执行操作系统的代码的时候的状态就是内核态。
从用户态切换为内核态一般有以下几种情况:
- 进行系统调用时。
- 当前进程的时间片到了,导致进程切换。
- 产生异常、中断、陷阱等。
从内核态切换为用户态有以下几种情况:
- 系统调用返回时。
- 进程切换完毕。
- 异常、中断、陷阱等处理完毕。
当从内核态返回用户态时会进行信号检测,会遍历pending表,如果pending表中为1并且该信号没有屏蔽,就执行对应的动作。
5.2 内核如何实现信号的捕捉
假设当前代码执行到了系统调用,此时需要跳转到内核态中执行,当执行完代码,OS会进行信号检测,如果某个信号处于未决状态并且没有被屏蔽,那么就会执行其对应的方法,如果处理方法是默认或者忽略的话,执行完后,会从内核态返回到用户态继续执行下面的代码,如果处理方法是自定义的话,需要先从内核态转变为用户态,因为方法是用户自己写的,在内核态执行可能会对OS造成损害,等执行完后,再由特定的系统调用返回内核态,在返回用户态向下继续执行代码
5.3 sigaction
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
//第二个参数为输入型参数,第三个参数为输出型参数
- sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo 是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。如果 在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需 要额外屏蔽的信号
- 如果该信号正在被处理,该信号默认会被阻塞,当信号处理完成后自动接触阻塞,防止发生处理函数被递归式调用导致出现异常
void Print(sigset_t &pending)
{
for(int sig = 31; sig > 0; sig--)
{
if(sigismember(&pending, sig))
{
std::cout << 1;
}
else
{
std::cout << 0;
}
}
std::cout << std::endl;
}
void handler(int signum)
{
std::cout << "get a sig: " << signum << std::endl;
while(true)
{
sigset_t pending;
sigpending(&pending);
Print(pending);
sleep(5);
break;
}
exit(1);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(2, &act, &oact); //捕捉2号信号
while(true)
{
std::cout << "I am a process, pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
六、可重入函数
- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因 为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函 数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从 sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步 之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只 有一个节点真正插入链表中了,导致了内存泄漏
- 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称 为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之, 如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
七、volatile
观察如下代码,如果我们不给进程发送2号信号,那么while循环就永远不会退出。但是在一些优化的情况下,由于main函数与handler函数没有直接的调用关系,操作系统用认为没有人可以改变flag,而每次while循环检测flag的值都需要从内存中获取,有些麻烦,可能直接会将flag的值保存在cpu的寄存器中,这样就会导致即使我们给进程发送了2号信号,成功改变了内存中的flag值,但是程序检测的flag永远是寄存器中的值,这样就导致了问题
ps:可以在编译时加上 -O + 数字 选项选择优化大小
所以我们需要用volatile修饰flag。
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
八、SIGCHID
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进 程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父 进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号 的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理 函数中调用wait清理子进程即可。
#include <sys/types.h>
#include <sys/wait.h>
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void Doanotherthing()
{
cout << "Doanotherthing" << endl;
}
void handler(int signum)
{
pid_t rid=waitpid(-1,nullptr,0);
if(rid>0)
{
cout<<"wait success!"<<endl;
}
else
{
cout<<"wait false!"<<endl;
}
}
int main()
{
signal(SIGCHLD,handler);
pid_t id = fork();
if (id == 0)
{
// 子进程两秒后自动退出,给父进程发信号
sleep(2);
exit(-1);
}
//父进程可以做其他事情
while (true)
{
Doanotherthing();
sleep(1);
}
return 0;
}
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作 置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程,如果不在乎子进程的处理结果和状态,这样做是最方便的。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证 在其它UNIX系统上都可用。
int main()
{
signal(SIGCHLD,SIG_IGN);
pid_t id = fork();
if (id == 0)
{
// 子进程两秒后自动退出,给父进程发信号
sleep(2);
exit(-1);
}
//父进程可以做其他事情
while (true)
{
Doanotherthing();
sleep(1);
}
return 0;
}