0


Linux之进程信号

一、概念引入

1、生活中的信号

在生活中,我们很容易能够想到常见的一些信号。比如,红绿灯,手机闹钟,上下课铃声,转向灯等等。我们人不仅能够识别它们,还能够知道不同的信号对应的下一步动作应该怎么做。比如,红灯停绿灯行;上课铃响就上课,下课铃响就下课;转向灯告诉别人我要转的方向。

那么,我们是怎么识别并知道这些信号,并且知道信号发出后,接下来的动作应该是怎么样的呢?首先,这当然是规定过的,交通部门规定了红灯停绿灯行,而如果交通部门规定红灯行,绿灯停,那么我们也就只能照做。其次,我们从出生开始,大人们就不断告诉我们,要红灯停,绿灯行,久而久之,我们就记住了特定场景下的信号,以及后续我们需要做到动作,并且终身不忘。

而且,即使我们没有在过马路,而是在吃饭,我们也能够知道应该如何处理红绿灯信号。

再比如,如果,我的9点的闹钟响了,但是我没有立即起床,而是30分钟后再起床。这就说明,当信号产生的时候,我们不一定会立即执行后续动作,但是我记住了闹钟响过了,我该起床了,后面我再执行起床的动作。

上面就是一些生活中的信号,以及我们对待信号的方式。下面我们就来看看Linux中的信号。

2、Linux中的信号

什么是Linux信号?

Linux信号本质是一种通知机制,是用户或者操作系统,通过发送一定的信号,来通知进程某件事已经发生,你可以后续对其进行处理。

Linux信号的特点

结合上面生活中的信号的特点,Linux信号有如下特点:

a. 进程能够识别信号,即能够看到信号发送给了自己,并且知道后续的处理动作。

b. 进程能够识别信号,已经由Linux设计者提前设计好了,并且规定了各种信号的后续处理动作。

c. 信号的产生是随机的,信号产生时,进程可能正在做自己的事,所以,进程不一定会立即对信号进行处理。

d. 因为进程不一定立即处理信号,所以进程一定能够将信号记住,后续再进行处理。

e. 进程会在合适的时候处理信号(什么时候合适?后面会讲)。

g. 一般而言,信号的产生相对于进程是异步的。

信号查看:我们可以通过 kill -l 命令查看Linux中有哪些信号

其中,131号信号,是普通信号,3464是实时信号。我们在平时使用中使用的最多的是普通信号。

二、信号处理常见方式

为了方便后面的讲解,我们首先了解一下信号处理的常见方式:

1、执行该信号的默认处理动作(进程自带的,Linux设计者写好的逻辑)。

2、用户自己提供一个信号处理函数,要求在进行信号处理时,使用用户自己定义的方式处理信号,这种方式称为捕捉(Catch)一个信号。

3、忽略该信号。

我们可以通过 man 7 signal 查看信号的默认处理动作:

value:信号编号 action:默认处理动作。

三、信号的产生

简单理解信号的保存和发送

为了下面我们讲解信号的产生,这里我们先简单地理解一下信号的保存。

前面讲到过,信号产生后,进程不一定会立即处理信号,而是在之后的某个合适的时间对信号进行处理。所以在这中间的一段时间里,我们必须对信号进行保存。

对于保存,进程只需要知道是否有这个信号,就可以对信号进行处理,所以我们可以使用位图来对信号进行保存。0就代表该比特位对应的信号没有产生,1就代表产生了该信号。这样,在之后进程只需要遍历一遍位图,就可以知道产生了哪些信号,然后进行处理。

该位图在进程的PCB中,属于内核数据,只有操作系统能够修改,所以信号的发送就是os把信号对应的比特位的数字由0改成1。

当然,关于信号的保存和发送我们会在下面的内容中,进行详细的讲解,这里只是有一个概念。

1、键盘产生信号

在之前讲进程等待时,我们知道使用 Ctrl + c 的组合键能够终止一个进程,而且我们也讲了,其本质就是通过向进程发送2号信号,来终止进程的。下面我们就来证明一下:

我们使用自定义函数,将信号进行捕捉:signal

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

RETURN VALUE
signal() returns the previous value of the signal handler, or SIG_ERR on error.  In the event of an error, errno is set to indicate the cause.
#include <iostream>
#include <signal.h>
#include <unistd.h>

using namespace std;

void catchsig(int signum)
{
    cout << "进程捕捉到了一个信号:" << signum << " "
         << "pid"
         << " " << getpid() << endl;
}

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

    while (true)
    {
        cout << "我是一个进程,我正在运行"
             << " "
             << "pid"
             << " " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

通过对比上面两张图,我们发现Ctrl + c 和发送2号命令,都调用了我们自定义的处理动作。所以 Ctrl + c的本质就是发送2号命令。

~ 核心转储

上面的一张图,在信号的默认动作action中,term表示只终止进程,而还有的信号的动作是core,这个动作不仅会终止进程,还可以发生核心转储。这个与我们前面的进程等待的内容又有些关联了。

上图是进程等待中,父进程获取子进程信息的status位图结构。低7位保存信号,之前有一个core dump标志,该比特位表示是否发生了核心转储。

核心转储:当进程出现某种异常时,是否由os将当前进程在内存中的相关核心数据,转存到磁盘中。

一般来说,云服务器上的核心转储功能是被关闭了的。而我们可以使用ulimit -a 命令查看core文件,ulimit -c 大小 命令打开云服务器的核心转储功能。

那么核心转储有什么作用呢?我们使用下面的代码来看看:

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

using namespace std;

void catchsig(int signum)
{
    cout << "进程捕捉到了一个信号:" << signum << " "
         << "pid"
         << " " << getpid() << endl;
}

int main()
{
    signal(SIGQUIT, catchsig);

    while (true)
    {
        cout << "我是一个进程,我正在运行"
             << " "
             << "pid"
             << " " << getpid() << endl;
        sleep(1);

        int a = 100;
        a /= 0;
    }

    return 0;
}

我们这次使用的是3号信号,它具有core的功能。

运行代码后生成了core文件,且以进程pid为后缀。

我们知道程序出错了,而有了core文件后,我们不用去一行一行找出错位置,使用core文件在gdb下可以直接定位出错位置,如下:

2、系统调用接口产生信号

1、kill函数

NAME
       kill - send signal to a process

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

       int kill(pid_t pid, int sig);

   Feature Test Macro Requirements for glibc (see feature_test_macros(7)):

       kill(): _POSIX_C_SOURCE >= 1 || _XOPEN_SOURCE || _POSIX_SOURCE

其实,我们常常使用的kill命令的底层所调用的就是该函数,下面我们可以模拟实现一下 kill命令的实现。

#include <iostream>
#include <cassert>
#include <sys/types.h>
#include <signal.h>

using namespace std;

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

//  ./mykill 2 pid
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    int signo = atoi(argv[1]);
    int sigpid = atoi(argv[2]);
    int n = kill(sigpid, signo);
    assert(n == 0);

    return 0;
}

2、raise

作用:进程让os给自己发送某一个信号。

NAME
       raise - send a signal to the caller

SYNOPSIS
       #include <signal.h>

       int raise(int sig);

DESCRIPTION
       The raise() function sends a signal to the calling process or thread.  In a single-threaded program it is equivalent to

       kill(getpid(), sig);
#include <iostream>
#include <cassert>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>

int main()
{
    cout << "我开始运行了" << endl;
    sleep(2);
    raise(2);

    return 0;
}

**3、abort **

作用:让os给自己发一个6号信号。其实abort的底层也是去调用 raise(6)去实现的。

NAME
       abort - cause abnormal process termination

SYNOPSIS
       #include <stdlib.h>

       void abort(void);
#include <iostream>
#include <cassert>
#include <unistd.h>
#include<stdlib.h>
#include <sys/types.h>
#include <signal.h>

using namespace std;

int main()
{
    cout << "我开始运行了" << endl;
    sleep(2);
    abort();

    return 0;
}

所以,总的来说,系统调用接口产生信号的具体过程就是: 用户调用系统接口——os执行对应的代码——os向目标进程写入信号——修改信号对应的比特位——进程后续对信号进行处理。

3、软件条件产生信号

~ 管道

在前面的进程间通信的管道中,我们讨论了一个问题:对于正在通信的两个进程,当管道的读端不读了,而且读端关闭了,但是写端一直在写。这时,写就没有任何意义了。我们验证了,在这个时候,os会通过发送13号信号的方式终止进程。因为管道是一个通过文件在内存级的实现,所以管道是一个软件,所以这种情况就是软件条件不满足而产生信号的一种情况。

~ 设置闹钟 alarm

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号,该信号的默认处理动作是终止当前进程。这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。闹钟一旦触发了,将会自动移除。

我们可以使用该函数写一个能够测试自己的电脑CPU的计算能力的代码:

#include <iostream>
#include <cassert>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>

using namespace std;

int count = 0;

void sigcath(int sig)
{
    cout << "final count: "
         << " " << count << endl;
}

int main()
{
    alarm(1);
    signal(SIGALRM, sigcath);

    while (true)
        count++;

    return 0;
}

我们也可以写一个代码来让os帮助我们每隔1秒就可以显示cout最新的计算结果:

#include <iostream>
#include <cassert>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>

using namespace std;

uint64_t count = 0;

void sigcath(int sig)
{
    cout << "final count: "
         << " " << count << endl;

        alarm(1);
}

int main()
{
    alarm(1);
    signal(SIGALRM, sigcath);

    while (true)
        count++;

    return 0;
}

4、硬件异常产生信号

~ 除0错误

我们来看一看下面的代码:

#include <iostream>
#include <cassert>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>

using namespace std;

void hander(int sig)
{
    cout << "我捕捉了一个信号:"
         << " " << sig << endl;
    sleep(1);
}

int main()
{
    signal(SIGFPE, hander);
    int a = 100;
    a /= 0;

    return 0;
}

运行结果如下:

我们知道了如果代码中出现了除0错误,os会给进程发送8号信号,那么是怎么产生并发送的呢?

首先,计算以及各种信息的处理是由CPU这个硬件进行的。CPU中有一个寄存器,叫做状态寄存器,它含有一个位图,该位图上有溢出标记位。 CPU在进行计算时,发现代码中出现了除0错误,因此将溢出标记位由0改为1,进程异常,CPU将该进程切出。os会自动进行计算完成后,检测状态寄存器,当检查到溢出标记位为1时,os就会提取当前正在运行的进程的PID,给其发送8号信号。

那么为什么会是死循环打印呢?

上面讲到,溢出标记位由0改为1后,CPU就会将该进程切出,因为寄存器里面的数据是该进程的上下文,所以位图也会跟随进程一起切出。但是,我们虽然将信号进行了捕捉,但是并没有让进程退出,所以这个进程只是被切出去了,当CPU正常进行调度时,再次调度该进程,上下文恢复上去,os立马识别到了溢出标记位还是1,再次打印,如此反复。

所以,为了解决这个问题,我们要在捕捉函数最后加上 exit,让进程退出。

~ 野指针和越界访问

我们知道,指针变量必须通过地址才能找到目标位置。而我们语言上的地址是虚拟地址,所以我们前面讲了通过页表将物理地址和虚拟地址建立联系。但是事实上,我们是通过页表+MMU(memory manger unit,一个硬件)的方式将物理地址和虚拟地址建立联系的,所以当代码中出现了野指针或者越界访问时,因为这是一个非法地址,那么MMU一定会报错,它会将自己内部的寄存器进行标识,os就能够检测到,且知道是哪个进程的地址转化出错了。

四、信号的保存

相关概念

a. 信号递达:进程对信号的处理动作称为信号递达。

b. 信号未决:信号从产生到递达之间的这个状态称为信号未决。

c. 信号阻塞:被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

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

信号保存——三个数据结构

前面我们讲到,在进程的PCB中,存在一种位图是用来保存信号的,但是事实上有3种数据结构与信号是相关的。他们分别是pending位图,block位图,typedef void(*handler_t)(int signo),handler_t handler[32]={0}结构。

pending位图:该位图就是我们常说的用来保存信号的位图。

block位图:该位图比特位的位置与信号标号一一对应,比特位的内容代表该信号是否阻塞。

**typedef void(*handler_t)(int signo),handler_t handler[32]={0}**:这个是一个函数指针数组,这个数组在内核中有指针指向它,这个数组称为当前进程所匹配的信号递达的所有方法,数组下标代表信号的编号,数组的每一个元素都是一个函数指针(存函数地址),指向信号对应的处理方法。

信号集——sigset_t

上面讲到的三个结构都是属于进程PCB,是内核数据结构。所以os必定不会让用户直接访问这三个结构,更不能够让用户直接进行位移操作。那么如果用户想要得到pending和block位图该怎么办呢?于是,Linux就提供了一种数据类型信号集——sigset_t,用户可以直接使用。

信号集操作函数

既然Linux提供了信号集,那么必定也通过了与之相关的各种方法,让用户能够去操作,这样用户根本就不需要关系在内核中这些结构到底是怎么样的。下面的5个函数就是对信号集进行操作的函数。

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

sigpending:获取当前进程的 pending 信号集。信号发送的本质就是对pending位图进行修改。

NAME
       sigpending - examine pending signals

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

读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

**sigprocmask **:读取或更改进程的信号屏蔽字(阻塞信号集) (block)

NAME
       sigprocmask - examine and change blocked signals

SYNOPSIS
       #include <signal.h>

       int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

下表说明了how参数的可选值及其作用:
选项作用SIG_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号SIG_SETMASK设置当前信号屏蔽字为set所指向的信号
注:9号信号是不能被捕捉或阻塞的。

五、信号的处理

有了上面信号保存相关概念,进程对应信号的处理的一般步骤就是:先去遍历pending位图,找到比特位为1的位置对应的信号,然后再去检测block位图对应位置的比特位是否为1。若不为1,就hander表的对应位置去调用信号的处理动作函数,若为1,不做任何事。

好了,既然我们已经知道了信号处理的一般步骤了,那么进程是在什么时候进行那几个步骤的呢?前面我们说了,是在合适的时候,那什么时候是合适的呢?其实,信号处理发生在由内核态返回用户态的时候。

内核态与用户态

CPU在执行我们自己写的代码的时候,我们就称为用户态,但是在自己的代码中我们会使用系统调用接口(write,getpid...),这样我们必然就会访问os的内核数据或硬件资源,此时我们就称为内核态。用户不能以用户态的身份执行系统调用,必须让自己的身份变成内核态。

那么CPU怎么知道进程是处于用户态还是内核态呢?

在CPU中,存在大量的寄存器,进程在执行的时候,会将自己的上下文数据加载到寄存器中。CPU中的寄存器分为可见寄存器和不可见寄存器。在不可见寄存器中有一个寄存器叫做CR3,它的作用是表示CPU运行级别,0表示内核态,3表示用户态,这就能够辨别是用户态还是内核态。

那么如何理解代码在操作系统上运行呢?

在进程地址空间中,0—3G的部分我们称为用户空间,是用户自己写的代码,这些数据通过用户级页表映射到物理内存中。3—4G的部分我们称为内核空间,是操作系统的相关数据,这些数据通过内核级页表映射到物理内存中。开机时OS加载到内存中,OS在物理内存中只会存在一份,因为OS只有一份,所以OS的代码和数据在内存中只有独一份。

内核级页表只有一份,不同的进程通过同一个内核级页表就可以访问同一个操作系统。

所以,进程进行系统调用的步骤就是:用户空间中的代码调用了系统调用——进程由用户态转成内核态——跳转到内核空间该接口的位置——通过内核级页表——访问物理内存中的os代码

内核态和用户态之间是怎么切换的呢?

从用户态切换为内核态通常有如下几种情况:1、需要进行系统调用时。2、当前进程的时间片到了,导致进程切换(进程切换由os执行自己的调度算法完成)。3、产生异常、中断、陷阱等。其中,由用户态切换为内核态我们称之为陷入内核。

所以,如果系统调用完成时,进程切换完毕或者异常、中断、陷阱等处理完毕,进程将由内核态转变成用户态,此时就会对信号进行处理。

六、信号的捕捉

内核如何进行信号捕捉

当一个执行流正在执行我们的代码时,可能会因为某些原因,陷入内核,去执行操作系统的代码。当操作系统的代码执行完毕准备返回到用户态时,os会检查pending表(此时仍处于内核态,有权力查看当前进程的pending位图),如果某个信号处于未决状态,那就再去检测block位图,看该信号是否被阻塞。如果阻塞,就直接返回,接着执行用户的代码。

如果未决信号没有被阻塞,那么此时就需要对该信号进行处理。

如果待处理信号的处理动作是默认或者忽略,而这两种处理动作已经由os写好,可以直接在内核态下进行处理。执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,接着上次被中断的地方继续向下执行。

但是,如果未决信号的处理动作是被自定义捕捉了的,那么我们就需要返回用户态,去执行用户自定义的处理动作的代码,执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,接着上次被中断的地方继续向下执行。

为什么不能在内核态下直接执行自定义捕捉动作的代码呢?

从理论上来说,是可以的,因为内核具有最高的执行权限。

但是,我们不能这样做。因为如果在用户自定义的捕捉函数里面有非法操作,比如清空数据,如果在内核态执行这样的代码,后果将不堪设想。所以,不能让操作系统直接去执行用户的代码。

sigaction

信号捕捉除了前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉。该函数可以读取和修改与指定信号相关联的处理动作,该函数调用成功返回0,出错返回-1。

NAME
       sigaction - examine and change a signal action

SYNOPSIS
       #include <signal.h>

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

参数act和oldact都是结构体指针变量,该结构体的定义如下:

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

说明:

sa_handler:该结构体变量就是信号的处理方法。我们可以给其赋值:SIG_IGN 或者 SIG_DFL 或者 自定义函数。

sa_flags:直接将sa_flags设置为0即可。

我们写一段代码来使用一下sigaction:

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

using namespace std;

void hander(int signum)
{
    cout << "pid: " << getpid() << " "
         << "获取了一个信号: " << signum << endl;
}

int main()
{
    struct sigaction sig;
    struct sigaction osig;
    sigemptyset(&sig.sa_mask);
    sigemptyset(&osig.sa_mask);
    sig.sa_flags = 0;
    sig.sa_handler = hander;

    sigaction(2, &sig, &osig);

    while (true)
        sleep(1);

    return 0;
}

sa_mask:当某个信号的处理函数被调用,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字。这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞,直到处理结束,该信号会在下次合适的时候被处理。

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。

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

using namespace std;

static void showpending(sigset_t &set)
{
    for (int sig = 1; sig <= 31; sig++)
    {
        if (sigismember(&set, sig))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}

void hander(int signum)
{
    cout << "获取了一个信号: " << signum << endl;
    cout << "获取了一个信号: " << signum << endl;
    cout << "获取了一个信号: " << signum << endl;
    cout << "获取了一个信号: " << signum << endl;
    cout << "获取了一个信号: " << signum << endl;

    sigset_t pending;
    int c = 20;
    while (true)
    {
        sigpending(&pending);
        showpending(pending);
        c--;
        if (!c)
            break;
        sleep(1);
    }
}

int main()
{
    cout << "pid: " << getpid() << " " << endl;
    struct sigaction sig;
    struct sigaction osig;
    sigemptyset(&sig.sa_mask);
    sigemptyset(&osig.sa_mask);
    sig.sa_flags = 0;
    sig.sa_handler = hander;

    sigaddset(&sig.sa_mask, 3);
    sigaddset(&sig.sa_mask, 4);
    sigaddset(&sig.sa_mask, 5);

    sigaction(2, &sig, &osig);

    while (true)
        sleep(1);

    return 0;
}

七、可重入函数

有下面这样的一个链表。

对于该链表,我们有下面的头插函数:

void insert(Node* p)
{
    p->next = head;
    head = p;
}

main函数中我们调用了它

int main()
{
...
    Node p1;
    insert(&p1)
...
}

信号捕捉函数中也调用了它:

void hander(int signum)
{
...
    insert(&p2);
...
}

~ 首先,main函数中调用了insert函数,想将结点p1插入链表,但插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到handler函数。

~ 在hander函数中,我们需要插入p2,将p2插入后,返回用户态。此时链表结构如下:

~ 返回用户态后,继续执行插入p1的insert的第二步。此时链表结构如下:

最终结果是,main函数和handler函数先后向链表中插入了两个结点,但最后只有p1结点真正插入到了链表中,而p2结点就再也找不到了,造成了内存泄漏。

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。

insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数。反之,如果一个函数只访问自己的局部变量或参数,则称为可重入函数。

如果一个函数符合以下条件之一则是不可重入的:
1、调用了malloc或free,因为malloc也是用全局链表来管理堆的。
2、调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

八、关键字volatile

volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。

我们来看一看下面的代码:

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

using namespace std;

int flag = 0;

void hander(int signum)
{
    (void)signum;
    cout << "change flag: " << flag;
    flag = 1;
    cout << "->" << flag << endl;
}

int main()
{
    signal(2, hander);
    while (!flag);
    cout << "进程退出后flag: " << flag << endl;
    return 0;
}

该程序的运行结果好像都在我们的意料之中,但是,如果我们使用的编译器优化程度太高,就会出现一些问题。

代码中的main函数和handler函数是两个独立的执行流,而while循环是在main函数当中的,在编译器编译时只能检测到在main函数中对flag变量的使用,而且main函数中只是对变量flag进行了检测, 并没有对其值进行修改。所以在编译器优化级别较高的时候,会直接将flag的值保存到CPU的寄存器中。

在编译器优化程度高的情况下,当进程运行起来,flag初始值0,就会被保存到CPU的寄存器里面,每次while循环检测的时候,CPU会直接到寄存器里面检测flag的值(CPU无法看到内存了),但是这个值一直是0。虽然我们对flag的值进行了修改,但是也只是将内存里面flag的值修改成了1,CPU寄存器里的值任然为0。while循环永远不会自动结束。

在编译代码时携带-O3选项使得编译器的优化级别最高,此时再运行该代码,就算向进程发生2号信号,该进程也不会终止。

为了解决这个问题,我们就可以使用volatile关键字对flag变量进行修饰,告知编译器,对flag变量的任何操作都必须真实的在内存中进行,即保持了内存的可见性。

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

using namespace std;

volatile int flag = 0;

void hander(int signum)
{
    (void)signum;
    cout << "change flag: " << flag;
    flag = 1;
    cout << "->" << flag << endl;
}

int main()
{
    signal(2, hander);
    while (!flag);
    cout << "进程退出后flag: " << flag << endl;
    return 0;
}

进程正常退出。

九、SIGCHLD信号

在进程等待的文章中,我们讲到,为了避免出现僵尸进程,父进程需要使用wait或waitpid函数等待子进程结束,父进程可以阻塞等待子进程结束,但是父进程阻塞就不能处理自己的工作了;当然也可以非阻塞地查询的是否有子进程结束等待清理,即轮询的方式,这样父进程在处理自己的工作的同时还要记得时不时询问一下子进程是否退出以及子进程的情况,程序实现复杂且效率低。

其实,子进程在退出时会给父进程发生SIGCHLD信号,该信号的默认处理动作是忽略。

于是,由于Linux的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用signal或者sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在退出时会自动清理掉,不会产生僵尸进程,也不会通知父进程。

下面的代码中父进程就没有等待子进程:

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

using namespace std;

int main()
{
    signal(SIGCHLD, SIG_IGN);
    pid_t id = fork();
    if (id == 0)
    {
        // 子进程
        cout << "我是子进程: pid: " << getpid() << endl;
        sleep(10);
        exit(0);
    }
    while (true)
    {
        cout << "我是父进程: pid: " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

​​​​​​​

还有一种方法就是:父进程可以自定义SIGCHLD信号的处理动作,这样父进程就只需专心处理自己的工作,不必关心子进程了,子进程退出时会通知父进程,父进程在自定义信号处理函数中调用wait或waitpid函数回收子进程即可。这样,子进程退出后向父进程发送17号信号,父进程就会去调用自定义的处理动作,回收子进程。

标签: linux 运维 服务器

本文转载自: https://blog.csdn.net/zdlynj/article/details/135905154
版权归原作者 dbln 所有, 如有侵权,请联系我们删除。

“Linux之进程信号”的评论:

还没有评论