Linux系统编程:进程信号的处理

目录

一. 用户态和内核态

1.1 用户态和内核态的概念

1.2 用户态和内核态之间的切换

二. 信号的捕捉和处理

2.1 捕捉信号的时机

2.2 多次向进程发送同一信号

2.3 sigaction 函数 

三. 可重入函数和不可重入函数

四. volatile 关键字

五. SIGCHLD信号

5.1 SIGCHLD信号的意义

5.2 通过SIGCHLD信号等待子进程

5.3 对于SIGCHLD信号的SIG_DFL和SIG_IGN处理方法 

六. 总结


一. 用户态和内核态

1.1 用户态和内核态的概念

用户态: 

  • 用户执行属于用户自身的代码(非操作系统内核代码)的状态,称为用户态。
  • 用户态的权限是受到管控的,在用户态中,OS内部的资源不能被随便访问,不能执行操作系统的内核级代码。
  • 用户态下,进程只能访问到属于用户本身的进程地址空间及对应的物理内存空间。

内核态:

  • 内核态,指进程嵌入到OS的内核中去,执行OS的内核级代码,访问OS的内部资源的状态。
  • 内核态具有最高的优先级,我们常说,进程在接收到信号后可能不会立刻处理,不立刻处理可能就是因为当前的状态为内核态,要等到用户态时,采取响应信号。

区分内核态和用户态的目的:对操作系统的内资源进行保护,由于用户只能在用户态下运行属于其自身的代码,这样就防止了恶意程序修改和盗取操作系统内部的资源,保证安全性。

1.2 用户态和内核态之间的切换

由于中断、系统调用、异常等原因,进程需要去访问OS的内部资源,这样就会发生由用户态到内核态之间的转换。执行完OS的内核代码后,就会从内核态再变回用户态。

用户态到内核态之间的切换,其底层的实现可以通过 进程地址空间 + 页表 来理解,如图1.1所示,每一个进程都有属于其自身的、独立的4G的进程地址空间,其中0~3G为用户空间,3~4G为内核空间,它们分别映射到用户自身使用的物理内存和操作系统内核级资源使用的物理内存

Linux系统编程:进程信号的处理_第1张图片 图1.1 进程地址空间和物理内存之间的映射关系

每个进程地址空间会使用2张页表,一张为用户级页表,用于用户空间映射属于用户的资源,一张为内核级页表,用于内核空间内存OS资源,每个进程都有一张独立的用户级页表,而一张内核级页表可以由多个进程共享,由用户态到内核态,就是执行用户代码向执行内核代码的转变,即:在进程地址空间中,从访问用户空间到访问内核空间的转变,用户态到内核态转换完成后,进程就可以通过内核级地址空间,通过内核级页表,映射访问到OS的内部资源。等到

结论:无论是进程是在用户态执行属于用户自身的代码,还是在内核态执行OS的内核级代码,都是在进程地址空间的上下文中运行的。

所有的计算,都是在CPU中进行的,如果想要执行OS的内核代码,就必须获取到物理内存中的OS区域的数据,并将其拿到CPU的寄存器中,那么,如何知道是否有权限访问OS资源,即如何确定当前状态是否为内核态?

由用户态切换到内核态的第一步,是通过汇编指令INT 80,来进行状态的切换,INT 80执行后,进程就具备了访问OS内核资源的权利。同时,CPU中的寄存器,也会记录当前所处的状态是用户态还是内核态。

CPU中有两套寄存器,一套为可见寄存器,一套为不可见寄存器:

  • 可见寄存区:如ebp、esp,对于用户来说是可见的,用户可以读取或改写寄存器中的内容。
  • 不可见寄存器:只能由CPU自身使用,对于用户是不可见的。

CPU中的CR3寄存器(不可见寄存器)就是用来表示当前CPU的执行权限的,CR3中记录不同的值,用于表示用户态或者是内核态。

总结,由内核态,到用户态再到内核态的转变过程中,执行的操作流程为:

  • Step1:由中断、异常、系统调用等触发用户态到内核态的转变开始。
  • Step2:执行汇编指令INT 80进入内核态,将CPU中的CR3寄存器的值进行改写,使CPU的执行权限提高到内核态。
  • Step3:在内核态状态下,执行OS内核代码。
  • Step4:内核代码执行完毕,由内核态再回到用户态。

二. 信号的捕捉和处理

2.1 捕捉信号的时机

捕捉信号,就要读取进程PCB中的block表和pending表的信息,来确定某个信号是否产生,以及是否被阻塞。由于进程PCB属于内核级数据,因此只有处于内核态的时候,才有去读进程PCB数据的权限,才能对信号进行捕捉。

结论1:对信号的捕捉一定是在内核态下进行的。

如果某个信号产生了,又处于阻塞状态,那么就需要对信号进行响应,由于用户可以自定义针对特定信号的处理方法,而用户自定义的处理信号的函数一定要在用户态下运行,因此,如果用户自定义了信号处理函数,从捕捉到信号到响应信号的过程中,会涉及到由内核态到用户态的转变

结论2:信号是在内核态向用户态转变的时候进行处理的。 

但是,如果相应信号的方式是默认或忽略,那么就不会涉及到内核态到用户态的转变,这是因为,通过默认和忽略方式响应信号的代码,属于OS的内核级代码,要在内核态下执行,一般默认的信号响应方式是终止进程或暂停进程。

对于终止进程的默认响应方式,OS直接将进程杀死,也就不会存在回到用户态,继续执行主指向流中的代码的情况。对于暂停进程的默认响应方法,进程PCB会被添加到阻塞(等待)队列中去,直到进程接收到可以继续运行的信号,这种状态下也不会回到用户态继续执行主执行流中的代码。如果采用忽略的策略响应信号,那么就是在内核态下执行完忽略响应方法,然后再回到用户态继续运行进程。

在用户自定义了信号响应函数的情况下,执行完用户的相应代码,是继续留在用户态回到主执行流上次终止的位置继续运行吗?答案是否定的。

执行完用户自定义的signal_handler函数后,会执行特殊的系统调用sigreturn再次进入到内核态,在内核态下,会进行对信号响应的一些收尾工作,如:处理改写 block、pending 表等,这些工作完成后,才会真正回到用户态,从主执行流上次中断的位置继续运行。  

图2.1为进行信号处理的全流程图,其中包含了每一步所处的状态是用户态还是内核态。

Linux系统编程:进程信号的处理_第2张图片 图2.1 信号处理的流程

2.2 多次向进程发送同一信号

  • 如果在信号被处理完成之后再次发生这个信号,那么进程会对这个信号进行正常的响应,不会受到任何影响。
  • 如果在执行对某个信号的处理函数signal_handler的过程中,向进程发生这一信号,进程在完成本次signal_handler函数的执行之前,不会再次对这一信号做出响应,因为在执行信号处理方法时,会将这个信号设置为阻塞。至于在signal_hander处理信号的过程中被阻塞的信号在之后怎么处理,可分为两种情况讨论:(1)只发送了一次:未决标志pending置1,等待本次signal_handler执行完成后,阻塞状态解除,对信号进行响应。(2)发送了多次:由于普通信号只能通过penging位图来决定这个信号是否被发送过且还没有处理(处于未决状态),而pending中的每个信号对应一个二进制比特位,只能用 1/0 来表示,也就是说只能记录到一次信号发送,因此,即使在执行signal_handler过程中多次发送被阻塞的信号,这个信号在signal_handler执行完成后阻塞状态解除后,也只能被执行一次。

结论1:如果进程正在执行编号为sigId的信号的处理方法,那么在执行处理方法的过程中,这个信号会被设置为阻塞状态。

结论2:在执行信号处理方法signal_handler的过程中,无论向进程发送多少次被阻塞的信号,在signal_handler执行完成后,这个信号也只能被相应一次,

在代码2.1中,对SIGINT信号的处理方法进行重新定义为signal_handler,在signal_handler中执行sleep(10),以便在执行signal_handler时可以多次接受到SIGINT信号,运行代码,先使用Ctrl+C发生SIGINT信号,触发对signal_handler的执行,在执行signal_handler期间多次通过Ctrl + C发送二号信号,观察运行结果,发现在第一次运行signal_handler函数期间接受的SIGINT信号,将来只会被响应一次。

代码2.1:处理信号期间多次发送信号

#include 
#include 
#include 
#include 

void signal_handler(int sig)
{
    std::cout << "recieve a signal, sigId:" << sig << std::endl;
    sleep(10);
}

int main()
{
    signal(SIGINT, signal_handler);
    while(true)
    {
        std::cout << "This is a process, pid:" << \
        getpid() << ", ppid:" << getppid() << std::endl;
        sleep(1);
    }
    return 0;
}
Linux系统编程:进程信号的处理_第3张图片 图2.2  代码2.1的运行结果

2.3 sigaction 函数 

函数原型:int sigaction(int sig, const struct sigaction* act, struct sigaction* oact)

头文件:#include

函数功能:让用户自定义处理某个信号的函数,并且可以设置执行对这个信号的处理方法期间对指定信号进行阻塞。

返回值:执行成功返回0,失败返回-1。

这个函数有三个参数,其中sig为信号编号,act 和 oact 为 struct sigaction 结构体类型的指针,Linux操作系统是由C语言编写的,C/C++允许函数名和自定义类型的名称相同,struct sigaction的定义如下所示,用于表示处理信号的方法函数以及信号屏蔽字等。act为输入性参数,用它的值设置信号处理方法和屏蔽信息,oact为输出型参数,用于接受原来的struct sigaction数据。

struct sigaction {
    void     (*sa_handler)(int);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void);
};

在struct sigaction中,成员sa_sigaction、sa_flags和sa_restorer为与实时信号相关的参数,本文只对普通信号做处理,暂时不关心这三个参数。sa_handler为指向信号处理函数的函数指针,sa_mask为阻塞信号集,用于设置在运行sa_handler所指向的函数期间要阻塞的信号。

代码2.2演示了对sigaction函数的使用,重新设置2号SIGINT信号的处理方法,并设置对1~8号信号的阻塞,通过Ctrl+C发生SIGINT信号,在执行signal_handler期间,多次发生1~8号信号,并不断输出未决信号集,可见,在执行signal_handler期间发送的这些信号在signal_handler函数运行期间都处于未决状态,等到signal_handler运行完毕,才会对这些信号进行响应。

代码2.2:使用sigaction设置信号处理函数和信号屏蔽字

#include 
#include 
#include 
#include 

void showpending(const sigset_t& pending)
{
    for(int sig = 1; sig <= 31; ++sig)
    {
        if(sigismember(&pending, sig)) std::cout << 1;
        else std::cout << 0;
    }
    std::cout << std::endl;
}

void signal_handler(int sig)
{
    std::cout << "recieve a signal, sigId:" << sig << std::endl;

    int count = 20;
    sigset_t pending;

    while (count--)
    {
        sigpending(&pending); // 获取未决信号
        showpending(pending); // 输出未决信号集状态
        sleep(1);
    }
}

int main()
{
    std::cout << "This is a process, pid:" << getpid() << std::endl;

    // act用于设置信号处理方法和阻塞集
    // oact用于接受原来的sigaction信息
    struct sigaction act, oact;

    // 重新设置对信号的处理函数
    act.sa_handler = signal_handler;

    // 设置对1-8号信号的信号屏蔽字
    sigemptyset(&act.sa_mask);
    for (int sig = 1; sig <= 8; ++sig)
    {
        sigaddset(&act.sa_mask, sig);
    }

    // 调用sigaction对信号响应方法和屏蔽状态重新设置
    sigaction(SIGINT, &act, &oact);

    while (true)
    { }

    return 0;
}
Linux系统编程:进程信号的处理_第4张图片 图2.3  代码2.2的运行结果

三. 可重入函数和不可重入函数

设想这样的场景:某个函数func正在被执行的时候(还没完成执行),突然进程接受到了一个信号,在处理这个信号的函数signal_handler中,func再次被调用,这样就相当于,在某一时刻,func被多个执行流调用执行,func发生和重入。

以链表头插为例,探究函数不可重入的场景。如图3.1所示,insertFront函数执行操作的流程为:将新节点node插入到原链表头结点的位置,然后更改head执行新插入的节点。那么假设,第一次调用insertFront函数的执行流执行到node1连接到node之前,但还没有更新head的时候,突然执行流被打断,insertFront重入执行node2头插,insertFront(node2)执行完成后退回到原inserFront的执行流中从上次被中断的位置继续运行,head被更改为node1,这样就造成了node2的丢失,这是由于insertFront函数重入引发的问题,insertFront函数就是不可重入函数。

  • 可重入函数:重入不会出问题的函数。
  • 不可重入函数:重入可能出现问题的函数。

实际的项目开发中,绝大部分函数都是不可重入的,函数可否重入,只代表函数的一种特性,而不用于评价函数的好坏。

Linux系统编程:进程信号的处理_第5张图片 图3.1 不可重入函数引发问题

四. volatile 关键字

volatile 关键字的功能是杜绝编译器的优化,本文借助信号处理,来解析volatile的功能。

编写代码4.1,定义一个全局变量flag并初始化为0,在主函数中通过while循环判断!flag是否为真,如果假,就终止while循环,然后进程退出。我们重定义对2号信号的处理函数signal_handler,进程在相应2号SIGINT信号时,应当执行的操作为将flag由0置1,那么当执行完一次signal_handler函数后,while循环理论上应当终止,使用g++编译代码,如果不人为指定优化级别,那么运行结果确实如此。但是,如果在用g++编译代码时,指定为最高的-O3优化级别,那么运行结果会如图4.1所示,指向完一次signal_handler函数后,flag由0置1,while理论上应当退出,但是并没有,这是为什么呢?

代码4.1:验证volatile关键字及编译器的优化情况

#include 
#include 

//volatile int flag = 0; -- 声明不允许编译器优化
int flag = 0;

void signal_handler(int sig)
{
    std::cout << "flag change: " << flag;
    flag = 1;
    std::cout << "->" << flag << std::endl;
}

int main()
{
    signal(SIGINT, signal_handler);
    while(!flag)
    {}
    return 0;
}
Linux系统编程:进程信号的处理_第6张图片 图4.1 代码4.1采用默认优化级别和最高O3优化级别编译的运行结果

这里就涉及到编译器的优化了,在最高O3级优化下,编译器看到在主函数中,flag没有被更改,因此就自作聪明的将第一次读到的flag载入到CPU的寄存器中,而不是在每次while循环判断成立条件时从内存中读取flag的值进行判断,因此即使内存中的flag已经被更改了,while循环依旧不会终止,因为这里直接从CPU寄存器中读取值用于while的条件判断,而CPU中的值是在第一次while条件判断的时候载入的,并没有被更改,这是由于编译器自作聪明的优化而造成的错误。

如果在声明和初始化全局变量flag时,使用volatile关键字进行声明,就可以杜绝上面的问题,语法为:volatile int flag = 0;

五. SIGCHLD信号

5.1 SIGCHLD信号的意义

子进程终止或暂停的时候,会向父进程发生SIGCHLD信号,SIGCHLD信号不会触发父进程进行任何操作,如果不回收子进程,子进程就会处于僵尸状态。代码5.1通过重定义对SIGCHLD的处理函数,来验证子进程终止向父进程发送SIGCHLD信号。

代码5.1:验证子进程终止向父进程发生SIGCHLD信号

#include 
#include 
#include 
#include 
#include 

void signal_handler(int sig)
{
    std::cout << "recieve a signal:" << sig << std::endl;
    if(wait(nullptr) > 0)
    {
        std::cout << "child process exit successful" << std::endl;
    }
    exit(0);
}

int main()
{
    signal(SIGCHLD, signal_handler);

    if(fork() == 0)
    {
        int count = 3;
        while(count--)
        {
            std::cout << "child process, pid:" << getpid() \
            <<", ppid:" << getppid() << std::endl;
            sleep(1);
        }
        exit(0);
    }

    while(true) {}
    return 0;
}
Linux系统编程:进程信号的处理_第7张图片 图5.1 代码5.1的运行结果

5.2 通过SIGCHLD信号等待子进程

如代码5.2所示,重定义SIGCHLD信号的处理函数为waitChlid,在父进程中一次创建多个子进程,由于父进程在收到SIGCHLD的那一时刻,可能有多个子进程都退出来,但父进程只能收到有个SIGCHLD信号,因此,在waitChlid函数中不能只执行一次wait/waitpid等待子进程退出,而是应当使用非阻塞等待的方式,通过while进程轮询检查,来实现对每个子进程的等待,避免子进程僵尸。

设有n个子进程被创建,那么通过SIGCHLD信号保证子进程全部被回收的方法有两种:

  • 通过waitpid,将第一个参数设置为-1,获取任意一个退出了的子进程的退出状态,并执行非阻塞等待。
  • 通过vector,记录每一个创建了的子进程的pid,在在waitChild中遍历vector,调用waitpid,给定等待子进程的pid,进行非阻塞等待。

代码5.2:使用信号的方法等待子进程退出

#include 
#include 
#include 
#include 
#include 
#include 

// 1. 通过waitpid,给定第一个参数-1和非阻塞,使用SIGCHLD信号非阻塞等待
void waitChild(int sig)
{
    std::cout << "recieve a signal, sig:" << sig << std::endl;

    pid_t id = 0;
    int status = 0;
    // 轮询检测,非阻塞等待子进程
    while((id = waitpid(-1, &status, WNOHANG)) > 0)
    {
        std::cout << "There is a child process exit, pid:" << id \
        << ", exit code:" << WEXITSTATUS(status) << std::endl;
    }
}

int main()
{
    signal(SIGCHLD, waitChild);
    
    // 创建5个子进程
    for(int i = 0; i < 5; ++i)
    {
        if(fork() == 0)
        {
            std::cout << "This is a child process, pid:" << getpid() << std::endl;
            exit(i);
        }
    }

    while(true) { };
    return 0;
}



// 2. 使用vector记录每个子进程id,wait非阻塞等待的方法,实现利用SIGCHLD信号等待子进程
std::vector childId;

void waitChild(int sig)
{
    std::cout << "recieve a signal, sig:" << sig << std::endl;

    pid_t id = 0;
    int status = 0;

    // 轮询检测,非阻塞等待子进程
    for (size_t i = 0; i < childId.size(); ++i)
    {
        if ((id = waitpid(childId[i], &status, WNOHANG)) > 0)
        {
            std::cout << "There is a child process exit, pid:" << id
                      << ", exit code:" << WEXITSTATUS(status) << std::endl;
        }
    }
}

int main()
{
    signal(SIGCHLD, waitChild);

    // 创建5个子进程
    for (int i = 0; i < 5; ++i)
    {
        pid_t id = fork();
        if (id == 0)
        {
            std::cout << "This is a child process, pid:" << getpid() << std::endl;
            exit(0);
        }
        childId.push_back(id); // 子进程的pid存入vector
    }

    while (true)
    { }
    return 0;
}

5.3 对于SIGCHLD信号的SIG_DFL和SIG_IGN处理方法 

通过man 7 signal,查阅SIGCHLD信号在系统中默认的处理方法为忽略,那么,采用SIG_DFL默认处理和SIG_IGN忽略处理,是不是效果完全一样呢?答案显然是否定的。

  • 采用SIG_DFL默认方法来处理子进程退出,如果不对子进程进行等待,那么子进程就会成为僵尸进程,因为系统并不能确定,是否真的不需要考虑子进程的退出状态,这样子进程的PCB就一直得不到释放,从而引发内存泄露。
  • 采用SIG_IGN忽略方法处理子进程退出,即使用户不采用wait/waitpid等待子进程,子进程也不会出现僵尸状态。

可以这么理解,虽然对于SIGCHLD信号的默认处理方式为忽略,但是SIG_IGN处理SIGCHLD的忽略等级更高,就连子进程的退出状态信息也忽略了,不会引发僵尸进程问题。

六. 总结

  • 用户态是用户执行自身代码的所处的状态,而内核态是执行操作系统内核代码所处的状态,用户态是一种受到管控的状态,处于用户态的的时候,进程不可以访问操作系统的内核资源,而内核态的优先级非常高,可以访问操作系统的内核资源。
  • 从用户态到内核态的切换,也是在一个进程的进程地址空间上下文中进行的,进程地址空间的0~3G为用户空间,3~4G为内核空间,分别通过用户级页表和内核级页表映射到用户进程和操作系统所使用的物理内存地址中去,用户态到内核态,就是执行流从进程地址空间的用户空间跳转到内核空间去执行。
  • 用户态 -> 内核态 转换的具体流程为:1. 由中断、异常或者系统调用触发转换  2. 执行INT 80指令进入到内核态,将CPU的CR3寄存器状态进行改写,让其具备访问操作系统内核资源的权限 -> 3. 运行操作系统的内核代码,直到完成操作 -> 返回用户态。  CPU就是通过其CR3寄存器中的值,确定其执行权限是用户态还是内核态。
  • 信号是在由内核态向用户态转变的时候,进行的信号处理。信号处理的流程为:1. 中断、异常或系统调用,引发状态转变,进入内核态 -> 2. 当执行完内核态工作时,在返回用户态之前对信号进行检测 -> 3. 如果信号某个信号处于未决状态,且没有被阻塞,并且信号的处理方法是用户自定义的,那么进入用户态执行用户自定义的信号处理函数 -> 4. 用户自定义的信号处理函数运行完毕后,再次进入内核态,执行一定的收尾工作 -> 5. 回到用户态,从上次主执行流中断的位置开始继续执行。
  • 在执行对某个信号的处理方法时,这个信号会被设置为阻塞,对于普通信号而言,如果处于阻塞态,那么再阻塞没有被清除之前,不管发生多少次信号,都只能被记录一次,也只能响应这个信号一次。
  • sigaction和signal函数都可以让用户自定义信号处理的方法,对于普通信号而言,sigaction还可以对阻塞状态进行设置,即:在执行对某个信号处理方法的过程中,对信号选择性阻塞。
  • 在某一时段内,某个函数可能被多组执行流共同执行调用,发生函数重入。重入不会出问题的函数称为可重入函数,重入会出问题的函数称为不可重入函数。
  • volatile关键字的功能是阻止编译器的任何优化措施。
  • 子进程退出的时候,会向父进程发生SIGCHLD信号,如果一个父进程创建多个子进程,可以通过自定义对SIGCHLD信号的处理方法,配合 while + waitpid非阻塞 或 记录子进程pid的vector数组 + waitpid非阻塞等待 ,来实现对所有子进程退出状态的回收,在等待子进程退出的过程中,父进程本身的工作不首影响。
  • 对于SIGCHLD,采用SIG_DFL默认处理的操作是忽略,但是默认处理如果不等待子进程退出的话会出现僵尸进程,而如果采用SIG_IGN处理,则不会出现僵尸状态。

你可能感兴趣的:(Linux系统和网络,linux,运维,服务器)