【Linux】进程信号


  • 📝 个人主页超人不会飞)
  • 📑 本文收录专栏:《Linux》
  • 💭 如果本文对您有帮助,不妨点赞、收藏、关注支持博主,我们一起进步,共同成长!

进程信号

开门见山,概念:

在操作系统中,进程信号(signals)是一种以异步方式通知进程发生特定事件的软件通信机制。OS或其它进程可以向目标进程发生通知,无需目标进程主动请求或轮询。

🔎 用kill -l指令可以查询OS定义的信号列表。之前学习进程时,kill -9杀死进程,就是向进程发送该列表中的9号信号。

在这里插入图片描述

1~31是普通信号,34~64是实时信号。本文只讨论普通信号。

什么是异步?

💭信号以异步的方式发送和接收。异步指的是信号的发送方和接收方不用同时进行或保持同步。发送方可以在发送信号后继续执行其他任务,而不必等待接收方的立即响应。接收方在接收到信号后,可以在适当的时间进行处理,而不必立即回复,因为当前可能有优先级更高的任务等着接收方去处理,而不是处理信号。

举个生活中的例子。你点了个外卖,但是你无法确定外面到达的准确时间,所以你开了一把游戏,然而游戏还没打完,外卖到了,外卖小哥打电话让你去取,通常情况下,你不会立刻去取,而是等打完这把游戏再去。这个场景中,信号是“外卖小哥的电话”,接收方是你,你当前有优先级更高的事情:打游戏。发送方是外卖小哥,他放好外卖,打完电话就走了,不会等你来拿,因为他也有优先级更高的事,比如派送下一单。这样的情况,你们就达成了“异步”。信号从外卖小哥产生,信号的处理动作是“出门取外卖”,而从产生到处理还有一个时间窗口,因为你要打完这把游戏,你必须在这个时间内记住“取外卖”这件事,这叫信号的保存。

因此,本文将分三个阶段学习Linux中的信号,即:信号产生 → \rightarrow 信号保存 → \rightarrow 信号处理


信号的产生

1. 信号产生的五种方式

  1. 键盘终端输入

    写一个死循环程序

    #include <iostream>
    #include <unistd.h>
    using namespace std;
    
    int main()
    {
        while(true)
        {
            cout << "process is running..." << endl;
            sleep(1);
        }
    
        return 0;
    }
    

    运行,然后通过键盘组合键ctrl+cctrl+\向进程发送信号,终止该进程。ctrl+c产生2号信号SIGINT,ctrl+\产生3号信号SIGQUIT

    [ckf@VM-8-3-centos lesson8_signal]$ ./mysignal 
    process is running...
    process is running...
    process is running...
    ^C #发送2号信号,进程终止
    [ckf@VM-8-3-centos lesson8_signal]$ ./mysignal 
    process is running...
    process is running...
    process is running...
    ^\Quit #发送3号信号,进程终止
    

    💡原理:

    当你按下键盘,键盘会触发硬件中断,并向CPU发送这个中断,CPU执行对应的中断处理方法,从键盘中读取对应的数据,输入到OS中。OS将类似于ctrl+cctrl+\这样的组合解释成一个信号,发送给前台进程。前台进程因为收到信号,进而终止。值得注意的是,使用键盘组合键产生的信号只能发送给前台进程。

    一个bash可以同时有一个前台进程和多个后台进程,前台进程运行时,命令行解释器无法执行命令。后台进程运行不影响命令行解释器的使用。运行程序时指令追加一个&即可在后台运行,如:./mysignal &

  2. bash指令

    kill -signo pid

  3. 系统调用

    kill —— 向指定进程发送指定信号

    SYNOPSIS
           #include <sys/types.h>
           #include <signal.h>
    
           int kill(pid_t pid, int sig);
    RETURN VALUE
           On success (at least one signal was sent), zero is returned.  On error, -1 is returned, and  errno  is  set appropriately.
    

    实际上,kill命令底层就是封装了kill系统调用,因此我们可以写一个简单的mykill程序。

    #include <iostream>
    #include <cassert>
    #include <sys/types.h>
    #include <signal.h>
    #include <cerrno>
    #include <cstring>
    
    using namespace std;
    
    //通过命令行参数,获取想要发送的信号和目标进程pid
    int main(int argc, char *argv[])
    {
        // argv[0] ./mykill
        // argv[1] signal
        // argv[2] pid
        assert(argc == 3);
    
        int signo = atoi(argv[1]);
        int pid = atoi(argv[2]);
    
        int ret = kill(pid,signo); //调用系统调用kill,向pid进程发送信号signo
        if(ret < 0)
        {
            perror("kill fail");
            return 1;
        }
    
        return 0;
    }
    

    运行mysignal程序,并用mykill向其发送9号信号终止进程
    在这里插入图片描述

    💭两个底层封装系统调用kill的C库函数

    raise —— 向当前进程发送指定信号

    SYNOPSIS
           #include <signal.h>
    
           int raise(int sig);
    RETURN VALUE
           raise() returns 0 on success, and nonzero for failure.
    

    abort —— 向当前进程发送6号信号SIGABRT

    SYNOPSIS
           #include <stdlib.h>
    
           void abort(void);
    RETURN VALUE
           The abort() function never returns.
    
  4. 软件条件

    💨 管道通信时,读端关闭,OS会向写端发送13号信号SIGPIPE,退出写端,这就是一种由软件条件产生的信号。下面介绍另外一种,alarm系统调用和SIGALRM信号。

    alarm —— 为当前进程设置一个seconds秒的闹钟,时间一到,向当前进程发送14号信号SIGALRM。

    NAME
           alarm - set an alarm clock for delivery of a signal
    
    SYNOPSIS
           #include <unistd.h>
    
           unsigned int alarm(unsigned int seconds);
    RETURN VALUE
           alarm() returns the number of seconds remaining until any previously scheduled alarm was due to be delivered, or zero if there was no previously scheduled alarm.
    

    设置闹钟时,返回先前设置任意一个的闹钟剩余的秒数,若先前没有闹钟,则返回。

    #include <iostream>
    #include <unistd.h>
    #include <signal.h>
    using namespace std;
    
    
    int main()
    {
        alarm(10);
    
        int cnt = 0;
        while(true)
        {
            cout << "process is running... pid: " << getpid() << endl;
            if(cnt++ == 5)
            {
                int rs = alarm(5);
                cout << "设置闹钟成功,之前闹钟剩余的秒数是:" << rs << endl;
            }
            sleep(1);
        }
    
        return 0;
    }
    
    [ckf@VM-8-3-centos lesson8_signal]$ ./mysignal #运行结果如下
    process is running... pid: 10648
    process is running... pid: 10648
    process is running... pid: 10648
    process is running... pid: 10648
    process is running... pid: 10648
    process is running... pid: 10648
    设置闹钟成功,之前闹钟剩余的秒数是:5
    process is running... pid: 10648
    process is running... pid: 10648
    process is running... pid: 10648
    process is running... pid: 10648
    Alarm clock
    

​ 实际上,进程由alarm闹钟是由OS管理的,一个进程可以有多个闹钟,OS将这些闹钟以先描述再组织的方式管理起来,并每隔一段时间检测闹钟是否计时结束,是则关闭该闹钟,释放其在内存中的空间,并向该闹钟对应的进程发送SIGALRM信号。

  1. 硬件异常

    硬件异常通常是硬件产生某种错误信息,检测到之后通知OS,OS根据对应的错误发送信号给当前进程。常见的硬件异常如下:

    除零错误: 当前进程的上下文信息中,有一个状态寄存器标志为溢出状态(CPU的寄存器)。OS识别到该状态标记,向当前进程发送信号SIGFPE。

    野指针: 如解引用空指针,MMU无法在页表上找到nullptr对应的物理地址,因此MMU硬件报错。CPU触发相应的中断,执行中断处理方法OS识别到当前进程上下文的MMU报错,向其发送信号SIGSEGV。

    tips:MMU是CPU中完成虚拟地址到物理地址的转换的硬件。页表的转化机制= MMU (实现的硬件) + 内存中的映射记录表(实现依据)。

    为了模拟上面出错的情况,先简单认识一个函数:signal

    SYNOPSIS
           #include <signal.h>
    
           typedef void (*sighandler_t)(int);
    
           sighandler_t signal(int signum, sighandler_t handler);
    
    

    signal信号捕捉函数,可以更改signum号信号的处理方法,该函数方法可由用户自定义,函数返回值为void,参数是一个int。使用signal捕捉信号时传入自定义处理方法的函数指针,处理信号时OS会自动调用,并传入参数信号的编号signo。

    💬模拟野指针问题:

    #include <iostream>
    #include <unistd.h>
    #include <signal.h>
    using namespace std;
    
    void handler(int signo)
    {
        cout << "处理信号signo: " << signo << endl;
        sleep(1);
    }
    
    int main()
    {
        // 先绑定信号SIGSEGV的处理方法
        signal(SIGSEGV, handler);
    
        // 发生野指针错误
        int *p = nullptr;
        *p = 10;
    
        // 打印一句话,看看程序能否运行到这
        cout << "野指针错误" << endl;
    
        return 0;
    }
    

    💨 运行程序,发现进程确实收到了11号信号SIGSEGV。由于我们自定义的信号处理方法并没有让进程退出,因此按理来说,处理完信号会继续执行下面的代码,打印"野指针错误"这句话。但结果并非如此,现象是:进程不断收到11号信号并处理。原因:MMU硬件报错后,进程并未退出,因此MMU的出错状态一直维持着,OS不断检测到硬件异常,不断向当前进程发送11号信号。因此进程会不断收到11号信号,不断调用handler函数。

    [ckf@VM-8-3-centos lesson8_signal]$ ./mysignal 
    处理信号signo: 11
    处理信号signo: 11
    处理信号signo: 11
    处理信号signo: 11
    处理信号signo: 11
    处理信号signo: 11
    处理信号signo: 11
    处理信号signo: 11
    处理信号signo: 11
    处理信号signo: 11
    处理信号signo: 11
    处理信号signo: 11
    ^C
    

    最终可以确认,我们在C/C++当中除零,内存越界等异常,在系统层面上,是被当成信号处理的。

2. core dump

💭先前学习进程等待时,提过waitpid的参数status是一个位图结构,且其中含有一个core dump位。如下:

在这里插入图片描述

下面来研究这个标志位存在的意义:

事实上,绝大多数的信号,最终处理动作都是让进程退出,既然作用相同,那OS为什么还要设置那么多不同的信号呢?很简单,退出的原因不同,产生的信号也要不同,这样才能让上层得知进程退出的原因。通过指令man -7 signal,可以查询不同的信号对应的默认处理动作。如下:

在这里插入图片描述

📝Action一列是信号的默认处理动作,其中,Term(terminate)是直接终止进程,而Core也是终止进程,除此之外,退出时会产生一个core二进制文件,这个文件会把进程的用户空间内存数据存储下来,事后可以通过调试器检查core文件以查清错误原因。 一般来说,异常终止(出现bug,如野指针、非法访问内存等)的进程会收到core类型的信号,方便后期debug。一般平台是不允许生成core文件的,一是因为core文件中可能包含用户密码等敏感信息,不安全,二是因为如果多个进程出现bug,会生成很多个core文件,占用磁盘空间。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。

[ckf@VM-8-3-centos lesson8_signal]$ ulimit -a #查看
core file size          (blocks, -c) 0 #core file size表示进程生成core文件的最大容量,为0默认不允许生成core文件。
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 7908
max locked memory       (kbytes, -l) unlimited
max memory size         (kbytes, -m) unlimited
open files                      (-n) 100001
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 7908
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited
[ckf@VM-8-3-centos lesson8_signal]$ ulimit -c 1024 #ulimit -c指令可以修改core file size的大小
[ckf@VM-8-3-centos lesson8_signal]$ ulimit -a #查看
core file size          (blocks, -c) 1024 #已修改
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 7908
max locked memory       (kbytes, -l) unlimited
max memory size         (kbytes, -m) unlimited
open files                      (-n) 100001
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 7908
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

运行这个程序,预期结果:程序崩溃(出现野指针问题),进程收到SIGSEGV信号,该信号Aciton为Core,会在当前目录生成一个core文件。

#include <iostream>
using namespace std;

int main()
{
    int *p = nullptr;
    *p = 10;
    
    return 0;
}

运行结果

[ckf@VM-8-3-centos lesson8_signal]$ ./mysignal 
Segmentation fault (core dumped) #发生了core dump
[ckf@VM-8-3-centos lesson8_signal]$ ll
total 272
-rw------- 1 ckf ckf 557056 Jul 30 16:34 core.16699 #确实生成了core文件,文件名格式是:core.pid
-rw-rw-r-- 1 ckf ckf     85 Jul 30 10:48 makefile
-rwxrwxr-x 1 ckf ckf   8712 Jul 30 16:34 mysignal
-rw-rw-r-- 1 ckf ckf    468 Jul 30 16:32 mysignal.cc

通过gdb检查core文件,可以获取程序的错误信息。

在这里插入图片描述


信号的保存

1. 信号的相关概念

  • 实际执行信号的处理动作称为信号**递达(**Delivery)

  • 信号从产生到递达之间的状态,称为信号未决(Pending)

  • 进程可以选择阻塞 (Block )某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.

2. 信号在内核中的存储

进程PCB中维护了三个结构,用以保存进程接收到的信号。

在这里插入图片描述

block表: 一个位图,比特位的下标代表信号的编号(1~31),比特位为0表示信号未阻塞,为1表示信号阻塞。

pending表: 一个位图,比特位的下标代表信号的编号(1~31),比特位为0表示未收到该信号,为1表示信号未决。

handler表: 一个函数指针数组,数组下标代表信号的编号,数组的内容代码信号对应的处理方法。SIG_DFL表示默认,SIG_IGN表示忽略,除此之外就是用户自定义方法。

注意:比特位为1称为有效位,为0称为无效位

3. 信号集sigset_t

用户可以通过sigset_t类型变量获取进程的block和pending位图,这个类型称为信号集,称进程的block位图为信号屏蔽字(阻塞信号集),pending位图为未决信号集。

# define _SIGSET_NWORDS	(1024 / (8 * sizeof (unsigned long int)))
typedef struct
  {
    unsigned long int __val[_SIGSET_NWORDS];
  } __sigset_t;

sigset_t底层是一个结构体,封装了一个long int类型数组,计算可得一共有32个long int元素,即1024个比特位的位图。通常我们只用1~31位,代表block和pending位图。

sigset_t的操作函数:

#include <signal.h>

//set指向目标信号集
int sigemptyset(sigset_t *set); // 清空信号集(位图全置0)

int sigfillset(sigset_t *set); // 填满信号集(位图全置1)
//前两个一般对信号集作初始化工作。

int sigaddset(sigset_t *set, int signum); // 增加信号signum(对应比特位置1)

int sigdelset(sigset_t *set, int signum); // 删除信号signum(对应比特位置0)

int sigismember(const sigset_t *set, int signum); // 判断signum是否在信号集内

返回值:前四个成功返回0,失败返回-1。sigismember,判断signum在信号集内返回1,反之返回0,出错返回-1。

⭕前面有关信号集的类型和函数都属于用户层,而下面是有关信号屏蔽字和未决信号集的两个系统调用接口

  1. sigprocmask

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

    NAME
           sigprocmask - examine and change blocked signals
    
    SYNOPSIS
           #include <signal.h>
    
           int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
    

函数介绍:

set和oldset是两个输出型参数。

若oldset是非空指针,set是空指针,则只读取当前进程的信号屏蔽字并通过oldset传出。

若set是非空指针,oldset是空指针,根据how的指示,使用set修改进程的信号屏蔽字。

若二者都非空,先将旧的信号屏蔽字通过oldset传出后,再按how的指示,使用set修改进程的信号屏蔽字。

howaction
SIG_BLOCK设置阻塞,set中的有效位即为我们想要设置阻塞的信号
SIG_UNBLOCK解除阻塞,set中的有效位即为我们想要解除阻塞的信号
SIG_SETMASK设置当前信号屏蔽字为set指向的值。
  1. sigpending

    调用sigpending获取未决信号集,传入set指向的值。

    NAME
           sigpending - examine pending signals
    
    SYNOPSIS
           #include <signal.h>
    
           int sigpending(sigset_t *set);
    

信号的处理

在学习信号的处理之前,首先要简单了解一下进程的两个执行级别:用户态和内核态。

1. 用户态和内核态

🔎重新认识进程的地址空间。

在这里插入图片描述

如图,进程的地址空间实际分为用户空间和内核空间。在32位的机器中,0~3G是用户空间,3G~4G是内核空间。操作系统的代码和数据保存在物理内存中,而进程的内核空间中存放的是OS各种接口的虚拟地址,通过一个内核级页表建立映射关系。所以实际上,之前谈的操作系统在运行,其实本质上OS是在进程的地址空间中运行的,调用内核空间(3G~4G)中的各种接口。在Linux中,1号进程用于运行一些OS的私有任务,如定期维护一些系统数据信息。因此内存中至少会有一个1号进程,不会让操作系统停止运行。

进程调用系统调用接口,也是在自己的地址空间中跳转的,即跳转到内核空间中对应的函数。那么这样一来,进程岂不是可以任意访问操作系统的代码和数据了?不,OS的设计者不允许这种事的发生。因此,进程会有两个执行级别:用户态内核态。运行用户的代码时是用户态,只有当进程处于内核态时,才能访问OS的代码和数据。实际上,OS提供的所有系统调用,在正式执行调用逻辑之前,都会先去修改执行级别,即从用户态到内核态。

用户态切换到内核态的过程,由软硬件(OS和CPU)结合完成,CPU中有一个状态寄存器CR3,其值为0时,进程的执行级别是内核态,其值为3时是用户态。

  1. CPU检测到系统调用指令
  2. 保存用户态的上下文信息到OS中
  3. CPU触发中断或异常
  4. 执行OS中对应的异常处理程序,其中一项工作是让CR3的值3->0
  5. 完成从用户态到内核态的切换。

执行完系统调用,进程便从内核态切换回用户态。

2. 信号的捕捉

进程中若有未决且不被阻塞的信号,它们会在合适的时机被捕捉并处理。这个合适的时机,指的是:从内核态切换到用户态。

💭从内核态切换到用户态,前提是进程得先能进入内核态,那么如何保证进程一定有陷入内核态的时候?

除了调用系统接口,CPU对进程的调度,时间片轮转,也会让进程陷入内核。进程如何被调度的?如下:

  1. 时钟硬件每隔一段很短的时间向CPU发送时间中断
  2. CPU收到时间中断,执行处理方法:检测当前进程的时间片(系统调用,在当前进程的地址空间中执行),到了,则调用schedule函数(也是系统调用,保存当前进程上下文信息),调度当前进程(阻塞、挂起等),并切换下一个进程。没到则不做处理。

由于进程的调度过程必定会涉及系统调用,因此进程总是会有陷入内核态的"时机"。

信号的捕捉过程如下:

在这里插入图片描述

📝 总体流程:

  1. 进程代码执行过程中,陷入内核。(系统调用,时间片轮转等原因)
  2. 执行内核中的某个任务
  3. 执行完毕后,要从内核态切换回用户态,而在此之前,需要先检测当前进程有无需要处理的信号,即非阻塞未决信号。
  4. 检测不到,则直接切换回用户态。检测到有需要处理的信号,且该信号的处理方法是SIG_DFL或SIG_IGN,则执行内核中对应的处理方法,再切回用户态。
  5. 检测到有需要处理的信号,且该信号的处理方法是用户自定义的。由于方法在用户态,为了避免用户代码非法访问系统数据,所以要切换到用户态执行自定义的信号处理方法handler。
  6. handler执行完毕后,执行系统调用sigreturn,再次进入内核。
  7. 恢复用户态上下文信息,返回用户态(此处内核态切回用户态,还会捕捉信号,做法和上面一样),回到主执行流中上次被中断的地方,继续向下执行。

补充细节:

  1. 进程每次从内核态切换回用户态都会捕捉信号;
  2. 从内核态切回用户态执行信号处理方法handler前,OS先将要处理的那个信号在未决信号集中置为0;
  3. 用户自定义处理信号时,会对该信号屏蔽(阻塞),处理完毕后解除屏蔽,防止递归地处理信号。 如果不这样做,举个例子:向一个进程发送了2号信号(处理方法绑定为用户自定义的handler),处理该信号时,调用handler,而handler执行时又有人向该进程发送2号信号,此时handler执行流也有可能陷入内核,走一遍信号捕捉的流程,发现2号信号需要处理,又调用了handler。如此递归下去后果不堪设想!所以应该保证在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止
  4. 用户自定义处理信号后,无法直接从handler执行流回到主执行流,因为主执行流的上下文信息被保存在OS中。因此要先回到内核态再返回用户态的主执行流。
  5. 一次捕捉,即一次从内核态到用户态,至多处理一个信号。
  6. Linux操作系统在处理多个未决信号时,可能会采用随机顺序或按照先进先出的排队方式进行处理。

3. signal和sigaction

上文提到的signal函数,其本质就是修改内核中进程维护的handler函数指针数组的元素,如:signal(2,handler),就是将当前进程的handler表中,下标为2的元素修改为指向handler函数的指针。接下来介绍一个sigaction,作用与signal类似,不过更丰富。

sigaction

NAME
       sigaction - examine and change a signal action

SYNOPSIS
       #include <signal.h>

       int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

RETURN VALUE
       sigaction() returns 0 on success; on error, -1 is returned, and errno is set to indicate the error.

其中,参数类型struct sigaction是一个结构体

The sigaction structure is defined as something like:

           struct sigaction {
               void     (*sa_handler)(int); // 用户自定义方法的函数指针
               void     (*sa_sigaction)(int, siginfo_t *, void *);
               sigset_t   sa_mask; // 信号处理时,除了当前信号,额外屏蔽的信号
               int        sa_flags;
               void     (*sa_restorer)(void);
           };

💬设计一个程序:2号信号处理时,同时屏蔽3、4、5号信号。2号信号绑定处理方法handler。观察不同时间段,进程的未决信号集。

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

void showPending()
{
    printf("进程%d的pending表: ", getpid());
    sigset_t set;
    sigemptyset(&set);
    // 查看pending
    sigpending(&set);
    for (int i = 31; i >= 1; i--)
    {
        if (sigismember(&set, i))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}

void handler(int signo)
{
    printf("handler is running... signo: %d\n", signo);

    // 此时向进程发送2~5号信号,观察现象
    int cnt = 20;
    while (cnt--)
    {
        showPending();
        sleep(1);
    }
}

int main()
{

    // 阻塞2号信号
    sigset_t set, oset;
    sigemptyset(&set);
    sigaddset(&set, SIGINT);
    sigprocmask(SIG_SETMASK, &set, &oset);

    // 绑定2号信号的处理方法为handler
    struct sigaction act, oldact;
    memset(&act, 0, sizeof(act));
    act.sa_handler = handler;
    act.sa_flags = 0;
    // 并设置2号信号处理的同时,阻塞3、4、5号信号
    sigaddset(&act.sa_mask, 3);
    sigaddset(&act.sa_mask, 4);
    sigaddset(&act.sa_mask, 5);
    sigaction(2, &act, &oldact);

    int cnt = 5;
    while (true)
    {
        if (cnt-- == 0)
        {
            // 解除对2号信号的阻塞
            sigprocmask(SIG_SETMASK, &oset, &set);
        }
        showPending();

        sleep(1);
    }

    return 0;
}

💨运行结果

这里同时也验证了,“从内核态切回用户态执行信号处理方法handler前,OS先将要目标信号在未决信号集中置为0”。

在这里插入图片描述


补充知识

1. 可重入函数

在这里插入图片描述

像上图一样,p1、p2、head指针指向链表的节点,都为全局变量。front_insert函数在main执行流中被调用了,但是调用还没返回时,另一个handler执行流也进入了front_insert函数,这称为重入。由于front_insert操作的是全局链表,因此可能会引发错误,如下:

在这里插入图片描述

因此,为了避免这些潜在的问题,就有了可重入函数与不可重入函数的概念:

可重入函数: 函数中只访问局部变量或参数,没有全局变量。

不可重入函数: 函数中访问了全局变量或静态变量。

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

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

2. volatile

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

int quit = 0;

void handler(int signo)
{
    cout << "处理信号signo: " << signo << " quit已修改为1" << endl;
    quit = 1;
}

int main()
{
    signal(SIGINT, handler);

    while (!quit);

    cout << "quit" << endl;
    return 0;
}

CPU检测到main主执行流中,只访问全局变量quit而不修改。实际上这是编译器的功劳,编译器编译时,发现main函数中只对quit不停做检测,如果CPU每次都要从内存中加载quit变量到寄存器中,IO次数太多,效率低,因此编译器可能会进行优化,让CPU只加载一次quit变量,往后访问quit都是访问寄存器里的值,内存中改变其值,寄存器中的值不变。

因此,运行上面这份代码,现象是:进程若收到2号信号,handler执行流中修改quit为1,进程不会如预料之中的结束while循环并退出,因为while检测的quit始终是CPU寄存器里的值0。

⭕运行结果

[ckf@VM-8-3-centos lesson8_signal]$ make
g++ mysignal.cc -o mysignal -std=c++11 -O2 #-O2选项,编译器优化
[ckf@VM-8-3-centos lesson8_signal]$ ./mysignal 
^C处理信号signo: 2 quit已修改为1 #发送2号信号,进程处理信号完毕后不会退出
^C处理信号signo: 2 quit已修改为1
^C处理信号signo: 2 quit已修改为1
^C处理信号signo: 2 quit已修改为1
^C处理信号signo: 2 quit已修改为1
^C处理信号signo: 2 quit已修改为1
^\Quit

这时就需要volatile关键字了。

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

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

volatile int quit = 0; // volatile修饰quit变量

void handler(int signo)
{
    cout << "处理信号signo: " << signo << " quit已修改为1" << endl;
    quit = 1;
}

int main()
{
    signal(SIGINT, handler);

    while (!quit);

    cout << "quit" << endl;
    return 0;
}

⭕运行结果

[ckf@VM-8-3-centos lesson8_signal]$ ./mysignal 
^C处理信号signo: 2 quit已修改为1
quit

3. SIGCHLD

子进程退出时,会向父进程发送SIGCHLD(17)信号。前面我们学过两种父进程等待子进程的方法:阻塞等待和非阻塞等待。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。因此可以用signal或sigaction函数,自定义SIGCHLD信号的处理动作,让父进程在捕捉到该信号时,再去等待子进程。

💭编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定 义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cassert>
using namespace std;

pid_t child_pid;
int status = 0;

void handler(int signo)
{
    // 收到SIGCHLD信号,子进程必定已经退出,等待回收子进程
    cout << "wait child process success!!" << endl;
    waitpid(child_pid, &status, 0);

    cout << "exit code: " << ((status >> 8) & 0xff) << endl;
    cout << "signal: " << (status & 0x7f) << endl;
}

int main()
{
    signal(SIGCHLD, handler);
    pid_t id = fork();
    assert(id >= 0);

    child_pid = id;
    if (id == 0)
    {
        // 子进程5s后退出
        int cnt = 5;
        while (cnt--)
        {
            cout << "child process is running..." << endl;
            sleep(1);
        }
        exit(2);
    }

    while (true)
    {
        cout << "parent process is do something..." << endl;
        sleep(1);
    }

    return 0;
}

⭕运行结果

[ckf@VM-8-3-centos lesson8_signal]$ ./mysigchild 
parent process is do something...
child process is running...
parent process is do something...
child process is running...
parent process is do something...
child process is running...
parent process is do something...
child process is running...
parent process is do something...
child process is running...
parent process is do something...
wait child process success!! #成功回收子进程,打印退出信息如下
exit code: 2
signal: 0
parent process is do something...
parent process is do something...
parent process is do something...
parent process is do something...
^C

ENDING…

评论 15
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值