⭐️ 本篇博客要给大家介绍一些关于进程间通信的一些知识。其中包括信号是什么,如何产生的,信号如何保存,什么时候处理,如何捕捉信号等等一些问题,在今天的博客中,你都将找到答案。
生活中的信号:
你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能 “识别快递”
当快递员到了你楼下,你也收到快递到来的通知,但是你在忙学习,需5min之后才能去取快递。那么在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成 “在合适的时候去取”。
在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”当你时间合适,顺利拿到快递之后,就要开始处理快递了。
而处理快递一般方式有三种:
快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
技术角度的信号:
根据我们之前所学的知识,用户通过按键盘按下Ctrl-C可以终止进程。键盘输入会产生一个硬件中断,被操作系统获取后,然后解释为信号,发送2号信号给进程,进程收到信号,然后终止进程。
实例演示:
#include
#include
int main(int argc, char *argv[])
{
while (1){
printf("I am a process,I am waitting signal\n");
sleep(1);
}
return 0;
}
代码运行结果如下:
程序开始是正常运行,但是当我们按下Ctrl+C
时,该进程受到2号信号,然后终止进程。下面我要给大家用代码验证一下Ctrl+C
代表的是2号信号。
在验证这个事情之前,我先给大家介绍一个函数signal:
功能: 对一个信号注册特定的处理动作(注册一个对信号的捕捉方式)
函数原型:#include
typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler); 参数:
- sig: 要注册的信号
- handler: 处理动作,有三种:SIG_DFL(默认) 、SIG_IGN(忽略) 和 自定义(函数指针)
其中函数指针指向的函数有一个int类型的参数,无返回值,这个函数指针就是用户给信号自定义的处理动作,通过函数实现
实例演示:
#include
#include
#include
#include
int count = 0;
void handler(int signo)
{
printf("catch a signal : %d\n", signo);
}
int main(int argc, char *argv[])
{
// 注册一个对特定信号处理的动作或者说注册对一个信号的捕捉方式
signal(2, handler);
while(1){
printf("I am a process, I am running...\n");
sleep(1);
}
return 0;
}
代码运行结果如下:
从代码运行结果可以看出,Ctrl+C
代表的是2号信号,且这个2号信号不再做终止进程的动作,而是打印了一句话。因为signal这个函数修改了2号信号的默认动作,让它执行自定义动作。
注意:
Ctrl+C
产生的信号只能发给前台进程。执行程序时在命令后面加上&可以把进程放到后台运行,后面可以通过一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。如果后期想把这个后台进程放到前天,可以通过fg
指令,把这个后台进程放到前台上面我们从生活角度和技术角度谈了一下信号的概念,其实二者是可以进行类比的,进程就是那个在学习的你,快递员就是操作系统,操作系统给进程发信号就相当于给你送快递,进程处理这个信号的动作就是你去取快递。
总结: 信号是进程之间事件通知的一种方式
可以通过kill -l
命令查看系统定义的信号列表:
可以看到的是,系统下总共有62个信号,1-31号属于普通信号,34-64属于实时信号。本篇博客只谈普通信号。
如果你想了解某个信号的产生条件和默认处理动作,可以通过指令man signal_id signal
上面介绍了signal函数,这个函数可以更改处理信号的动作,有三种处理动作:
后面还会介绍一个sigaction 函数,这也是一个对信号处理的函数,也有以上三个动作
前面我们介绍过了,通过按键Ctrl C
可以发送2号信号(SIG_INT),默认处理动作是终止进程。还有可以通过按键按下Ctrl \
,发送3号信号(SIG_QUIT),默认处理动作是终止进程并且Core Dump (在进程等待那里我们留下来这个问题,这里进行讨论).
讨论Core Dump:
Core Dump是什么?
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。我们可以通过使用gdb调试查看core文件查看进程退出的原因,这也叫事后调试。
Core Dump使用演示:
一个进程允许产生多大的core文件取决于进程的Resource Limit,这个信息报错在PCB中。默认情况下,不允许产生core文件,因为core文件中比较大(容易浪费资源),包含用户密码等敏感信息,不安全。我们可以通过命令ulimit -a 查看系统允许我们产生多大的core文件
可以发现的是,系统是不允许产生这个core文件的,但是我们可以通过命令ulimit -c size 修改,允许产生size大小的core文件
此时我们编写一个程序:
#include
int main()
{
printf("waiting signal...\n");
while(1);
return 0;
}
代码运行结果如下: 运行起来后,用Ctrl \
信号终止进程并参数core文件
查看目录下的文件,会发现多了一个core文件,可以看到的是,这个文件很大,这也是系统不允许我们产生这个文件的一个原因
此时通过gdb调试器打开这个程序,然后通过指令core-file长core文件的错误信息,就可以发现这个进程是被收到3号信号如何退出的
之前我们介绍过kill -9可以发送9号信号杀死进程,同样地,后台进程也可以被干掉。
下面介绍三个系统函数:
功能: 给任意进程发送任意信号
#include
int kill(pid_t pid, int sig); 参数:
- pid:进程pid
- sig:要发送的信号
返回值: 成功返回0,失败返回-1
实例演示:
#include
#include
void handler(int signo)
{
printf("catch a signal : %d\n", signo);
}
int main(int argc, char *argv[])
{
signal(9, handler);
if (argc == 3){
// 给指定的进程发送指定的信号
kill(atoi(argv[1]), atoi(argv[2]));
}
return 0;
}
代码运行结果如下: 运行一个后台进程,然后通过mytest程序加参数的方式发生9号信号杀死后台sleep进程
功能: 给进程自己发送信号
#include
int raise(int sig); 参数:
- sig:要发送的信号
返回值: 成功返回0,失败返回-1
和kill比较:raise函数相当于kill(getpid(), sig)
实例演示:
#include
#include
void handler(int signo)
{
printf("catch a signal : %d\n", signo);
}
int main()
{
signal(2, handler);
while(1){
raise(2);
sleep(1);
}
return 0;
}
代码运行结果如下: 可以看到的是,该进程不断给自己发送2号进程
功能: 使用当前进程收到信号而异常终止(发送6号信号)
函数原型:#include
void abort(void);
实例演示:
#include
#include
#include
void handler(int signo)
{
printf("catch a signal : %d\n", signo);
}
int main()
{
signal(6, handler);
while(1){
abort();
}
return 0;
}
代码运行结果如下:
可以看出,即使我们对6号信号的处理动作进行了修改,但是这个信号还是把该进程终止了,这就说明了abort的函数永远是成功的。
在上一篇博客介绍过,管道如果读端不读了,存储系统会发生SIGPIPE 信号给写端进程,终止进程。这个信号就是由一种软件条件产生的,这里再介绍一种由软件条件产生的信号SIGALRM(时钟信号)
先说alarm函数:
功能: 设定一个闹钟,操作系统会在闹钟到了时送SIGALRM 信号给进程,默认处理动作是终止进程
函数原型:#include
unsigned alarm(unsigned seconds); 参数:
- second:设置时间,单位是s
返回值: 0或者此前设定的闹钟时间还余下的秒数
实例演示:
实例1:
#include
#include
#include
int main()
{
// 由软件条件产生信号 alarm函数和SIGPIPE
alarm(5);
while (1){
printf("count:%d\n", ++count);
sleep(1);
}
return 0;
}
代码运行结果如下: 5s后,闹钟到了,发生闹钟信号终止进程
实例2: 看下面两段代码
代码1: 不断打印count的数
#include
#include
#include
#include
int count = 0;
int main()
{
alarm(1);
while (1){
printf("count:%d\n", ++count);
}
return 0;
}
代码2: 最后打印
#include
#include
#include
#include
int count = 0;
void handler(int signo)
{
printf("count:%d\n", count);
exit(0);
}
int main()
{
// 由软件条件产生信号 alarm函数和SIGPIPE
signal(14, handler);
alarm(1);
while (1){
count++;
}
return 0;
}
代码运行结果:
代码1:
代码2:
对比两个程序最后运行的结果,同样是1s,程序1的count最后加到了2w,但是程序2的count且加到了5亿多。二者是1w倍的关系。相差这么大时什么原因导致的呢?
但是是IO,因为程序1每加1次都在打印,但是程序2只是最后一次才打印,所以程序1在运行的过程中不断的在进行IO操作,IO操作其实是很慢的。得出结论:一个体系结构中,IO是影响程序运行效率的最大一方面。
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
这里给大家介绍两个硬件异常:CPU产生异常 和 MMU产生异常
CPU产生异常 发生除零错误,CPU运行单元会产生异常,内核将这个异常解释为信号,最后OS发送SIGFPE信号给进程
实例演示:
#include
#include
#include
#include
int main()
{
// 由软件条件产生信号 alarm函数和SIGPIPE
// CPU运算单元产生异常,内核将这个异常处理为SIGFPE信号发送给进程
int a = 10;
int b = 0;
printf("%d", a/b);
return 0;
}
代码运行结果如下:
MMU产生异常: 当进程访问非法地址时,mmu想通过页表映射来将虚拟转换为物理地址,此时发现页表中不存在该虚拟地址,此时会产生异常,然后OS将异常解释为SIGSEGV信号,然后发送给进程
实例演示:
#include
#include
#include
#include
int main()
{
// MMU硬件产生异常,内核将这个异常处理为SIGSEGV信号发送给进程
signal(11, handler);
int* p = NULL;
printf("%d\n", *p);
return 0;
}
注意: 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
OS发生信号给一个进程,此信号不是立即被处理的,那么这个时间窗口中,信号就需要被记录保存下来,那么信号是如何在内核中保存和表示的呢?
先看下面的图(有三张表):
说明: 上面有三种表,分别是信号阻塞位图block表,信号未决位图pending表,信号处理动作handler表
分析图中几个信号:
总结:
POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
回答几个问题:
信号的产生涉及到软硬件,且OS是软硬件资源的管理者,还是进程的管理者。
答案是能知道的。每个进程都可以通过task_struct找到表示信号的三张表。此时该进程的pending表中哪些信号对应的那一位比特位是为0的,且进程能够查看block表知道如果收到该信号是否需要阻塞,可以查看handler表知道对该信号的处理动作。
OS给某一个进程发送了某一个信号后,OS会找到信号在进程中pending表对应的那一位比特位,然后把那一位比特位由0置1,这样OS就完成了信号发送的过程。
sigset_t: 未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,也被定义为一种数据类型。这个类型可以表示每个信号状态处于何种状态(是否被阻塞,是否处于未决状态)。阻塞信号集也叫做当前进程的信号屏蔽字,这里的“屏蔽”应该理解为阻塞而不是忽略。
信号集操作函数: sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释
注意: 对应sigset类型的变量,我们不可以直接使用位操作来进行操作,而是一个严格实现系统给我们提供的库函数来对这个类型的变量进行操作
下面是信号集操作函数的原型:
#include
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
注意: 在实现这些函数之前,需要使用sigemptyset 或sigfillset对信号集进行初始化。前四个函数的返回值是成功返回0,失败返回-1。最后一个函数的返回值是真返回1,假返回-1
阻塞信号集操作函数——sigprocmask:
功能: 读取或更改进程的信号屏蔽字
函数原型:#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 参数:
how:三个选项
- SIG_BLOCK:把set中的信号屏蔽字添加到进程的信号屏蔽字中,mask = mask|set
- SIG_UNBLOCK:把set中的信号屏蔽字在进程信号屏蔽字的那些去掉,mask = mask&~set
- SIG_SETMASK:设置当前进程的信号屏蔽字为set,mask = set
set:如果为非空指针,则根据how参数更改进程的信号屏蔽字
oset:如果为非空指针,将进程原来的信号屏蔽字备份六种oset中
返回值: 成功返回0,失败返回-1
未决信号集操作函数——sigpending:
功能: 读取进程的未决信号集
函数原型:#include
int sigpending(sigset_t *set); 参数:
- set:读取当前进程的信号屏蔽字到set指向的信号屏蔽中
返回值: 成功返回0,失败返回-1
实例演示:
实例1: 把进程中信号屏蔽字2号信号进行阻塞,然后隔1s对未决信号集进行打印,观察现象
#include
#include
#include
void PrintPending(sigset_t* pend)
{
int i = 0;
for (i = 1; i < 32; ++i)
{
if (sigismember(pend, i)){
printf("1");
}
else{
printf("0");
}
}
printf("\n");
}
int main()
{
sigset_t set, oset;
sigset_t pending;
// 使用系统函数对信号集进行初始化
sigemptyset(&set);
sigemptyset(&oset);
sigemptyset(&pending);
// 阻塞2号信号
// 先用系统函数对set信号集进行设置
sigaddset(&set, 2);
// 使用sigprocmask函数更改进程的信号屏蔽字
// 第一个参数,三个选项:SIG_BLOCK(mask |= set) SIG_UNBLOCK(mask &= ~set) SIG_SETMASK(mask = set)
sigprocmask(SIG_BLOCK, &set, &oset);
int flag = 1; // 表示已经阻塞2号信号
int count = 0;
while (1){
// 使用sigpending函数获取pending信号集
sigpending(&pending);
// 打印pending位图
PrintPending(&pending);
sleep(1);
}
return 0;
}
代码运行结果如下: 可以看到,进程收到2号信号时,且该信号被阻塞,处于未决状态,未决信号集中2号信号对应的比特位由0置1
实例2: 将上面的代码进行修改,进行运行10s后,我们将信号屏蔽字中2号信号解除屏蔽
int count = 0;
while (1){
// 使用sigpending函数获取pending信号集
sigpending(&pending);
// 打印pending位图
PrintPending(&pending);
if (++count == 10){
// 两种方法都可以
sigprocmask(SIG_UNBLOCK, &set, &oset);
//sigprocmask(SIG_SETMASK, &oset, NULL);
}
sleep(1);
}
代码运行结果如下: 可以看出的是,2号信号解除阻塞后,信号被递达了,进程终止
先思考一个问题:信号是什么时候被进程处理的?
首先,不是立即被处理的。而是在合适的时候,这个合适的时候,具体指的是进程从用户态切换回内核态时进行处理
这句话如何理解,什么是用户态?什么是内核态?
注意: 操作系统中有一个cr寄存器来记录当前进程处于何种状态
进程空间分为用户空间和内核空间。此前我们介绍的页表都是用户级页表,其实还有内核级页表。进程的用户空间是通过用户级页表映射到物理内存上,内核空间是通过内核级页表映射到物理内存上,如下面简图所示:
进程有不同的用户空间,但是只有一个内核空间,不同进程的用户空间的代码和数据是不一样的,但是内核空间的代码和数据是一样的。
上面这些主要是想说:进程处于用户态访问的是用户空间的代码和数据,进程处于内核态,访问的是内核空间的代码和数据。
下面给演示信号捕捉的整个过程:
从上面的图可以看出,进程是在返回用户态之前对信号进行检测,检测pending位图,根据信号处理动作,来对信号进行处理。这个处理动作是在内核态返回用户态后进行执行的,所以这里也就回答了开始提出的那一个问题了。
我们还需要注意的是,如果信号处理动作是用户自定义的函数,那么信号捕捉的整个过程如下:
这里还有介绍一个函数sigaction,这个也是一个对指定信号的执行动作进行特殊处理的函数
功能: 可以读取和修改与指定信号相关联的处理动作
函数原型:
#include
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数:
signum: 要操作的信号
act:一个结构体
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
返回值: 成功返回0,失败返回-1
实例演示:
#include
#include
#include
void handler(int signo)
{
printf("catch a signal: %d\n", signo);
}
int main()
{
struct sigaction act, oact;
act.sa_flags = 0;// 选项 设置为0
sigfillset(&act.sa_mask);
act.sa_handler = handler;
// 对2号信号修改处理动作
sigaction(2, &act, &oact);
while (1){
raise(2);
sleep(1);
}
return 0;
}
先看下面一段代码:
#include
#include
int a = 10;
void SelfAdd(int n)
{
a = a + n;
a = a + n;
}
void handler(int signo)
{
SelfAdd(signo);
}
int main()
{
signal(2, handler);
SlefAdd(2);
printf("%d\n", a);
return 0;
}
上面我写了一个比较简单的代码,我们慢慢分析,当我们在主函数中执行调用SelfAdd时,进入该函数,执行完函数中int a = a + n这句代码后,a变成了12,此时收到2号信号,发生中断
最后打印a结果是18,其实正常调用该函数的话,打印的应该是18。
像上面这样的因为重入导致结果错乱的函数就叫做不可重入函数。其中a是一个全局变量。如果一个函数值访问自己的局部变量或参数,那么这样的函数就叫做可重入函数。
思考一个问题:为什么两个不同的控制流程调用同一个函数,访问同一个局部变量或参数不会造成错乱?
在多线程中,每个线程虽然是资源共享,但是他们的栈却是独有的,所以说局部变量不会造成错乱
如果一个函数符合以下条件之一则是不可重入的:
先看一段代码:
#include
#include
#include
int flag = 1;
void handler(int signo)
{
flag = 0;
printf("flag changs from 1 to 0\n");
}
int main()
{
signal(2, handler);
while (flag);
printf("running here...\n");
return 0;
}
代码运行结果如下: 此时代码看上去没有什么问题
此时我们使用gcc带上优化(-O2)进行编译,如下:
优化后,执行代码结果如下:
此时按下Ctrl C 程序并没有往下指向,其实是因为flag的值没有发生改变,因为编译器优化,会把flag这个变量放入到寄存器中,handler指向流只会把内存中的flag变为1,但是flag在寄存器中的值并没有改变,但while检测flag是检测寄存器中flag值,所以这下循环是不会退出的。
这时解决办法就是使用volatile关键字,报错内存的可见性。
volatile: 保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量
的任何操作,都必须在真实的内存中进行操作