一、可重入函数
1、引入
我们来先看一个例子来帮助我们理解什么是可重入函数:
假设我们现在要对一个链表进行头插,在执行到第10行代码时,突然进程的时间片到了,进程被切换了,一会等进程再度切换回来时,当前进程要处理信号,而信号处理函数是sighandler
,而sighandler
里面也进行了头插,等进程从内核态返回到用户态时,继续执行第11行的代码,这时我们再观察链表的结构会发现链表中出现了节点丢失的问题,而造成这种问题的根源是我们的insert
函数同时被两个执行流给进入了。
node_t node1, node2, *head; int main() { ... insert(&node1); ... } void insert(node_t*p) { p->next = head; head = p; } void sighandler(int signo) { insert(&node2); }
由这个问题衍生出了一种函数分类的方式:
- 如果一个函数同时被多个执行流进入所产生的结果没有问题,该函数被称为可重入函数
- 如果一个函数同时被多个执行流进入所产生的结果有问题,该函数被称为不可重入函数
- 可重入函数主要用于多任务环境中,一个可重入的函数通常来说就是可以被中断的函数,也就是说,可以在这个函数执行的任何时刻中断它,转入OS调度下去执行另外一段代码,而返回控制时不会出现什么错误;
- 不可重入的函数由于使用了一些系统资源,比如全局变量区,中断向量表等,所以它如果被中断的话,可能会出现问题,这类函数是不能运行在多任务环境下的。
2、可重入函数的判断
如果一个函数符合以下条件之一则是不可重入的:
- 函数体内使用了静态(
static
)的数据结构或者变量; - 调用了
malloc
或free
,因为malloc
也是用全局链表来管理堆的。 - 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
二、volatile关键字
1、引入
volatile
是C语言的一个关键字,该关键字的作用是保证内存数据的可见性。
我们来先来看一段代码,这里我们不加入volatile
关键字并开启编译器优化选项,优化级别是-O2
。
这段代码的意思是:我们让进程一直运行,直到我们给进程发送2
号信号以后,进程再退出。
#include <stdio.h> #include <unistd.h> #include <signal.h> int flag = 0; void handler(int signo) { printf("捕捉到了%d号信号\n", signo); // 将flag置为1 flag = 1; printf("已经将flag置为%d\n", flag); } int main() { signal(2, handler); printf("进程正在运行...\n"); while (!flag); // 当flag == 1时,进程退出。 printf("运行结束!\n"); return 0; }
运行结果:
可以看到,我们明明都已经让flag = 1
了但是进程中的循环依然没有结束,这时为什么呢?下面我们一起来分析这个过程:
代码中的main
函数和handler
函数在触发时是两个独立的执行流,而while循环是在main
函数当中的,而且main
执行流里面并没有使用过handler
函数(signal
函数只是对2
号信号进行了捕捉,没有调用过handler
函数),所以在编译器编译时检测到在main
函数中对flag变量没有做过修改操作,而且由于while循环运行时需要频繁使用flag变量,所以编译器可以将flag变量的值用一个寄存器进行保存,以后每次使用flag变量直接去寄存器里面取数据,不必每次都要将内存中的flag搬运到寄存器里面然后让CPU去计算。
可是不巧的是我们给当前进程发送了2
号信号,让另外一个执行流更改了内存中的flag变量,而由于编译器的优化,认为flag变量不会改变导致内存中的flag变量改变以后也没有将寄存器中的数据同步修改,而CPU运算使用的数据又是寄存器中的数据,这就导致了内存数据的不可见,于是while循环就会一直运行,导致了上面的问题。
为了让编译器每次都要去内存取数据来进行计算,我们可以在flag变量前面加上volatile
关键字。
#include <stdio.h> ... volatile int flag = 0; void handler(int signo) { ... } int main() { ... }
再次运行程序,发现运行结果符合预期!
2、关于编译器的优化的简单讨论
上面的代码如果我们不开启优化,就算不加上volatile
关键字也是能正常运行的,可见编译器的优化不是越高越好。
如何理解编译器的优化?
编译器的本质是将代码翻译成01的二进制序列,所以编译器的优化是在你编写的代码上动手脚,也就是说编译器的优化其实改变了一些最终翻译成01
二进制以后的执行逻辑。
三、SIGCHLD信号
在一前我们讲过用wait
和waitpid
函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,也很麻烦。
上面使用wait
与waitpid
其实都是父进程主动检查子进程是否处于僵尸状态,那么有没有一种方法能够让子进程主动告诉父进程自己处于僵尸状态呢?
其实,子进程在终止时会给父进程发SIGCHLD
信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD
信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait
或waitpid
清理子进程即可。
下面就是一个对SIGCHLD
信号的一个使用:
在父进程中我们创建了10个子进程,这10个子进程退出时都会给父进程发送SIGCHLD
信号,由于父进程回收其中一个子进程时,其他子进程也有可能同时给父进程发送SIGCHLD
信号,而pending
表又没有办法同时存储多个信号,所以我们就要进行循环回收子进程,而为了不影响父进程的执行流程我们可以选择非阻塞等待。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <signal.h> pid_t id = 0; void WaitProcess(int signo) { printf("捕捉到了%d号信号,正在处理...\n", signo); while (1) { pid_t ret = waitpid(-1, NULL, WNOHANG); if (ret > 0) { printf("等待子进程%d成功,父进程%d\n", ret, id); } else { break; } } printf("WaitProcess, done\n"); } int main() { signal(SIGCHLD, WaitProcess); int i = 0; // 创建10个子进程 for (i = 0; i < 10; i++) { id = fork(); // 子进程 if (id == 0) { int cnt = 5; //睡眠cnt秒以后退出 while (cnt--) { printf("我是子进程,我的pid是:%d,ppid是:%d\n", getpid(), getppid()); sleep(1); } exit(0); } } // 父进程一直休眠 while (1) { sleep(1); } return 0; }
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用signal
将SIGCHLD
的处理动作置为SIG_IGN
,这样fork
出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用signal
函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux
可用,但不保证在其它UNIX系统上都可用。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <signal.h> pid_t id = 0; int main() { // 对SIGCHLD设置为忽略,这样产生的子进程退出时不会形成僵尸状态。 signal(SIGCHLD, SIG_IGN); int i = 0; // 创建10个子进程 for (i = 0; i < 10; i++) { id = fork(); // 子进程 if (id == 0) { int cnt = 5; //睡眠cnt秒以后退出 while (cnt--) { printf("我是子进程,我的pid是:%d,ppid是:%d\n", getpid(), getppid()); sleep(1); } exit(0); } } // 父进程一直休眠 while (1) { sleep(1); } return 0; }