Linux之信号详解

x33g5p2x  于2022-04-01 转载在 Linux  
字(20.5k)|赞(0)|评价(0)|浏览(381)

进程信号

信号入门

  1. 理解生活中的信号
    下课铃,红绿灯,闹钟,烽火,脸色…等等这些都是信号

a.你为什么能认识红绿灯或者闹钟?曾经有人教育过我们,大脑里记住的

b.现在没有闹钟的时候,你知不知道闹钟响了之后,该怎么办?知道

很多事情需要经过这三个问题:是什么?为什么?怎么办?

a,b交代了:是什么?为什么?怎么办?

c.操作系统相当于社会,进程相当于人,进程要能够识别非常多的信号

总结:人能够识别信号

  1. 见一下Linux当中的信号

生活角度的信号

  • 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
  • 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
  • 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
  • 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
  • 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

进程就是你,操作系统就是快递员,信号就是快递

技术应用角度的信号

用户输入命令,在Shell下启动一个前台进程。用户按下 Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出

#include <stdio.h>
int main()
{
    while(1){
    printf("I am a process, I am waiting signal!\n");
    sleep(1);
	}
}

我们ctrl+c,发现进程终止了:

注意

  1. Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
  2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
  3. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。

信号概念

用kill -l命令可以查看系统定义的信号列表

每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define SIGINT 2,1号到31号信号称为普通信号,剩下的是实时信号,我们不讨论实时信号。

这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal:

信号的生命周期:信号产生时->信号识别中->信号处理中

信号产生

键盘产生

我们写一个死循环的程序:

#include<iostream>
#include<sys/types.h>
#include<unistd.h>

int main()
{
    while(true)
    {
        std::cout<<"i am a process: "<<getpid()<<std::endl;
        sleep(1);
    }
}

Makefile的编写

CC=g++
LDFLAGS=-std=c++11
Src=sig.c
Bin=mysig

$(Bin):$(Src)
	$(CC) -o $@ $^ $(LDFLAGS)

.PHONY:clean
clean:
	rm -f $(Bin)

我们写了一个程序,让他死循环,然后让他运行起来变成进程,我们ctrl+c实际上是将2号信号发送给进程:

kill命令产生

也可以命令行kill命令给进程发送信号,kill命令其实底层调用了kill接口函数:

kill -SIGINT 24994

底层本质这两种是一样的,对于相当一部分信号而言,当进程收到的时候默认的处理动作就是终止当前进程

信号19暂停进程,18继续进程:

发送18信号发现进程并没有终止:

调用系统函数向进程发信号

abort

终止进程

void abort(void);
raise

int raise(int sig);

raise函数可以给当前进程发送指定的信号(自己给自己发信号)。

kill
int kill(pid_t pid,int sig);

发送一个信号给一个进程

kill也是一个接口,kill命令就是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。

kill命令的命令行输入模拟实现
#include<cstdlib>
//kill 9 112233
int main(int argc,char *argv[])
{
    if(argc != 3)
    {
        cerr<<"Usage: "<<argv[0]<<"pid signum"<<endl;
        exit(1);
    }
    kill(atoi(argc[2]),atoi(argv[1]));
}

可以看到成功杀掉了进程15430

由软件条件产生信号

alarm

软件条件是某种条件设定,当条件触发时,OS会向进程发信号,设定闹钟,到达该时间点,OS给该进程发送信号

比如我们想计算1秒能够打印多少次count:

#include<iostream>
using namespace std;
#include<unistd.h>
int main()
{
    alarm(1);//1秒之后会给目标进程发送SIGALRM,注意:设定不会有明显的现象,1秒之后触发
    int count = 0;
    while(1)
    {
        cout<<count++<<endl;
    }
}

1秒之后会给目标进程发送SIGALRM,注意:设定不会有明显的现象,1秒之后触发

我们可以通过信号捕捉的方式看一下alarm发送的是什么信号:

#include<iostream>
using namespace std;
#include<unistd.h>
void handler(int signo)
{
    cout<<"get a signo: "<<signo<<endl;
    sleep(3);
}
int main()
{
    for(int i = 1;i<32;i++)
    {
        signal(i,handler);//信号捕捉
    }
    sleep(3);
    alarm(1);//1秒之后会给目标进程发送SIGALRM,注意:设定不会有明显的现象,1秒之后触发
    int count = 0;
    while(1)
    {
        //cout<<count++<<endl;//因为有输入输出,所有效率低
        count++;//不带IO进行累加
    }
}

可以看到14信号是SIGALRM。

我们发现一秒才打印了count一共60000多次,计算机计算速度那么快为什么才这么少呢?是因为有输入输出,而且我们这是云服务器,还需要网络将数据发送给云服务器,还通过网络将数据结果返回来,所以效率低,我们将count设置成全局变量,不带IO进行累加,在信号捕捉里看一下一秒count能够加到多少:

#include<unistd.h>
int count = 0;
void handler(int signo)
{
    cout<<"get a signo: "<<signo<<endl;
    cout<<"count is "<<count<<endl;
    sleep(3);
}
int main()
{
    for(int i = 1;i<32;i++)
    {
        signal(i,handler);//信号捕捉
    }
    sleep(3);
    alarm(1);//1秒之后会给目标进程发送SIGALRM,注意:设定不会有明显的现象,1秒之后触发
    //int count = 0;
    while(1)
    {
        //cout<<count++<<endl;//因为有输入输出,所有效率低
        count++;//不带IO进行累加
    }
    return 0;
}

可以发现不进行IO进行累加,count都加到了2亿

硬件异常产生信号

说到硬件异常产生信号,我们要先说一个东西core dump标志位,这个标志是否打开核心转储,这个标志位在进程等待那里提到过,父进程获取子进程退出状态时就有这个信息。

查看Linux中内核中一些东西的大小:

ulimit -a

我们看到core file size是0,说明此时的核心转储是关闭的,那么怎么打开?

ulimit -c 1024

将核心转储的大小设置成1024:

我们在写程序时,故意写个除0错误,进程会收到8号信号然后被终止掉,当进程收到8号信号时,目录下会有core文件,core dumped叫做核心转储:OS将进程运行时的核心数据dump到磁盘上,方便用户进行调试使用,一般而言线上环境核心转储是被关闭的(比如云服务器),为什么呢?

一般在公司里,如果服务器出问题了,一般不是立即找bug,而是尽快的将服务再弄起来,在下一次出问题之前将bug找出来解决,线上环境如果将核心转储打开,服务器一直出问题,就会生成一堆的core文件,时间一长服务器内存可能已经被占满了,服务器可能登录都是问题了

为什么要核心转储?方便用户进行调试使用,比如我们在程序中写出一个除0操作,进入gdb调试,将core文件倒到gdb,gdb可以直接定位到出错的行数:

core-file core.29792

程序异常(野指针(11号信号),除0(8号信号))这些都是硬件异常产生信号的范围,站在语言的角度,就叫程序崩溃,站在系统的角度,就叫做进程收到了信号,core dump标志位,如果发生了核心转储,为1,没有发送核心转储,为0

检测core dump标志位

Makefile的编写

CC=g++
LDFLAGS=-std-c++11
Src=mytest.cc
Target=mytest
    
$(Target):$(Src)
    $(CC) -o $@ $^ $(LDFLAGS)
.PHONY:clean
clean:
	rm -f $(Target)

检测core dump标志位,子进程退出,父进程获取子进程的退出状态waitpid:

#include<iostream>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>

using namespace std;
int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //child
        int count = 5;
        while(count)
        {
            cout<<"I am child: "<<getpid()<<"count:"<<count<<endl;
            count--;
            sleep(1);
        }
        int arr[5] = 0;
        for(int i = 0;i<50;i++)
        {
            arr[i] = i;//越界
            cout<<arr[i]<<endl;
        }
        int *p;
        *p = 100;
        int a = 10;
        int b = 0;
        a/=b;//除0
        exit(0);
    }
    //parent
    int status = 0;
    pid_t ret = waitpid(id,&status,0);
    id(ret == id)
    {
        cout<<"wait success!"<<endl;
        cout<<"exit code!"<<(status>>8)&0xff<<endl;
        cout<<"exit signal!"<<(status & 0x7F)<<endl;
        cout<<"core dump!"<<(status>>7)&1<<endl; 
    }
    return 0;
}

我们发现core dump是0,然后我们将核心转储打开:ulimit -c 1024,允许core dump核心转储

一个进程要有core dump标志位为1:
需要操作系统打开核心转储,而且还要出现相关的错误(除0,越界等等)

当你的进程触发错误的时候,比如说除0,野指针越界的时候,也会由操作系统识别到,然后给目标进程发送信号,来达到终止进程的目的。

如何理解自己曾经遇到的各自程序崩溃的现象?
本质上就是操作系统识别到错误,然后给目标进程发送信号,来达到终止进程的目的

OS是如何具备识别异常的能力?

OS是软硬件的管理者!软硬件好的时候OS清楚,软硬件坏的时候也能够知道

基本上所有的报错都有对应的软硬件:

除0报错:CPU->状态寄存器,出现错误是状态寄存器会发生变化

越界/野指针:内存和页表MMU

出现错误后OS会知道谁干的,比如如果CPU执行指令出错了,这是谁的指令,然后将该进程终止

总结

  • 上面所说的所有信号产生,最终都要有OS来进行执行,为什么?
    OS是进程的管理者

  • 信号的处理是否是立即处理的?在合适的时候

  • 信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?

  • 一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?

  • 如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?

信号识别

  • 进程收到信号其实不是立即处理的,而是在合适的时候
    比如你正在打游戏,此时外卖员来给你送外卖来了,敌方正在推你的高低,此时你正在处理重要的事情

为什么不是立即处理的而是在合适的时候?

信号的产生是在进程的运行的任何时间点都可以产生的,有可能进程正在做更重要的事情,信号的产生和进程的运行是:异步的

信号的处理

  1. 默认方式(部分是终止进程,部分有特定的功能)
  2. 忽略信号
  3. 自定义方式:捕捉信号
    闹钟响了,你起床这是默认。闹钟响了你继续睡觉,这是忽略,闹钟响了,你跳舞,这是自定义

信号的本质:因为信号不是立即处理的,所以信号一定要先被保存起来

在哪里保存?如何保存?谁发的,如何发?
在进程的PCB,进程控制块task_struct

  • 如何保存?
    对进程而言,关心的是"是否有信号"+"信号是谁"的问题,就跟外卖员给你送外卖,你关心的是外卖到了没和是什么外卖,用什么结构来保存呢?位图!unsigned int signals;比特位的位置代表的是是谁,比特位的内容(0或者1)代表的是是否收到信号

  • 谁发的,如何发?
    发送信号的本质就相当于写对应进程的task_struct信号位图,因为OS是进程的管理者,对进程数据做修改,OS是有能力和义务的!信号是OS发送的,通过修改对应进程的信号位图(0->!),完成信号的发送!信号的产生都是直接或者间接通过OS发送给进程

自定义方式捕捉信号

#include<iostream>
#include<sys/types.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
	std::cout << "get a signal: " << signo << std::endl; 
}
int main
{
   	signal(2, handler);//自定义方式捕捉信号
    while(true)
    {
        std::cout<< "l am a process: " << getpid0 << std::endl;
        sleep(1);
    }
    return 0;
}

可以看到捕捉到了2信号

#include<iostream>
#include<sys/types.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
	std::cout << "get a signal: " << signo << std: : endl ; 
}
int main
{
    for(int i=0;i < 32; i++)
    {
    	signal(i, handler);
    }
    while(true)
    {
        std::cout<< "l am a process: " << getpid0 << std: : endl;
        sleep(1);
    }
    return 0;
}

可以对大部分信号进行自定义捕捉,但是个别无法自定义捕捉,比如9号信号,更不能忽略,9号信号没有办法捕捉和忽略

所以我们写了自定义捕捉程序时,如果程序终止不了,可以使用kill -9选项进行终止程序

/usr/include/bits/signum.h

在这个路径下可以看到信号的信息说明:

信号的发送,信号的识别,信号的处理上面已经讲解了,信号发送给进程后,进程不一定会立即处理,需要保存信号,那么具体是怎么保存的呢?我们从内核的角度来讲解信号的保存

  1. 保存信号的内核角度
  2. 理解如何保存

阻塞信号

信号其他相关概念

  • 实际执行信号的处理动作称为信号递达(Delivery)。
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block )某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

信号抵达的方式有:默认,忽略,自定义捕捉,进程可以允许某些信号不会被递达(阻塞),此时这些信号是阻塞信号,保持在未决状态,直到解除阻塞(方可递达),被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

注意:
阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作

在内核中的表示

前面我们说过进程在接受到信号后,可能不是立即处理信号,而是先将信号保存起来,是在进程控制块中保存的:

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。

在上图的例子中:

  • SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT信号产生了,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前
    不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。

如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?

Linux是这样实现的:
常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。

pending位图:比特位的位置代表信号的编号,比特位的内容(0 or 1)代表是否收到信号,OS发送信号本质是修改task_struct pending位图的内容

handler数组:用信号的编号,作为数组的索引,找到该信号对应的信号处理方式,然后指向对应的方法(递达)

block位图:比特位的位置代表信号的编号,比特位的内容(0 or 1) 代表是否阻塞该信号

注意:
如果没有收到对应的信号,照样可以阻塞特定信号,阻塞更准确的理解成一种"状态",检测信号是否会被递达,是否被阻塞,都是OS的任务,信号的自定义捕捉方式是用户提供的!是在用户的权限下对应的方法

sigset_t信号集

信号集用来描述信号的集合,每个信号占用一位。从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集

这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下面将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

我们写程序创建sigset_t变量,本质上是在栈上开辟的空间创建的他,是在用户空间,我们设置进程属性还需要系统调用接口

int main()
{
    sigset_t set;//在栈上开辟空间,用户空间,设置进程属性(OS),系统调用接口
    return 0;
}

信号集操作函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统
实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做
任何解释,比如用printf直接打印sigset_t变量是没有意义的

这些操作函数只是在用户空间上的,修改的是用户空间的变量

#include <signal.h>
int sigemptyset(sigset_t *set);

函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有
效信号。

#include <signal.h>
int sigfillset(sigset_t *set);

函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系
统支持的所有信号。

注意:

在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号

#include <signal.h>
int sigaddset (sigset_t *set, int signo);

指定位置设置为1(添加信号)

#include <signal.h>
int sigdelset(sigset_t *set, int signo);

指定位置设置为0(删除信号)

#include <signal.h>
int sigismember(const sigset_t *set, int signo);

判断特定信号是否已经被设置

这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含
某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

这些都只是语言层面的操作函数,我们需要设置进程属性的话就需要系统调用接口:

sigprocmask

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

返回值:若成功则为0,若出错则为-1

set是输入型参数,oset是输出型参数:
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信
号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后
根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值:

how说明
SIG_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set
SIG_SETMASK设置当前信号屏蔽字为set所指向的值,相当于mask=set

sigpending

#include <signal.h>
int sigpending(sigset_t *set);

读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。 下面用刚学的几个函数写个程序。程
序如下:

程序的步骤:

  1. 使用sigprocmask系统调用接口屏蔽(阻塞)2号信号
  2. 使用sigpending接口不断的获取pending信号集,并打印,0000000000000000000
  3. 发送2号信号给进程(2号进程是无法递达的),就能看到一个现象:0100000000000000000000
#include<signal.h>
#include<iostream>
using namespace std;
void show_pending(sigset_t *pending)
{
    for(int i = 1;i <= 31;i++)
    {
        if(sigismember(pending,i))
        {
            cout<<"1";
        }
        else
        {
            cout<<"0";
        }
    }
    cout<<endl;
}
int main()
{
    sigset_t in,out;//定义变量表示阻塞信号,in阻塞信号集:out是旧的阻塞信号集(输出型参数)
    sigemptyset(&in);//初始化阻塞信号集所有位为0
    sigemptyset(&out);//初始化未决信号集所有位为0
    
    sigaddset(&in,2);//设置2号信号被block,user stack,用户栈上设置,不影响内核
    
    sigprocmask(SIG_SETMASK,&in,&out);//在内核中完成2号block
    sigset_t pending;
    while(1)
    {
        sigpending(&pending);//读取当前进程的未决信号集
        sleep(1);//每隔一秒打印一下当前进程的未决信号集
        show_pending(&pending);
    }
    return 0;
}

首先要打印0000000000000000000000000000000,因为此时2号信号是阻塞状态,我们还没有发送2号状态,所以刚开始未决信号集打印的是0000000000000000000000000000000,然后当我们发送2号信号时打印的是0100000000000000000000000000000,因为2号是被设置为阻塞,只能处于未决,不能递达:

接下来我们再添加一些步骤:

  1. 过一段时间,取消对2号信号的block
  2. 2号信号立马会被递达
  3. 然后依旧打印pending位图,010000000->000000000

第五步需要考虑一下2号信号的默认处理

void show_pending(sigset_t *pending)
{
    for(int i = 1;i <= 31;i++)
    {
        if(sigismember(pending,i))
        {
            cout<<"1";
        }
        else
        {
            cout<<"0";
        }
    }
    cout<<endl;
}
int main()
{
    signal(2,handler);
    sigset_t in,out;
    sigemptyset(&in);
    sigemptyset(&out);
    
    sigaddset(in,2);//设置2号信号被block,user stack,用户栈上设置,不影响内核
    
    
    sigprocmask(SIG_SETMASK,&in,&out);//在内核中完成2号block
    int count = 0;
    sigset_t pending;
    while(1)
    {
        sigpending(&pending);
        sleep(1);
        show_pending(&pending);//每隔一秒打印一下当前进程的未决信号集
        if(count == 20)
        {
            //20秒之后会恢复2号信号
            sigprocmask(SIG_SETMASK,&out,&in);//恢复之后,2号信号立马递达,并且执行默认动作
            //恢复之后in又变成了老的阻塞信号集
            cout<<"old: ";
            show_pending(&in);//打印in信号集 010000000000...
            cout<<"new: ";
            show_pending(&out);  //打印out信号集 0000000000...
        }
        count++;
    }
    return 0;
}

我们分析一下程序:首先每隔一秒打印一下当前进程的未决信号集00000…,然后我们发送2号信号,此时未决信号集发生变化打印01000000…,然后20秒之后,将2号信号恢复,恢复之后in又变成了老的阻塞信号集,out是新的阻塞信号集,打印in信号集即0100000…,打印out信号集00000…

看打印结果:

我们发现20秒之后程序直接退出了,并没有打印old和in,当count等于20时,恢复了2号信号,2号信号是终止进程,恢复了2号信号,2号信号完成递达,我们没有自定义捕获信号,所以默认处理,即终止了进程。

我们写了自定义捕获信号后,就不会终止进程,会继续打印in信号集和out信号集了:

void show_pending(sigset_t *pending)
{
    for(int i = 1;i <= 31;i++)
    {
        if(sigismember(pending,i))
        {
            cout<<"1";
        }
        else
        {
            cout<<"0";
        }
    }
    cout<<endl;
}
//自定义处理方式:捕获:
void handler(int signo)
{
	std::cout << "get a signal: " << signo << std::endl ; 
}
int main()
{
    signal(2,handler);
    sigset_t in,out;
    sigemptyset(&in);
    sigemptyset(&out);
    
    sigaddset(in,2);//设置2号信号被block,user stack,用户栈上设置,不影响内核
     
    sigprocmask(SIG_SETMASK,&in,&out);//在内核中完成2号block
    int count = 0;
    sigset_t pending;
    while(1)
    {
        sigpending(&pending);
        sleep(1);
        show_pending(&pending);//每隔一秒打印一下当前进程的未决信号集
        if(count == 20)
        {
            //20秒之后会恢复2号信号
            signal(2,handler);//自定义捕获2号信号
            sigprocmask(SIG_SETMASK,&out,&in);//恢复之后,2号信号立马递达,并且执行默认动作
            //恢复之后in又变成了老的阻塞信号集
            cout<<"old: ";
            show_pending(&in);//打印in信号集 010000000000...
            cout<<"new: ";
            show_pending(&out);  //打印out信号集 0000000000...
        }
        count++;
    }
    return 0;
}

信号的捕捉

什么时候能够处理信号呢?合适的时候,指的是进程从内核态切换到用户态时,尝试进行信号检测与捕捉执行

内核如何实现信号的捕捉

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

什么时候进行信号的递达?

普通用户可以操作用户空间,可以进行管理,用户访问用户空间是用户态,访问内核时是内核态,进程从内核态返回用户态时,尝试进行信号检测与捕捉执行

进程地址空间中有3G的用户空间和1G的内核空间:

我们在执行程序时如果访问的是用户的代码,则所处的状态是用户态,当通过系统调用访问内核数据时,用户是不能访问内核的,所以系统会自动进行身份切换:usr->kernel,那么OS怎么知道当前所处的状态呢?CPU中会存在一个权限相关的寄存器数据或者看你使用的是哪个种类的页表来标识所处的状态,如果状态变成内核态肯定用的是内核态的页表

我们知道每个用户进程都有自己的用户级页表!但是OS只有一份,所有我们只需要维护一份内核级页表,内核页表是进程所共享的,不同进程通过内核级页表映射的代码和数据看到的是一样的代码和数据:

如何触发内核和用户之间的切换?

中断,调用系统接口等等

用户态和内核态的权限级别不同,决定了能看到的资源是不一样的,内核态的级别更高,但是并不代表它可以随意访问用户态,前面我们说信号被捕捉的时间点,内核态返回用户态的时候,下面我们来说明一下整个信号捕捉的过程:

整个信号捕捉的过程

CPU执行用户层的代码,用户层的代码可能有系统调用,调用系统调用去执行,这里的系统调用是函数,OS提供。并且有代码,也需要被执行,那么应该以什么态执行呢?普通人不能执行OS的代码,故要以内核态允许,当调用完系统调用接口时,执行完系统调用后需要返回,返回时会检测信号与捕捉执行,如果有信号在返回时需要做信号处理,当函数指针数组里面的处理方法是默认和忽略是2简单。麻烦的是自定义捕捉,如果是自定义捕捉,那么就要返回去执行信号捕捉方法,那么执行信号捕捉方法的状态是什么状态?

理论上内核态是绝对可以执行用户态的代码,但实际上并非如此,不能以内核的身份去执行用户层的代码,OS不相信任何人写的代码,一旦自定义捕捉信号是恶意程序的话,要是以内核态执行,那么是最高权限去执行的,这样就危险了,所以必须得从内核态切换回用户态去执行用户态的代码,执行完不能直接返回系统调用那里,需要再返回内核态经过特殊系统调用sys_sigreturn()返回用户态调用系统调用的地方。

一图理解信号捕捉过程:

与横线的交点数就是内核态和用户态之间的切换次数,我们发现有四个交点,箭头的指向说明是由谁切换向谁

对应处理信号捕捉的函数除了signal,还有sigaction:

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_flag我们基本设置为0

sa_mask的解释:
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

默认情况下,当你正在处理某个信号的时候,当前信号会被短暂的block,直到当前信号处理完毕,设置自定义捕捉时相当于是在那个函数指针数组中修改数组中的元素,下面我们来写程序验证一个信号的捕捉过程:

Makefile的编写

CC=g++
Src=myproc.cc
Target=myproc
$(Target):$(Src)
	$(CC) -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -f Target

验证一个信号的捕捉过程(sigaction):

#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void handler(int signo)
{
    cout<<"get a signo: "<<signo<<endl;
    sleep(10);
    exit(10);
}
int main()
{
    struct sigaction act,oact;
    act.sa_headler = headler;
 	act.sa_flags = 0;
 	sigemptyset(&act.sa_mask);//sa_mask都置为0    
    sigaddset(&act.sa_mask,3);//将3号信号添加到sa_mask信号集
    
    sigaction(SIGINT,&act,&oact);//信号捕捉
    
    while(1)
    {
        cout<<"process is running...\n"<<endl;
        sleep(1);
    }
    return 0;
}

写出这个代码的意思是,我们先让进程运行起来,然后我们设置sigaction参数,我们主要演示sa_mask的作用,我们将3号信号添加到sa_mask信号集,我们的程序需要出现的画面是:先一秒打印一次process is running…,当我们发送2号信号,会进去自定义捕捉,打印get a signo: 2,然后睡眠10秒,在这10秒期间我们发送2号信号和3号信号,发现没反应,是因为它们在阻塞状态。

到此我们信号发送时,信号保存中,信号处理时,信号的这三个过程已经全部讲完。

下面说一下普通信号的一个区别

  • 普通信号是会丢失的,因为普通信号的pending是由普通位图实现的,如果一个信号在进程当中是阻塞状态,当相同信号发送给进程时,它会处于未决状态,OS只是做了相同的事情将pending位图当中属于该信号的bit位置为1而已,并没有办法记录它的个数。
  • 而实时信号不会丢失:操作系统进程PCB中还会维护实时信号,实时信号内核中用结构体来描述,当操作系统给进程发送信号时,这些信号会用队列形式链接起来进程控制块PCB里面存放着指向队列的指针,进程收到一个信号如果不会立即处理都会放在这个队列当中,解除阻塞后会依次递达

可重入函数

#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void show(int signo)
{
    int i = 0;
    while(i<10)
    {
    	cout<<"get a signo: "<<signo<<endl;
		i++;
        sleep(1); 
    }
}
void handler(int signo)
{
    show(signo);
}
int main()
{
    struct sigaction act,oact;
    act.sa_headler = headler;
 	act.sa_flags = 0;
 	sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask,3);//将3号信号添加
    sigaction(SIGINT,&act,&oact);
    
    while(1)
    {
        cout<<"process is running...\n"<<endl;
        sleep(1);
    }
    return 0;
}

上面的代码当我们发送2号信号时,会进入自定义信号捕捉函数,然后会调用show函数,show函数里面10秒进行打印10次,那么有个问题,当我们在这10秒期间再次发送2号信号呢?

我们刚开始发送2号信号开始了打印,当我们在这10秒当中有发送2号信号,会等第一次发送的2号信号打印完然后再次打印后面发的2号信号。

当我们在main函数当中调用show函数,会发生什么现象呢?我们来看一下:

#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void show(int signo)
{
    int i = 0;
    while(i<10)
    {
    	cout<<"get a signo: "<<signo<<endl;
		i++;
         sleep(1); 
    }
}
void handler(int signo)
{
    show(signo);
}
int main()
{
    struct sigaction act,oact;
    act.sa_headler = headler;
 	act.sa_flags = 0;
 	sigemptyset(&act.sa_mask);
    
    sigaddset(&act.sa_mask,3);//将3号信号添加
    sigaction(SIGINT,&act,&oact);
    show(9999);//调用show函数
    while(1)
    {
        cout<<"process is running...\n"<<endl;
        sleep(1);
    }
    return 0;
}

我们发现刚开始我们调用了show函数再进行打印9999,在打印的10秒期间我们发送2号信号,发现开始打印2了,打印完2,再将剩余的9999打印完。我们发现一个函数被多个执行流同时进入访问。

main执行流和信号捕捉执行流同时进入了,函数被多个执行流同时进入的情况,叫做重入

不可重入函数(重入会引起问题)和可重入函数(重入不会引起问题),如果函数里面都是涉及局部变量,那大概率是可重入的,如果是全局变量,那么大概率是不可重入的,大部分的函数都是不可重入的!

volatile关键字

volatile关键字作用:
保持内存的可见性,易变关键字

我们来看下面这个代码:

int flag = 0;
void handler(int signo)
{
    flag = 1;
    cout<<"handler signo: "<<signo<<",set flag == 1"<<endl;
}
int main()
{
    signal(2,handler);
    while(!flag);
    cout<<"process end..."<<endl;
    return 0;
}

我们通过发送2号信号来讲flag改为1,这样死循环就能够退出了:

然后换成C语言:

int flag = 0;
void handler(int signo)
{
    flag = 1;
    //cout<<"handler signo: "<<signo<<",set flag == 1"<<endl;
    printf("handler signo: %d,set flag == 1\n",signo);
}
int main()
{
    signal(2,handler);
    while(!flag);
    //cout<<"process end..."<<endl;
    printf("process end...\n");
    return 0;
}

看gcc可不可以,gcc:

我们发现gcc也是可以的

此时没有问题是因为优化级别为默认O0

优化级别高一点时就会有疑惑,比如我们设置成O1

我们发现我们设置成O1时,还是在死循环,咦?我们明明把flag改成1了,为什么还在死循环呢?

是因为main执行流没有修改flag,当main执行流中没有修改flag的操作时,就会进行优化:会将flag优化为寄存器变量,该寄存器变量的值为0,while循环会去寄存器ebx当中查看flag的值,而在信号捕获处理函数中是将内存中的值改为了1,而main执行流在寄存器中直接ebx当中查看falg的值。所以while循环并没有出去。

一旦用volatile关键字修饰,就告诉编译器,这个变量我们可能还要修改呢,不要放在寄存器里,此时编译器就不会优化到寄存器,找flag的值就需要内存中的flag,main执行流就会将内存中的flag拿到CPU中去判断,此时就跳出了循环:

SIGCHLD信号

我们都知道父进程创建子进程,父进程得知道子进程退出了,如果想要知道子进程的退出状态,有两种方式:

  • 父进程主动去看(阻塞方式,非阻塞方式)
  • 子进程退出时会给父进程发送SIGCHLD信号,通过自定义捕获完成子进程的等待
void handler(int signo)
{
    cout<<"father process .."<<getpid()<<" " << getppid() << " count:" << "signo"<<signo<<endl;
}
int main()
{
    signal(SIGCHLD,handler);//
	if(fork())
    {
        //child
        int count = 5;
        while(count)
        {
            cout<<"child process .."<<getpid()<<" " << getppid() << " count:" << count<<endl;
            sleep(1);
            count--;
        }
        cout<<"child quit...!"<<endl;
        exit(0);
    }
    //> 0 parent
    sleep(10);
    return 0;
}

五秒之后子进程退出,而父进程此时正在sleep,此时父进程被信号提前唤醒,在这里子进程给父进程发送SIGCHLD信号,父进程被唤醒处理该信号,处理完该信号之后父进程不会继续剩余时间的睡眠,而是执行下面的工作,sleep函数的返回值是睡眠的剩余时间,我们打印一下sleep的返回值:

通过理解SIGCHLD信号,我们可以给父进程可以写个信号处理函数来等待子进程的退出,这个信号处理函数里面等待子进程的退出(waitpid)获取子进程的状态。

事实上,要想不产生僵尸进程还有另外一种办法:父进程调用signal函数将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用signal函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。

#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
int main()
{
    signal(SIGCHLD,SIG_IGN);
	if(fork() == 0)
    {
        //child
        int count = 5;
        while(count)
        {
            cout<<"child process .."<<getpid()<<" " << getppid() << " count:" << count<<endl;
            sleep(1);
            count--;
        }
        cout<<"child quit...!"<<endl;
        exit(0);
    }
    //> 0 parent
    int ret = sleep(10);
    cout<<ret<<end;
    return 0;
}

可以看到ret为0,父进程没有被唤醒,忽略了此信号,并且子进程不会变成僵尸进程,会自动清理掉

我们如何决定是否需要wait子进程?

站在Z进程的内存泄露角度,可以等待也可以不等待,signal(SIGCHLD,SIG_IGN),有可能需要获取子进程的退出码,如果父进程不关心子进程的退出码,可以不进行wait,有可能关心,那么父进程必须wait

相关文章