前面的文章中我们讲述了信号的产生与信号的保存这两个知识点,在本文中我们将继续讲述与信号处理有关的信息。
信号处理
之前我们说过在收到一个信号的时候,这个信号不是立即处理的,而是要得到的一定的时间。从信号的保存中我们可以知道如果一个信号之前被block,当解除block的时候,对应的信号会立即被递达。因为信号的产生是异步的,当前进程可能在做更重要的事情,当进程从内核态切换回用户态的时候,进程就会在OS的指导下进行信号的检测与处理。
用户态、内核态
首先我们先来讲讲这两个状态,用户态:执行自己写的代码的时候,进程所处的状态;内核态:执行OS的代码的时候,进程所处的状态。
- 当进程的时间片到了需要切换时,就要执行进程切换逻辑。
- 系统调用
之前在进程地址空间中我们学习过进程地址空间的相关知识,我们知道PCB连接到进程地址空间,然后通过页表的映射,映射到物理内存中。之前我们只学习了用户空间,里面有堆、栈、代码等。我们知道操作系统也是一段代码,而在进程地址空间中的内核空间就是存储的OS的代码与数据映射的地方,因此同样需要一张内核级的页表。以32位的系统为例子,所有的进程地址空间中的0-3GB都是不同的存放的是该进程自己的代码与数据,匹配了自己的用户级页表;所有进程的3-4GB都是一样的存放的是OS的代码与数据,每一个进程都可以看到同样的一张内核级页表,所有进程都可以通过统一的窗口看到同一个OS;OS运行的本质:其实都是在进程的地址空间中运行的;所以所谓的系统调用,其实就如同调用.SO中的方法,在自己的地址空间中进行函数跳转并返回即可。
此时就会出现一个问题,正应为OS的代码与数据跟用户的代码与数据在同一个地址空间中,为了防止用户随意的访问OS的数据与代码,因此就有了用户态与内核态。当执行自己的代码,对应的状态就是用户态,要对系统调用进行访问,OS就会对身份,执行级别进行检测,检测到不是内核态就会终止进程。在CPU中存在一种寄存器叫做CR3,里面有对应的比特位,比特位为0表征正在运行的进程是用户态,比特位为3表征正在运行的进程级别是内核态。由于用户无法直接对级别进行修改,因此OS提供的系统调用,内部在正式执行调用逻辑的时候会去修改执行级别。
进程是如何被调度的?
首先我们要讲一下OS。OS的本质是软件,本质是一个死循环;OS时钟硬件,每个很短的时间向OS发送时钟中断,然后OS要执行对应的中断处理方法。进程被调度就是时间片到了,然后OS将进程对应的上下文等进行保存并切换,选择合适的进程,这通过系统函数schedule()函数执行上面的保存任务。
内核如何实现信号的捕捉
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
sigaction
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
下面我们看一个简单的例子:
static void PrintPending(const sigset_t &pending) {
cout << "当期进程的pending信号集:";
for (int signo = 1; signo <= 31; ++signo) {
if (sigismember(&pending, signo)) // 用于打印信号集
cout << "1";
else
cout << "0";
}
cout << endl;
}
static void handler(int signo) { // 添加了static之后该函数只能在本文件中使用
cout << "对特定信号:" << signo << "执行捕捉动作" << endl;
int cnt = 10;
while (cnt) {
cnt--;
sigset_t pending;
sigemptyset(&pending);
sigpending(&pending);
PrintPending(pending);
cout << "打印完成pending信号集" << endl;
sleep(1);
}
}
int main() {
struct sigaction act, oldact;
memset(&act, 0, sizeof(act));
memset(&oldact, 0, sizeof(oldact));
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3); // 可以添加其他信号的阻塞方式,在自定义捕捉时将其余收到的信号阻塞
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
sigaction(2, &act, &oldact);
while (true) {
cout << getpid() << endl;
sleep(1);
}
}
通过在对2号信号实行自定义捕捉的时候给进程发送3,4,5号信号,就可以通过打印信号集来查看该信号是否被阻塞。
其余知识点
可重入函数
我们以链表结点指针的头插为例子:
一般头插分为两步首先将新节点插入在链表之前,然后再将头指针指向新节点的地址,如果在第一步的时候进行了信号的自定义动作保存了当前函数执行的状态,在自定义动作之中又执行了一次链表头插的动作,那么当自定义动作处理结束之后,返回至用户态函数执行的地方,就会继续原先的插入动作,那么我们在自定义函数中的插入结点就会丢失,导致内存泄漏。
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
如果一个函数符合以下条件之一就是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构
volatile
下面我们来看一个关键字volatile,首先我们来看一个例子:
int quit = 0; // 保证内存可见性
void handler(int signo) {
printf("change quit from 0 to 1\n");
quit = 1;
printf("quit : %d\n", quit);
}
int main() {
signal(2, handler);
while(!quit); //注意这里我们故意没有携带while的代码块,故意让编译器认为在main中,quit只会被检测
printf("main quit 正常\n");
return 0;
}
运行上述的代码,就与我们之前学习的一样会让全局变量quit由0变1,进行打印然后退出。
我们在编译的时候是有优化的级别的,可以根据不同的优化级别记性优化。我们选择-O2来对上述的代码记性优化,可以发现我们虽然可以自定义捕捉信号,变量quit同样也变成了1,但是却无法让程序退出。
下面我们来解释一下为什么? CPU匹配的运算种类只有两种,算术运算与逻辑运算,while循环的代码需要在CPU上执行,因为只有CPU能够进行计算,因此需要我们先将quit加载到CPU中,然后再进行真假的判断,在CPU中还有记录当前程序位置的指针,当判断条件生效之后,指针就会指向下一句代码。这就是为什么我们能够退出的原因。
while循环是一种运算,这样的运算是需要运算源的,每次都需要将数据从内存加载到CPU中,编译器发现在main函数中quit的值并没有修改,而只是进行判断,编译器就会认为每次的quit数据都是一样的,那么就会进行优化将数据第一次load进CPU中,然后就不再进行加载工作,只检测CPU中寄存器的保存的quit数据,相当于让CPU中的quit替换掉了内存中的quit。这就导致了quit为什么进行了修改但是并没有退出的问题。为了告诉编译器,保证每次检测都要从内存中进行数据读取不要用寄存区中的数据覆盖,让内存数据可见,因此就有了volatile。
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
SIGCHLD
在进程等待的哪里我们学习过子进程退出之后如果父进程不进行处理,子进程就会变为僵尸进程,然后我们就学习了waitpid和wait函数清理僵尸进程。父进程可以以非阻塞或者阻塞的方式进行主动检测,由于子进程推出了,父进程暂时不知道。子进程在退出的时候会给父进程发送SIGCHLD信号,该信号的默认处理动作是忽略(SIG_DFL)什么都不做。
我们就可以使用自定义捕捉的方法进行检测 :
那么我们就设想可以在自定义捕捉中进行对僵尸进行的处理,这样就可以让父进程做自己的事情,可以自动对子进程进行回收。
pid_t id ;
void waitProcess(int signo) {
printf("捕捉到一个信号: %d, who: %d\n", signo, getpid());
sleep(5);
while (1) {
// 这里若设置的为0,那么如果有些子进程退出了,有部分子进程没有退出导致自定义捕捉的函数无法返回会一直阻塞在里面,因此要设置为非阻塞的等待方式
pid_t res = waitpid(-1, NULL, WNOHANG); // -1表示等待任意一个子进程
if (res > 0)
{
printf("wait success, res: %d, id: %d\n", res, id);
}
else break; // 如果没有子进程了?
}
printf("handler done...\n");
}
void handler(int signo) {
printf("捕捉到一个信号: %d, who: %d\n", signo, getpid());
}
int main() {
signal(SIGCHLD, waitProcess);
// signal(SIGCHLD, handler);
int i = 1;
for (; i <= 10; i++) {
id = fork();
if (id == 0) {
// child
int cnt = 5;
while (cnt)
{
printf("我是子进程, 我的pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(1);
}
}
// 如果你的父进程没有事干,你还是用以前的方法
// 如果你的父进程很忙,而且不退出,可以选择信号的方法
while (1) {
sleep(1);
}
return 0;
}
由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。