目录
一、可重入函数
二、volatile关键字
三、SIGCHLD信号
一、可重入函数
以一个链表头插为例子
- main函数调用insert函数像一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候因为硬件中断使进程切换到内核,再次回到用户态之前检测到有信号待处理,于是就切换到sighandle函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入的两步都完成之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果就如同上面的图4,只有一个节点被插入进去了。对于不理解内核态和用户态的小伙伴可以看看这篇文章:深度理解信号处理阶段
- 像上例子这样,insert函数被不同的控制流调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成混乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或者参数,则可称之为可重入函数。
以下条件符合其一就是不可重入函数:
1、调用了malloc或者free因为,malloc也是用全局链表来管理堆的。
2、调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
二、volatile关键字
volatile关键字的作用:保持内存的可见性,告知编译器,被该关键字修饰的变量不允许被优化,对该变量的任何操作都应该在真实的内存中进行操作。
上面提到的可见性、优化、和编译器的关系不太了解的就来通过一个例子来学习这个关键字:
我要写一个代码,一个条件为1的死循环代码,当我输入ctrl+c的指令,条件由1变为0,那么循环结束。代码如下:
int flag=0;
void myhandler(int sig)
{
printf("turn flag to 1\n");
flag=1;
}
int main()
{
signal(2,myhandler);
while(!flag);
printf("process nomaly qiut\n");
return 0;
}
它的执行结果就会如同我所期望的:一输入ctrl+c程序循环结束,程序退出
但是如果我改变一下编译条件在后面添加一个-O2,这样编译器就会优化代码,让编译器替我优化一下代码:
gcc -o mysigchld mysigchld.cc -O2
那么结果就会变成下面这样,我连续按下了几个ctrl+c,它对我的回应也是将flag改变为1了。但是循环就是不结束。
这是为什么呢?
我们来了解一下优化原理:
我们知道,数据不先加载到内存里cpu根本看不到,os系统将这个程序的代码和数据都加载进来了,上面的是数据区,下面是存放代码的代码区
首先cpu先从数据区读入flag数据
然后执行while条件的真假逻辑计算,对比!flag是否大于0。
此外还有一个pc指针指向代码区,来获取下一步指令如果结束了死循环,就由pc来获取下一个代码:
已上就是底层理解这个代码的运行过程,那么这个过程有什么值得优化的呢,或者说编译器它优化了什么呢?
有小伙伴会说是这个死循环,答案比较接近了,死循环是biu要执行的,这个优化不了,但是我们每次死循环都要进行一次条件判断,这个条件判断正常来说不是直接和cpu里的flag进行运算,而是需要先从内存里的那个flag获取,然后再进行运算。
编译器优化的点其实就在数据拷贝的这个点,毕竟是在两个硬件上拷贝,效率不是很高,编译器就将这个过程优化成了只需要拷贝第一次,后面的每次比较就直接在cpu里获取了。这样自然而然的在每次我们改变flag的值时它还是无法结束死循环。
以下就是汇编语言的区别:因为编译器优化工作的本质就是对代码动手脚,而cpu只是一个硬件没有自己的想法的,别人给什么任务就执行就可以了。
三、SIGCHLD信号
在学习进程的时候我们知道,我们可以用wait和waitpid来清理僵尸进程。
使用第一个方法,父进程可以阻塞的等待子进程的结束,第二种方式就可以给通过轮询的方式查询子进程是否已经结束等待清理。第一种方式,父进程阻塞了就不能继续做自己的任务了,第二种方式在处理自己的工作时还要是不是询问一下,这样不仅代码复杂还效率低下。
不论怎么说,让父进程阻塞等待子进程结束是不是有点太浪费资源了,那为什么我们不用其他的方法来结束子进程呢——因为我们之前不知道,子进程结束后其实是会向父进程发送一个信号的,也就是SIGCHLD信号。这个信号的默认处理动作是忽略,既然这样我们可以利用这个信号,自定义一个子进程已结束才提醒父进程去清理子进程。如果不是很了解如何写自定义函数处理信号的同学可以看看以下这篇文章:信号原理解析 中的第二小节
一个任务:写出一段代码,生成一个子进程并在五秒后退出,然后利用子进程退出时发送的SIGCHLD信号结束子进程。
#include<stdlib.h>
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
void myhandler(int sig)
{
waitpid(-1,NULL,WNOHANG);
printf("成功回收子进程\n");
}
int main()
{
signal(SIGCHLD,myhandler);
pid_t cid;
cid=fork();
if(cid==0)
{
//child
int cnt=5;
while(cnt>0)
{
printf("pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
cnt--;
}
exit(0);
}
//father
while(1)
{
printf("father is doing something\n");
sleep(1);
}
return 0;
}
我来解析一下上面这个代码:一开始父进程生成了一个子进程,然后子进程开始输出自己以及父进程的pid持续五秒,在五秒后子进程结束,然后向父进程发送一个信号,父进程接受到信号后就跳转到myhandler函数清理子进程。
看看结果是不是入我们所想:
确实如此。
到这里我们就学会了一个非常管用的方法去处理子进程,这样也不会占用太多的资源。但是上面这段代码真的正确吗,或者说这个思路没问题但是代码实现细节真的普适吗?
如果一个父进程同时生成多个子进程
以下面这个代码为例,父进程申请了十个子进程:
#include<stdlib.h>
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
void myhandler(int sig)
{
waitpid(-1,NULL,WNOHANG);
printf("成功回收子进程\n");
}
int main()
{
signal(SIGCHLD,myhandler);
pid_t cid;
// cid=fork();
for(int i=1;i<=10;i++)
{
int cid=fork();
if(cid==0)
{
//child
int cnt=5;
while(cnt>0)
{
//printf("pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
cnt--;
}
exit(0);
}
}
while(1)
{
printf("father is doing something\n");
sleep(1);
}
return 0;
}
按照我们之前的思路,十个子进程退出就应该会向父进程发送十个信号,并且会出现十个:“成功回收子进程”这一字符串。
但是结果是什么呢?
父进程只回收了三个子进程,为什么会出现这样的情况呢?就像我们刚刚说的一样,十个进程同时退出,同时向进程发送同一个信号。信号储存是通过位图的方式储存的,所以这十个进程需要占用同一个bit位,而父进程一次却只能处理一个信号,对于信号储存原理不是很了解的小伙伴可以先看看这个文章:信号储存解析,因此
waitpid(-1,NULL,WNOHANG);
printf("成功回收子进程\n");
这段代码很有可能会遗漏掉一些信号,所以我们可以将这段代码改成:
while (1)
{
pid_t res = waitpid(-1, NULL, WNOHANG);
if (res > 0)
{
printf("wait success, res: %d, id: %d\n", res, id);
}
else break; // 如果没有子进程了?
}
这样handler函数不处理完SIGCHLD信号就不会退出循环。
执行结果:
到这里为止,我们才算学会了回收子进程的第二种方式。
那么我们要如何使用这两种方法呢?
- 如果父进程没有什么任务要执行就可使用wait这种阻塞的回收方式。
- 如果父进程自己也有任务需要执行,就使用利用信号这一方式来回收子进程。