一、信号的处理
进程对应信号的处理的一般步骤就是:先去遍历pending位图,找到比特位为1的位置对应的信号,然后再去检测block位图对应位置的比特位是否为1。若不为1,就hander表的对应位置去调用信号的处理动作函数,若为1,不做任何事。
好了,既然我们已经知道了信号处理的一般步骤了,那么进程是在什么时候进行那几个步骤的呢?前面我们说了,是在合适的时候,那什么时候是合适的呢?其实,信号处理发生在由内核态返回用户态的时候。
1.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、产生异常、中断、陷阱等。其中,由用户态切换为内核态我们称之为陷入内核。
所以,如果系统调用完成时,进程切换完毕或者异常、中断、陷阱等处理完毕,进程将由内核态转变成用户态,此时就会对信号进行处理。
二、信号的捕捉
2.1内核如何进行信号捕捉
当一个执行流正在执行我们的代码时,可能会因为某些原因,陷入内核,去执行操作系统的代码。当操作系统的代码执行完毕准备返回到用户态时,os会检查pending表(此时仍处于内核态,有权力查看当前进程的pending位图),如果某个信号处于未决状态,那就再去检测block位图,看该信号是否被阻塞。如果阻塞,就直接返回,接着执行用户的代码。
如果未决信号没有被阻塞,那么此时就需要对该信号进行处理。
如果待处理信号的处理动作是默认或者忽略,而这两种处理动作已经由os写好,可以直接在内核态下进行处理。执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,接着上次被中断的地方继续向下执行。
但是,如果未决信号的处理动作是被自定义捕捉了的,那么我们就需要返回用户态,去执行用户自定义的处理动作的代码,执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,接着上次被中断的地方继续向下执行。
信号的捕捉:
为什么不能在内核态下直接执行自定义捕捉动作的代码呢?
从理论上来说,是可以的,因为内核具有最高的执行权限。
但是,我们不能这样做。因为如果在用户自定义的捕捉函数里面有非法操作,比如清空数据,如果在内核态执行这样的代码,后果将不堪设想。所以,不能让操作系统直接去执行用户的代码
2.2、sigaction(信号捕捉)
信号捕捉除了前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉。该函数可以读取和修改与指定信号相关联的处理动作,该函数调用成功返回0,出错返回-1。
NAMEsigaction - examine and change a signal actionSYNOPSIS#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字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。
三、可重入函数
有下面这样的一个链表。
对于该链表,我们有下面的头插函数:
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号信号,父进程就会去调用自定义的处理动作,回收子进程。