Linux — 【进程信号】

目录

一、信号的概念

那么生活中的信号有哪些呢? 

如何把上面的概念迁徙到进程中呢? 

signal函数

二、 信号的产生

  1. 通过终端按键产生信号

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

   kill函数

   raise函数

  abort函数

 3. 硬件异常产生的信号

 4. 软件条件产生信号

alarm函数

进程退出时的核心转储问题

三、信号的保存

 1. 信号其他相关常见概念

 2. 信号在内核中的表示

 3. sigset_t

 四、信号的递达

信号捕捉流程

信号集操作函数

sigprocmask函数

sigpending函数

sigaction函数

可重入函数

volatile ---C语言关键字

SIGCHLD信号 ---了解


一、信号的概念

这里有个时间轴,我会根据时间轴的顺序来讲解信号。

在讲信号的产生之前有一些预备知识要提前说明:

在之前的文章中用过kill -9 这个信号,所以对信号也不算特别陌生,这里用指令 kill -l 查看信号,

这里的1-31号信号是普通信号,34-64是实时信号,这里不对实时信号作讲解,只说普通信号。

 那么生活中的信号有哪些呢? 

        比如外卖,你知道自己在手机上下了订单以后,过一段时间就会有外卖小哥把你的外卖送给你,你怎么知道外卖这个东西的呢?是因为你的人生经验或者有人曾经告诉过你这个东西。

        过一会外卖小哥把你的饭送到了,给你打电话让你来取,但是这个时候你有更重要的事要处理,比如你在排位晋级赛,这个时候你不会立马就下去取外卖,你会给外卖小哥说你先放在某某地方,我待会去取,这个时候你就把当前取外卖这个信号暂时保存在你的脑子里,等处理完这件事再去取。

        等你终于取到外卖了,然后你处理外卖有三种情况:第一种是默认情况,拿到直接开吃,第二种自定义情况,你会先找一个下饭的视频,再吃外卖,第三种是忽略,你把外卖取回来后,时间还有点早不想吃,你又继续打开手机开始排位,这是处理信号的三种情况。

        从下订单到外卖到来,这整个过程对你来讲是异步的,也就是说在这个时间段内你和外卖小哥各自有各自的事情要干。

如何把上面的概念迁徙到进程中呢? 

        首先我们知道信号是给进程发的,比如kill -9 命令。

那么进程是如何识别信号的呢?

        肯定是有人给它“说过”,进程本身是由程序员编写的属性和逻辑的集合,信号也是由程序员编写的,在设计的时候肯定就给进程“说过”信号啦。

        那么当进程收到信号时,可能正在执行更重要的代码,所以信号不一定会被立即处理。那么这个信号就要被保存起来,如果不保存,进程执行完代码,就会“忘记”那个信号,信号就会消失掉,所以进程本身是要有保存信号的能力的。

        进程在处理信号的时候也一样,有三种情况:默认,自定义,忽略。这个叫信号被捕捉。

如果一个进程要保存信号,这个信号应该保存在哪里呢?

当然是进程的PCB(task_struct 结构体)中,它里面有很多属性,其中有一个属性就是用来记录属性的。

那么如何保存呢?

在进程PCB中有一个属性是signal,他是一个位图结构(是一个unsigned int),用比特位的位置标识信号编号,如下图,用比特位的内容表示是否收到信号,0表示没有收到,1表示收到了。

 那如何理解信号的发送呢?

发送信号的本质就是修改进程PCB中的信号位图。我们知道进程PCB是属于内核维护的数据结构对象,PCB的管理者是操作系统,那么就可以得出信号是通过操作系统发送给目标进程的。我们要知道未来无论有多少种发送信号的方式,本质都是通过操作系统向目标进程发送的信号,得出这个结论后,那么操作系统就必须提供发送信号和处理信号的相关系统调用。

到这里预备知识就说完了,然后我们了解一下系统调用接口就进入信号的产生部分。

之前常用的 ctrl+c 热键,是一个组合键,操作系统将它解释为2号信号,可以通过 man 7 signal 命令查看详细手册。看下图,可以得知2号信号的默认动作是终止进程。

 可以看到 ctrl+c 以后进程就终止了。

 signal函数

#include <iostream>
#include <signal.h>
#include <unistd.h>

using namespace std;

void handler(int signo)
{
    cout << "捕捉到了一个信号,信号编号为:" << signo << endl;
}
int main()
{
    //这里是signal函数的调用,并不是handler的调用
    //仅仅是设置了对2号信号的捕捉方法,并不代表该方法被调用了
    //一般这个方法不会执行,除非收到对应的信号
    signal(2,handler);

    while(1)
    {
        cout<< "我是一个进程,我正在执行!进程ID:"<< getpid() << endl;
        sleep(1);
    }

    return 0;
}

 从下图可以看到,我们对进程发送了2号信号,但是进程却没有退出,这是为什么呢?那是因为我们对2号信号做了自定义动作,就是handler函数,当我们给进程发送2号信号时,进程捕捉到信号并执行我们设置的自定义动作,我们的自定义动作中并没有让进程退出,那么进程自然不会退出啦。那么有人会问这个进程一直执行终止不掉了?当然不是,我们有大杀器 9号信号 ,9号信号的作用就是杀掉进程。

当然其他的信号也可以终止掉该进程,我们只是将2号信号的处理动作改成自定义了而已。那么这里有人问了,如果把9号信号的处理动作也改成自定义怎么办?或者说把1- 31个信号全设置成自定义动作,那进程岂不是就杀不掉了?这里大家可以试一下,一般操作系统是不允许这样的事情发生的。


二、 信号的产生

 1. 通过终端按键产生信号

        这里比如 热键 ctrl + \,他的默认动作也是终止进程,查看信号后发现是3号信号,同时对该进程发送3号信号,发现效果是一样的。

通过按键产生的信号只能发给前台进程,操作系统可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到这种控制键产生的信号。比如bash(命令行解释器),你运行程序后他就被切到后台了,你输入命令是不管用的,只有终止掉当前程序,bash才会变成前台进程。

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

   kill函数

 我们可以通过系统调用实现一个简单的程序,可以帮助我们杀掉某个进程。

#include <iostream>
#include <string>

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

using namespace std;
static void Usage(const string& proc)
{
    cout<< "\nUsage: " << proc << "pid signo\n" << endl;
}

int main(int argc,char* argv[])
{
    //通过系统调用项目表进程发送信号
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }

    pid_t id = atoi(argv[1]); //进程id
    int signo = atoi(argv[2]); //信号

    int n = kill(id,signo); 
    if(n != 0)
    {
        perror("kill");
    }

    return 0;
}

 由下图可以看到,我们通过自己的程序终止了另一个进程。

 这个kill 函数可以向任意进程发送任意信号。

   raise函数

int main()
{
    int cnt = 10;
    while(cnt--)
    {

        cout<<"cnt:" <<cnt <<endl;
        sleep(1);
        if(cnt == 5)
            raise(3);
    }
    return 0;
}

 我们设置一个计数器,正常要倒数10秒后结束进程,但是我们让他5秒后给自己发送3号信号终止进程。

 abort函数

        给自己发送一个指定信号 ---- > 6号信号

int main()
{
    int cnt = 10;
    while(cnt--)
    {
        cout<<"cnt:" <<cnt <<endl;
        sleep(1);
        if(cnt == 5)
            abort();
    }
    return 0;
}

从下图中可以看到,进程倒数5秒后给自己发送了一个信号,那么具体是不是6号呢?

 可以看到确实是6号信号。

从上述情况可以看到,进程收到大部分的信号,默认动作都是终止进程,那么信号的意义是什么?我们知道一个程序抛出异常后大概率程序也会被终止,那么信号的不同,代表不同的事件,但是事件发生后的处理动作可以一样,比如终止进程,不同的信号代表不同的原因。

 3. 硬件异常产生的信号

        信号的产生不一定非得用户显示的发送,比如下面这个代码:

int main()
{
    //3. 硬件异常产生的信号
    //信号的产生不一定非得用户显示的发送
    while(true)
    {
        cout<< "我是一个进程,我正在执行!进程ID:"<< getpid() << endl;
        sleep(1);
        int a = 10;
        a /= 0;
    }
    return 0;
}

 可以看到执行程序1秒后,出错终止程序了,是一个浮点数错误。那么该信号是几号信号呢?

 很明显是8号信号,它是一个错误码的缩写,

 我们修改一下代码,把8号信号改成自定义动作。

void catchSig(int signo)
{
    cout << "捕捉到信号,编号是:" << signo << endl;
    sleep(1);
}
int main()
{
    //3. 硬件异常产生的信号
    //信号的产生不一定非得用户显示的发送
    signal(SIGFPE,catchSig);

    while(true)
    {
        cout<< "我是一个进程,我正在执行!进程ID:"<< getpid() << endl;
        sleep(1);
        int a = 10;
        a /= 0;
    }
    return 0;
}

 可以看到一直在疯狂打印捕捉到的8号信号

 我们知道除0后会默认终止进程并报错,那是因为进程收到了操作系统发送的8号信号,那么操作系统如何得知应该给当前进程发送8号信号的?或者操作系统怎么知道我们除0了呢?

看下图,CPU内部会有一批寄存器保存进程的上下文,也会处理运算表达式,当CPU进行运算的时候 发生了 /0 运算 ,此时不管值是什么,CPU都会将自己内部的状态寄存器中的溢出标记位置为1,CPU发出异常,操作系统作为软硬件资源的管理者,当然就捕捉到了该异常,然后进行处理,处理谁呢?现在是哪个进程在用CPU就处理谁,就会给该进程发送8号信号终止该进程。

那为什么我们让8号信号执行自定义动作后,他就一直输出这个异常?

那是因为8号信号的默认动作是终止进程,但是我们更改后没有终止进程,进程没有退出,那么该进程就可能会再次被调度,虽然CPU内部的寄存器只有一份,但是寄存器中的内容属于当前进程的上下文,不论是进程还是用户都没有能力去修正这个错误,所以当进程被切换时,就有无数次状态寄存器被保存和恢复的过程,所以每一次恢复的时候,就让操作系统识别到了CPU内部的状态寄存器的溢出标记位为1,操作系统就又给进程发送8号信号,一直循环往复,直到你关闭进程。

这就是为什么除0会导致程序崩溃,它是由操作系统将硬件问题转换成软件问题,再给进程发送信号终止进程。

 我们知道不光是除0,会出现报错,对空指针解引用也会报错,那么空指针解引用是几号信号呢?

void catchSig(int signo)
{
    cout << "捕捉到信号,编号是:" << signo << endl;
    sleep(1);
}
int main()
{
    //3. 硬件异常产生的信号
    //信号的产生不一定非得用户显示的发送
    signal(SIGSEGV,catchSig);

    while(true)
    {
        cout<< "我是一个进程,我正在执行!进程ID:"<< getpid() << endl;
        sleep(1);
        int*p = nullptr;
        *p = 10;    //野指针
    }
    return 0;
}

 可以看到出现空指针解引用后操作系统会给进程发送11号信号。

 那么操作系统怎么知道野指针了呢?

我们知道进程有PCB,虚拟地址空间,页表,通过页表关联获取到物理内存,虚拟地址空间上都是虚拟地址,指针本质就是虚拟地址,我们让指针变量p指向空,也就是指向0号虚拟地址,虚拟地址是通过页表转化访问到物理地址,除了页表还有一个硬件叫MMU,MMU是一个内存管理单元,它是集成在CPU当中的。MMU它通过读取页表的内容,在自己内部形成物理地址再去访问该地址。当我们对指针p解引用,访问0号地址时,在通过页表映射时,当前进程是不允许访问0号地址的,操作系统当然可以去阻止进程去访问,但是更重要的是你为什么会去访问这个0号地址,所以操作系统觉得该进程出问题了,要进行处理,那么MMU这个硬件会因为进程的越界访问发生异常,操作系统因为是软硬件资源的管理者,所以MMU在给你这个进程转化时出现了异常,操作系统里吗就识别异常转换成11号信号发送给该进程,终止进程。

        总的来说因为当你野指针的时候,会引起虚拟地址到物理地址转化时对应的硬件MMU报错,进而被操作系统识别到报错,将报错转化成信号,发送给进程,进而终止掉你的进程。我可以选择不终止该进程,但是这是没有意义的。

         大部分情况下一旦出现异常,进程什么都做不了,只能是打一句日志信息,然后就退出了。但是为什么进程都退出了,我们还要捕捉这个异常呢,或者说为什么有这么多种的异常?因为异常的不同可以代表我们是因为什么原因导致的什么异常,进而可以方便我们追溯原因,定位问题。

 4. 软件条件产生信号

        在进程间通信那里我们讲过,有两个进程通过管道进行通信,此时读端进程关闭了自己的读接口,操作系统不能让写端进程一直写呀,别人又不读,写了有啥意义,所以操作系统会给写端进程发送13号信号(SIGPIPE),写端进程收到信号后就执行默认动作退出了。像这种只是因为读端关闭了这一软件条件触发的信号,我们称为软件条件产生信号。

alarm函数

 可以看到,我们设置了闹钟,闹钟在一秒后给进程发送了14号信号,进程执行默认动作退出了。

 如果我们把该信号捕捉了呢?

 我们捕捉到了14号信号,让进程执行自定义动作,但是我们发现该信号只发送了一次,而后进程又继续执行了,也就是说这是一个一次性闹钟。

 这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,你要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。

如果你你闹钟的参数为0,表示取消掉上一个闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。

 那么其他进程是不是也可以通过alarm接口设置闹钟呢?

        当然可以,任意一个进程都可以通过alarm这个系统调用接口在内核中设置闹钟,那操作系统内一定会存在很多个闹钟,操作系统要不要对这些闹钟进行管理呢?当然要!

那么怎么管理呢?

        操作系统内会有一个alarm的结构体,它里面的属性有:未来闹钟响的时间,脑中的类型(比如你是想要一个一次性的闹钟,还是周期性的闹钟),还存有当前进程的PCB,和下一个闹钟的地址等,这些闹钟像一个链表一样串起来,操作系统会周期性的检查这些闹钟,如果当前时间戳到了闹钟该响的时间,那么操作系统会给当前进程发送14号信号,我们怎么知道当前进程是哪一个呢?闹钟这个结构体里存有当前进程的PCB,通过这个PCB操作系统就可以给进程发送信号了。那么操作系统对闹钟的管理就变成了对结构体的管理,对闹钟的操作就变成了对结构体的增删查改。

        当然在操作系统内存有很多管理闹钟的方式,比如时间轮。

总结:

因为操作系统是进程的管理者,所以所有信号的产生,最后都由操作系统来执行的。

一个信号产生后不会立即被处理,而是在一个合适的时候才会被处理。

如果信号不是立即被处理,那么信号需要被保存起来,保存在进程PCB中。

一个进程在没有收到信号时,他依旧知道当信号来时,他应该怎么处理,因为程序员已经设定好了默认动作。

操作系统给进程发送信号的本质就是操作系统识别信号来源,再去修改进程PCB中信号位图中的那个信号位置。

进程退出时的核心转储问题

从下图来看,说明我们越界了不一定会报错,那是因为系统在给我们分配空间时,不是说我们要多少给多少,而是给你一块一块的开辟。

在云服务器上,进程默认是core退出的,我们暂时看到不到明显现象,是因为云服务器默认关闭了core file选项。

 如果想看到现象,我们可以通过指令 ulimit 打开core file选项(1024指的是空间大小)

 再次运行程序,发现报错的后面多了一点东西,这就表示我们已经进行了核心转储。

 而我们的目录下也多了一点东西,可以看到文件名的后面有一串数字,这串数字是什么呢?他是引起core问题的进程pid。

那么这个核心转储是什么呢?当进程出现异常时,我们将进程在对应的时刻,在内存中的有效数据转储到磁盘中。通俗的说就是进程在运行时异常了,如果是其他的信号就直接终止进程了,但是如果是core选项,并且你打开了这个选项,进程终止时还会多做一些工作,把我进程在内存中的二进制有效数据给dumped到磁盘当中。

 那么为什么要有这个核心转储呢?

一旦进程崩溃了之后,我们最想知道的是进程为什么崩溃?在哪里崩溃了,所以操作系统为了我们后期便于调试,他会将我们进程在运行期间出现崩溃的代码相关上下文数据全部给我们dumped到磁盘当中,用来进行支持调试。那么如何支持呢?所以我们在编译时要给命令加上-g选项,然后再次运行,通过gdb命令调试,用core-file + 生成的文件(core.XXX)这个命令可以快速定位问题,这中我们称为事后调试。

 那么Term和Core动作都是退出,有什么区别呢?

如果是Core动作退出,并且选项是打开的,那么该进程在退出时会核心转储,生成一个磁盘文件,方便我们去定位错误,Term动作就是我们主动的正常下的杀掉进程。

拓展: 

之前我们说过一个问题,如果我们对全部的信号进行捕捉,让信号执行自定义动作,会不会进程就终止不了了呢?现在我们来试一试。

从下图可以看到,确实大部分的信号都终止不了进程了,19号是暂停进程,18号是继续暂停的进程,而唯独9号信号他没有被捕捉,这说明什么呢?说明操作系统是禁止捕捉9号信号的,即便你设置了也没用,系统最少要保留一个信号,一个管理员信号用来杀掉所有的异常进程。


三、信号的保存

 1. 信号其他相关常见概念

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

 2. 信号在内核中的表示

        每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作

 每个task_struct结构体中,都有block位图,pending位图和handler数组,位图的比特位位置代表信号编号,block位图中比特位的内容表示是否阻塞了对应信号,pending位图中比特位的内容表示是否收到了对应信号,handler数组下标也表示信号编号,数组下标对应的内容表示对应信号的处理方法。

 一个信号产生时,内核在进程控制块中设置该信号的未决标志,然后查看该信号是否被阻塞,如果该信号被阻塞,则暂时不能被抵达,直至进程解除对该信号的阻塞,如果没有被阻塞,信号也已经产生了,则递达该信号,执行handler表里对应的动作。信号递达后,该信号的未决标志会被清除。

如果一个信号没有产生,并不妨碍他可以先被阻塞。什么意思呢?因为block和pending是两个位图,即使一个信号没有产生,我们也可以预先将该信号在block位图中的位置设为阻塞状态。

我们现在也清楚了进程为什么能够识别信号了,程序员在设计这套机制时,在内核当中给每个进程都设置好了对应的三种结构,block位图,pending位图,handler表,这三个结构组合起来就能够完成识别信号的目的。

现在有一个问题,我们的信号是用位图保存的,那么在一个时间段内突然有大量的相同信号产生,位图只有一个比特位,就代表我们只能存储一次信号,那么其他信号都丢失了?

        是的,在Linux中常规信号在递达之前产生多次只计一次,因为只能存储一次,所以重复相同的信号就相当于被丢失了,那么信号不想被丢失怎么办?那么在内核当中还有一个实时信号,实时信号在递达之前产生多次,可以依次放在一个队列里。这里不说实时信号。

 3. sigset_t

        从前面信号的三张表来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
        因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。

        在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。


 四、信号的递达

        我们之前说过信号产生的时候,不会立即被处理,而是在合适的时候。那么这个合适的时候究竟是什么时候?从内核态返回用户态的时候,进行处理。

        我们自己写的代码在编译运行后全部都是运行在用户态的,虽然我们的代码运行在用户态,但是难免会访问到两种资源,1是操作系统自身的资源(比如getpid,waitpid等等),2是硬件资源(如prinft,read,write),我们为了访问这两种资源,就必须通过系统调用接口完成访问,而系统调用是操作系统提供的接口,普通用户无法以自己用户态的身份执行系统调用,必须让自己的状态变成内核态,才能去执行系统调用接口。

 你要执行系统调用接口,需要先转换状态,才能去执行,所以说这个系统调用接口是比较费时间的,那我们就尽量避免频繁的调用系统接口。

那么这里有个问题我怎么知道我当前是用户态还是内核态?

CPU内部有很多寄存器,这些寄存器分为两类:1.可见寄存器(如pc指针,esp,ebp),2.不可见寄存器(如状态寄存器)。那么无论是可见的还是不可见的,其中这些寄存器中有相当大的一部分都和进程是强相关的,这些寄存器上直接或间接保存的进程的相关数据,我们都当成当前进程的上下文数据。所以我们之前说过,进程在切换时,除了切换PCB地址空间,页表这些东西,还要帮我们切换CPU内匹配的上下文数据,每个进程在切走的时候可以把自己的上下文带走,切回来的时候,再把自己的上下文带回来。

CPU中有一些个寄存器,他里面保存有当前正在执行的进程的PCB的起始地址,这就是为什么操作系统可以知道当前正在执行的进程是谁,还有寄存器可以保存用户级页表的起始地址,还有CR3,表征当前进程的运行级别:0.表示内核态,3.表示用户态。

那么当前进程正在执行时怎么确认我是内核态还是用户态呢?很简单,只要查看CPU中CR3这样的寄存器为0还是为3,就可以辨别当前的运行级别了。

 到这里呢我还是不太理解,我是一个进程,怎么跑到内核中执行方法呢?

我们之前说过,进程是通过页表将虚拟内存映射到物理内存的,进程为了维护独立性,每个进程都有自己的虚拟地址空间和用户级页表。除此之外,操作系统内部还维护了一张内核级页表,操作系统为了维护从虚拟到物理之间的操作系统级别的代码所构建的一张内核级页表。当你在开机时,操作系统会被加载到内存中,操作系统在物理内存中只会存在一份,不像进程的代码可以有很多份。

从下图可以看到虚拟地址空间上0-3G是用户空间,而3-4G是内核空间,每个进程都是一样的,进程的内核空间也都要通过内核级页表去映射操作系统在内存上的位置,因为操作系统在内存上只有一份,所以内核级页表也只有一份就够了。那么每个进程都可以在自己的地址空间内的内核空间处,通过内核级页表访问到操作系统的代码和数据。

注意:这里每个进程都有自己独立的用户空间,因为操作系统只有一份,且每个进程看到的内核空间都是一样的,所以内核空间是操作系统通过特殊方式映射到虚拟地址空间3-4G处的。通俗点说就是东西虽然是一样的,但是你也有一份,我也有一份。无论进程怎么切换,是不会更改这个内核空间的。

我们回到问题,进程想访问操作系统的接口,其实只需要在自己的地址空间上进行跳转即可。也就说进程执行内核级代码就跟执行共享区代码一样,比如代码执行遇到了系统接口进程会跳转到内核空间,通过内核级页表找到操作系统的代码,执行完后再由内核空间跳转回对应的代码处继续执行,这就是整体流程。

 信号捕捉流程
 

我们之前说过信号的产生不会被立即处理,而是在合适的时候,这个合适的时候是在哪呢?在内核态返回用户态的时候处理,在处理的时候进程的状态曾经一定是内核态,那么状态的转变就需要进程用诸如系统调用之类的方法去转变。

如下图,进程在用户态执行着自己的代码,遇到系统调用接口,进而转变状态为内核态,去执行操作系统的代码,执行完毕后(那我来都来了,干这么点小活就走了成本太高,在干点别的),再返回用户态之前去进程中检查三张表,先查block表,如果信号被阻塞了不用管它,因为该信号暂时不会被递达;如果信号没有被阻塞,再看pending表,有没有收到信号,收到信号就去处理该信号;再看handler表里处理该信号的动作,如果是默认,就直接处理进程,如果是忽略,就什么都不做,完了然后再将pending表里该信号的那个比特位改成0,最后返回用户;如果是自定义动作,那么就要复杂一些,因为这个自定义动作是我们写的,所以这个代码在用户态。我们是以内核态的身份执行用户态的handler方法吗?当然不是,并且也不行,在之前的文章说过,操作系统不信任任何用户,他怕你这个handler方法里有一些会造成严重后果,需要越权的代码,所以操作系统需要通过一个特定的调用将状态转化为用户态,再去执行这个你写的handler方法。执行完毕后我们可以直接从handler方法返回到我们的代码处吗?当然不行,也做不到,所以执行完handler方法后会通过一个特定的调用再将状态转化为内核态,如果后面没有其他信号需要处理了,就从内核态返回用户态,到原来中断的代码处继续执行,这就是信号捕捉的全部流程。

 下图为快速记忆信号的捕捉过程:

        进程执行代码,因为系统接口需要转换状态去内核态执行操作系统代码,所以要转换状态,执行完操作系统代码,对信号进行检测,检测有没有信号被阻塞,有没有信号未决,默认和忽略直接执行该动作,完成后返回用户态;自定义动作需要特定的调用将状态转变为用户态去执行该信号的handler方法,执行完方法,如果没有其他信号要处理,再通过特定的调用转换成用户态,继续执行后续代码。

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。


信号集操作函数

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

 #include <signal.h> --- 头文件

int sigemptyset(sigset_t *set);

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

int sigfillset(sigset_t *set);

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

int sigaddset (sigset_t *set, int signo);

        初始化sigset_t变量之后就可以在调用sigaddset在该信号集中添加某种有效信号。

int sigdelset(sigset_t *set, int signo);

        初始化sigset_t变量之后就可以在调用sigdelset在该信号集中删除某种有效信号。

        注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态,前面四个函数都是成功返回0,出错返回-1。

int sigismember(const sigset_t *set, int signo);

        用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回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参数的可选值。

 1为添加,2为解除,3为重置

参数1:如何设置该进程的信号屏蔽字,

参数2:不为空,就以该参数为基准进行设置 ,

参数3:输出型参数,将原来的屏蔽字拷贝一份到oset里

sigpending函数

#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

演示:

        屏蔽2号信号,让我们可以从pending位图中看到2号信号被屏蔽

#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;

#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31

void show_pending(const sigset_t& pending)
{
    for(int signo = MAX_SIGNUM; signo>= 1;signo--)
    {
        if(sigismember(&pending,signo))
            cout << 1;
        else
            cout << 0;
    }
    cout << endl;
}
int main()
{
    //1.屏蔽制定的信号
    sigset_t block,oblock,pending;
    //1.1 初始化
    sigemptyset(&block);
    sigemptyset(&oblock);
    sigemptyset(&pending);

    //添加要屏蔽的信号
    sigaddset(&block,BLOCK_SIGNAL);

    //开始屏蔽,设置进内核(进程)
    sigprocmask(SIG_SETMASK,&block,&oblock);

    //2.打印pending信号集
    while(true)
    {
        //初始化
        sigemptyset(&pending);
        //获取
        sigpending(&pending);
        //打印
        show_pending(pending);

        sleep(1);
    }
    return 0;
}

从下图中可以看到,2号信号被阻塞了,未决在pending位图中没有递达给进程,而3号信号则可以递达给进程。

先屏蔽2号信号,过10秒后再解除二号信号,会发生什么事情

#include <iostream>
#include <vector>
#include <unistd.h>
#include <signal.h>
using namespace std;

// #define BLOCK_SIGNAL 2
#define MAX_SIGNUM 42

//把想屏蔽的信号放入数组
static vector<int> signum = {2};

void show_pending(const sigset_t& pending)
{
    for(int signo = MAX_SIGNUM; signo>= 1;signo--)
    {
        if(sigismember(&pending,signo))
            cout << 1;
        else
            cout << 0;
    }
    cout << endl;
}
int main()
{
    //1.屏蔽制定的信号
    sigset_t block,oblock,pending;
    //1.1 初始化
    sigemptyset(&block);
    sigemptyset(&oblock);
    sigemptyset(&pending);

    //添加要屏蔽的信号
    for(const auto& sig:signum)
    {
        sigaddset(&block,sig);
    }

    //开始屏蔽,设置进内核(进程)
    sigprocmask(SIG_SETMASK,&block,&oblock);

    cout << getpid() << endl;
    //2.打印pending信号集
    int cnt = 10;
    while(true)
    {
        //初始化
        sigemptyset(&pending);
        //获取
        sigpending(&pending);
        //打印
        show_pending(pending);

        sleep(1);
        if(--cnt == 0)
        {
            cout << "解除对信号的屏蔽 " << endl;
            //一旦对特定信号解除屏蔽,一般操作系统至少立马递达一个信号!
            sigprocmask(SIG_SETMASK,&oblock,&block);
            cout << "hello" << endl;
        }
    }
    return 0;
}

 从下图中可以看到,我们屏蔽了2号信号,等10秒后解除了对所有信号的屏蔽,进程就退出了,那么这里有个问题,为什么我们解除屏蔽对信号的屏蔽后,后面那句话没有被打印呢?

因为2号信号之前被阻塞,一直保存在pending位图中,你解除对信号的屏蔽后,操作系统从内核态返回用户态时,顺手就帮你递达了2号信号,进程接收到2号信号,执行默认动作,直接终止进程。

 设置对2号信号的自定义动作,添加对信号的捕捉,和自定义动作。

static void myhandler(int signo)
{
    cout << signo <<"号信号解除阻塞 " << endl;

}   
 //捕捉信号,做自定义动作
    for(const auto& sig:signum)
    {
        signal(sig,myhandler);
    }

 从下图中可以看到,我们先设置对2号信号的屏蔽,10秒后解除屏蔽,2号信号被抵达,进程执行自定义动作,没有被终止,还在运行。


 sigaction函数

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。

参数1:signo是指定信号的编号。

参数2:若act指针非空,则根据act修改该信号的处理动作。

参数3:若oact指针非 空,则通过oact传出该信号原来的处理动作。

使用sigzction函数捕捉2号信号 

#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;

void myhandler(int signo)
{
    cout << signo << "号信号被捕捉 " << endl;
}
int main()
{
    struct sigaction act,oact;
    act.sa_handler = myhandler;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);
    sigaction(SIGINT,&act,&oact);
    while(true)
    {
        sleep(1);
    }
    return 0;
}

 可以看到2号信号被捕捉到了

 那么这里有个问题,我发送多个重复信号会不会都被捕捉呢?

先给代码加个倒计时,再看结果。

//倒计时
void count(int cnt)
{
    while(cnt--)
    {
        printf("倒计时:%d\n",cnt);
        // fflush(stdout);
        sleep(1);
    }
}
void myhandler(int signo)
{
    cout << signo << "号信号被捕捉 " << endl;
    count(5);
}

 看下图,我们发送第一个2号信号时,被捕捉到了,后面又连续发送了很多个2号信号,但是却只捕捉到了一次,为什么呢?

信号在被递达前,会先将pending位图中该信号的比特位由1置0,而后递达信号执行自定义动作,在执行自定义动作时我们又给进程发送了很多个重复的信号,该进程还是在执行自定义动作,为什么呢?

因为进程在递达某个信号期间,同类型信号无法被递达。因为操作系统会自动将当前信号加入进程的信号屏蔽字中,也就是说递达某个信号时,同类型信号自动会被阻塞。等进程完成自定义动作,操作系统又会自动解除对该信号的屏蔽,我们前面说过解除对某个信号的屏蔽后,会自动递达该信号,那么由于pending位图对单个信号只能保存一次,所以后续又递达了一次2号信号就没了。

由此可以得出一个结论:进程处理信号的原则是串行的处理同类型信号,不允许递归。

那么这里我们在处理某个信号时,也想顺便屏蔽其他信号,就可以添加到这个sa_mask中。

可以看到,2号信号在执行动作时,3号信号也被屏蔽了 ,只能等2号信号处理完动作,才会解除对2号,3号信号的屏蔽,解除屏蔽后,3号信号被递达,执行默认动作,终止进程。

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,这里暂时默认把sa_flags设为0,sa_sigaction是实时信号的处理函数,这里不详细解释这两个字段,有兴趣可以自己查一查。

可重入函数

main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是 main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了,而node2无法被访问到,造成内存泄漏。

一般而言main函数执行流和信号捕捉执行流是两个执行流。像上面这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数;反之,因为重入没有造成错乱,则称为可重入(Reentrant) 函数

我们目前大部分情况下用的的接口,全部都是不可重入的。

如果一个函数符合以下条件之一则是不可重入的:

        调用了malloc或free,因为malloc也是用全局链表来管理堆的。
        调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

volatile ---C语言关键字

作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量
的任何操作,都必须在真实的内存中进行操作。

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

int quit = 0;
void handler(int signo)
{
    printf("%d 信号被捕捉 \n",signo);
    printf("quit : %d",quit);
    quit = 1;
    printf("->  %d\n",quit);
}
int main()
{
    signal(2,handler);

    while(!quit);
    printf("进程退出\n");
    return 0;
}

一般情况下,我们给进程发送2号信号,捕捉到2号信号,执行自定义动作更改quit,因为quit被更改,不满足循环要求,所以进程就结束了。这很正常对吧。


但是我们给编译选项加上 -O3 会发生什么事情呢?

 

 从下图可以看到,本来应该是进程捕捉到2号信号,执行自定义动作更改quit,quit不满足循环要求,进程就结束。现在进程却没有退出,我们可以很确认的是quit已经被更改了,但是进程为什么没有结束呢?

 我们画一个简图解释一下:

出现这种情况是编译器优化的效果,优化就是编译器觉得在main执行流中,while循环的判断条件quit只被做检测,没有被修改,所以编译器就建议把quit的值直接放到CPU的寄存器中暂时保存起来,要是while循环做判断,CPU就直接用寄存器里的值进行判断,不用每次都将quit的值从内存加载到CPU中了。因为每次判断都用寄存器中的值,你执行信号捕捉自定义方法把quit 的值改了,改的是内存中的值,而寄存器中的值是临时变量,你改内存中的值又没有改变寄存器中的值,所以就会出现我明明改了quit的值,但是循环却还在继续。

那么要想避免出现这种情况,就需要volatile 关键字,他的作用是保持内存可见性,什么意思呢?

        就是说虽然main函数执行流中不更改quit的值,但是你检测时也别自作主张的把quit这个值优化到寄存器中,我要每一次检测都从内存里读取quit的值,而不是读取时,通过寄存器来覆盖我的物理内存当中的某个变量,这就叫做保持内存可见性。

可以看到进程就正常结束了。

SIGCHLD信号 ---了解

在进程那里讲过用 wait 和 waitpid 函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。

其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。


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

 这里还有一个细节需要注意,17(SIGCHLD)这个信号默认动作就是忽略呀,为什么我们还要手动的去忽略呢?

首先系统默认忽略的特性和我们手动设置忽略的特性是不一样的,我们要知道的是,如果出现了一些事情和你预想的不一样时,那么操作系统一定进行了处理。因为我们设置信号在创建子进程之前,那么操作系统在创建子进程时会识别你对该子进程的处理动作是什么,如果你手动的设置了SIG_IGN(忽略),那么操作系统就设置让子进程退出时自动回收,不会变僵尸,也不会发信号给父进程,如果你没有设置,那么该子进程就跟正常的一样,需要父进程去等待回收。这两个忽略的含义是不一样的,就如有些地方的方言,长虫本意是长的虫子,但也有蛇的意思。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

晚风不及你的笑427

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值