写在前面
我们前面的几个博客都弱化进程控制的知识,主要关注原理了。这里我们正式的谈谈进程的控制,包括进程的创建和退出等等,这里面最难的是进程的等待,不过也不要担心,我尽量把涉及到的知识谈透。
进程创建
我们先来看一下进程的创建.这个我们在之前已经使用了,就是使用fork.不过在这里之前,我们还是需要谈一下写时拷贝的.
写时拷贝
我们这里要谈两个问题,一个就是拷贝什么,而是为何要使用写时拷贝.这也是我们今天的一个比较重要的点.
拷贝什么
我们先来解决第一个问题,拷贝的是什么.我们在前面已经和大家谈过.刚开始子进程和父进程共享同一片空间.也就是代码和数据都是共享的.这里我就疑惑了,所谓的代码共享是指从fork开始代码共享部分代码还是共享父进程所有的代码?
我们可能会这么想,我们子进程是从fork后面开始执行的,那么有极大的可能是共享部分代码.这样想也有一定的道理.不过大家看一下下面的代码.举得例子可能不太恰当,不过也能证明一部分.
int main()
{
pid_t id = fork();
if(id == 0)
{
// parent
printf("我是父进程 %d\n",g_val); }
else
{
// child
printf("我是子进程 %d\n",g_val);
} return 0;
}
这个例子在一定程度上证明了我们最起码不是从fork开始复用的.至于是不是复用全部代码,这里还体现不出来.这里我直接给结论,子进程是复用父进程的全部代码.至于验证方法这里就不演示了,有兴趣的可以去看一看vfork这个函数,这里可以解决你的疑惑.
那么这里我们就疑惑了,既然我们是复用全局的代码,那么子进程又是如何可以准确的找到fork函数这一行对应的地址的.这里面就要看一下子进程在创建的时候编译器是如何做的.首先第一点,我们肯定是先创建一个task_struct+mm_struct + 页表.那么这里就会出现问题的答案.我们在函数栈帧那个博客提过,在CPU中存在一个寄存器eip,这个寄存器记录着下一条语句的地址,也叫PC指针.我们子进程拷贝的时候也会把这个地址给拷贝下来,这就是我们为何子进程在fork这个函数语句执行.我们修改eip里面的PC指针,也可以修改子进程的执行的初识地址.
为何是写时拷贝
这个问题我们在前面的一篇博客稍微提过一下.首先不是我们非要使用写时拷贝,而是写时拷贝相对于其他的方法比较优良.想一下,我们计算机的内存空间是有一定的限度的.如果我们每一个子进程被创建出来,都要开辟一款空间.如果我们不修改该里面的数据,那么这篇空间就被浪费了.我们可能回想,是不是我们可以把要修改的数据的空间给开辟出来,这也是一个解决方法啊.是的,但是你有没有想过,编译器知道哪些是要被修改的数据吗,它只有跑完程序才会知道,所以这个方法不可以被实现.人们就像到,既然我们不知道要修改那个数据,我们就像不开辟空间,等到修改的时候在开辟,这就是写是]时拷贝,也叫延迟拷贝.当然,写实拷贝需要一定的效率,但是相比较其他的方法总体而言是非常优秀的,所以Linux采用写时拷贝.
进程创建
这里我们就谈fork这个函数就可以了,也没有什么可以多说的.
fork失败
我们知道进程的创建是需要空间的,内存就这么大,一般内存中进程过多的话,就会出现进程的创建失败.
#include <unistd.h>
int count = 0;
int main()
{
while(1)
{
pid_t id = fork();
if(id < 0)
{
printf("进程创建失败 %d\n",count);
break;
}
else if(id == 0)
{
printf("我是子进程 %d\n",getpid());
sleep(20);
exit(0);
}
++count;
}
return 0;
}
大家可以看到,我们创建了4k个进程,不过实际进程要比这小的多,主要是我们创建子进程的时候什么是都没有干,空间要求的比较少.
进程终止
现在我们就要好好的谈论一一下进程的终止了,所谓的进程终止就是进程进入X状态.这个涉及到的内容可就多了.不过在这里之前我们还是需要解决几个问题,.进程的退出只可能是下面的三种情况.
- 进程跑完了,结果正确
- 进程跑完了,结果错误
- 进程没跑完,遇到异常终止了
这里我们先来解释前面的两种情况,至于第三种等到以后再说.
进程退出码
大家可能对这个进程退出码感到疑惑.我举个例子大家就可以明白了.看一下下面的代码.
这里我想提问一下,为何会出现一个return,假设我们理解出现return,那么为何事0,不能事其他数吗?
#include <stdio.h>
int main()
{
int a = 1;
return 0;
}
上面的问题是我们从未想过的,之前我们都是无脑的在main函数中return 0.今天我们要稍微的揭开一下它神秘的面纱.
首先我先解释一下,return语句可以结束相应的函数,main函数也是一个函数,所以这里我们使用return.再来说一下,我们为何返回0.首先,不是我们必须返回0,而是0可以代表一种情况,前面我们说过,进程会出现三大种情况,其中0就代表我们进程跑完了,而且结果是正确的.我们把return X中的X 称为进程退出码.
我带大家看一一下进程退出码所对应的信息,我们这这里先来了解一下.
#include <string.h>
int main()
{
int n = 50;
for(int i=0;i<n;i++)
{
printf("%d %s\n",i,strerror(i));
}
return 1;
}
echo $?
如果我们想要查看最近一i次进程的退出码就可以用这个指令.
[bit@Qkj 08_29]$ echo $?
这里我们也验证一下,我们已经知道的Linux里面的指令本质上也是函数,或者是一个进程,我们查看一下不存在的文件,然后看一下进程退出码.
[bit@Qkj 08_29]$ ls m
这里我还要和大家说一下,以后我们写网络编程,这个进程退出码就非常重要了,不能再像以前无脑return0了.而且我们也可以自己定义不同的进程退出码代表的不同的信息,没必要一定按照上面的来.
进程终止方法
现在我们就可以正式谈一下进程的终止了.进程终止也是一个大章节.我们正常进程终止有俩种方法.关于我们之前的ctrl + c,是信号终止 异常退出,这俩先放一下,等到后面的几个博客在和大家谈.我们先来简单的看一下.
- 在main函数中遇到 return 语句
- 在任意地方遇到exit函数
这里我先简单的解释一下第一个,记住我们是在main函数遇到return这个进程才算是终止的,其他函数是不行的.
exit
我们在C语言的时候好象见过这个函数,这里我们不多废话,先来看用法,后面再说其他的.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void func()
{
exit(111);
}
int main()
{
func();
return 0;
}
我们发现退出码是exit的,这里就可以得到一个结论,只要一碰到exit,这个进程就会终止.
_exit
有的同学可能会感到疑问,好象还是存在一个_exit这个函数吧,他们有什么区别?这里和大家提一下功能上的区别,exit的底层是调用了\_exit,当然也做了一些另外的功能,比如说是缓冲区的问题.
- _exit 不会涮洗缓冲区
- exit 退出前会刷新缓冲区
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void func()
{
printf("hello word");
exit(111);
}
int main()
{
func();
return 0;
}
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
void func()
{
printf("hello word");
_exit(111);
}
int main()
{
func();
return 0;
}
注意,这两个函数的作用和main函数里面的return 0一样,记住是main函数.
当只有一个进程时,我们把退出码给bash进程,bash进程会释放相应的资源,但是对于多个进程,它的作用只是提供退出码,子进程的资源没有被释放,这是父进程的事,你需要手动释放拿,要和后面的进程控制配套使用.
在一个进程调用了exit()之后,该进程并不会立刻完全消失,而是留下一个称为僵尸进程(Zombie)的数据结构 。 僵尸进程是一种非常特殊的进程,它已经释放了几乎所有的内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位置,记载该进程的退出状态等信息供其他进程收集,除此之外,僵尸进程不再占有任何内存空间.留下一个称为僵尸进程(Zombie)的数据结构 这句话有些不对,不是留下一个僵尸进程的数据结构,而是僵尸进程退出程序的运行了,但是pcb资源没有被完全释放也正是因为pcb没有被释放,所以ps查看进程信息的时候才能依然查看到进程信息,但是pcb内部的大部分资源都被释放掉了
操作系统做了什么
这里我们就开是考虑一件事了,关于进程的终止,我们计算机内核做了什么.这才是我们进程终止的重点.
首先我们要明白,每一个进程程度都是存在一个父进程的,我们对于子进程的退出码会给到父进程,这一点可以稍微放一下.我们再看,对于进程的销毁,肯定是存在下面两个方面的销毁.
首先要明确一点,代码和数据一定是会被销毁的,至于数据结构存在一定考虑.大家想我们进程运行的时候首先就是开辟空间+初始化,这两个都要耗费时间,操作系统就想我们能不能把这个要销毁的数据结构保存下来,我放在一个地方,如果有新进程,就不开辟空间,直接初始化就可以了,这是一些内核的做法.其中这个特殊存放这些数据结构的地区叫做内核的数据缓冲池,也叫做slab分派器.
进程控制
我们前面见过僵尸进程,进程已经进入的Z状态,我们是杀不死它的,僵尸进程的出现会造成内存的泄漏,这里就出现可以进程等待.所谓的进程等待就类似于你的老板让你去出差,等你工作结束了,你不能只闷头回到公司工作,肯定要先领导汇报情况啊.领导正在等会着你的结果呢.这就是进程等待的原因,子进程的工作已经做完了,但是里面的资源还没有释放,这里父进程等着释放资源.
我们进程等待主要是考两个函数,不过我们需要理解一下他们背后的一些深层次的功能.
- wait
- waitpid
其中我们重点关注第二个函数.
wait
我们先先来认识一下这个函数,这个函数还是比较简单的,里面传入的是一个指针参数,这里我们先不管它,后面也存在一个函数,其中传入参数的类型和作用和这个一样,到后面再谈.
我们在父进程中使用wait,等到子进程功能运行结束后,父进程释放掉子进程对应的资源,继续向后执行.
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
while(1)
{
printf("我是子进程 pid %d\n",getpid());
sleep(1);
}
}
else
{
printf("我是父进程,正在等待子进程 pid %d\n",getpid());
sleep(20); // 进程等待
pid_t ret = wait(NULL);
sleep(20);
if(ret == -1)
{
printf("等待错误\n");
}
else
{
printf("等待成功\n");
}
}
return 0;
}
这里我们又可以得到一个结论,我们观察到父进程处于S状态,也就是阻塞态,这里的阻塞态可不是为了等待硬件,而是软件部分没有到位,这里算是前面博客的一个补充,放在这里比较好理解一点.
waitpid
这个才是我们经常使用的进程等待函数,这个函数完全包含了上一个wait.我们这里就要看一下他们的区别了.我们这里先说一下,由于前面我们都是一个子进程,所以来说wait就可以等待,wait这个函数的本质就是等待任意一个子进程,如果是多个的话就需要我们现在的waitpid这个函数了.
我们来解释一下返回值,我们重点关注成功和不成功,另外的一种情况暂时不考虑.至于函数的三个参数,这就是我们需要注意的了.
- pid_t pid 要等等待 子进程测 pid,是几就表示等待那个进程,如果是-1表示等待任意进程
- int *status 一个变量的指针,这个是我们今天要重点分享的
- int options 这个先不说,今天先设为0,0叫阻塞等待,阻塞的概念谈过,但是这个还没说,留下来后面谈
再析进程退出码
由于我们这里只学了进程退出码,这里先来看一下进程退出码在进程等待中的作用.在task_struct中是存在一个变量保存进程的退出码的.
在子进程结束后,子进程的退出码就会保存在exit_code里面,父进程会拿到子进程里面的退出码,这就像领导会向你询问工作完成测状况.
int *status
这里面我们就可以讨论这个情况了,我们把进程的退出码放在一个变量里面,这就是我们要谈的.但是一个int类型的变量是32的比特位,这里面我们就用次16位.而且退出码还保存在高8位,这一点要记住.
int main()
{
pid_t id = fork();
if(id == 0)
{
int count = 5;
while(count--)
{
printf("我是子进程\n");
sleep(1);
}
exit(13);
}
else
{
int statu = 0;
printf("我是父进程,在等待子进程结束\n");
pid_t ret = waitpid(id,&statu,0);
if(ret > 0)
{
printf("等待成功 退出码 %d\n",(statu&0xff00)>>8);
}
else
{
printf("等待失败\n");
}
}
return 0;
}
那么这里面我们就存在一个问题,我们是不是可以设计一个全局变量,一旦我们们退出子进程,我们就修改这个全局变量的值,这样还用这么麻烦的吗?但是你忘记了一个问题,一旦我们子进程修改变量,那么操作系统就会重现开辟空间,那么对于父进程来说,原本的变量的值可是没有变化.
这里我们还存在一个问题,既然高8位是进程退出码,那么我想问下低8位代表的什么?
我来解释一下后面的8位,准确来说是7位,因为有一位这里还无法谈,先放一下.
大家可能对终止信号出现疑惑,那么我们前面用kill指令,用的是9好命令.我们看一下kill指令,这里我们提一下,具体的等到信号那个博客在谈.
我们后面会学习前31个信号代表的啥,注意看,这里是没有0号信号的.
[bit@Qkj 08_29]$ kill -l
这里我们就可以测试出来低7位是做什么的了.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
while(1)
{
printf("我是子进程 pid %d\n",getpid());
sleep(1);
}
}
else
{
int statu = 0;
printf("我是父进程,在等待子进程结束\n");
pid_t ret = waitpid(id,&statu,0);
if(ret > 0)
{
printf("子进程退出码 %d,子进程的退出信号 %d\n",(statu&0xff00)>>8,statu&0x7f);
}
else
{
printf("等待失败\n");
}
}
return 0;
}
WIFEXITED & WEXITSTATUS 函数
既然我们已经知道了退出信号和退出码,那么这两个优先看哪一个?首先我们要确保进程可以运行,只有这样进程的退出码才有意义.那么我们是如何确定的进程正常运行呢?这里提供了两个函数.
- WIFEXITED(status) 进程正常退出为真
- WEXITSTASTUS(status) 若进程正常退出,可以直接查看退出码
int main()
{
pid_t id = fork();
if(id == 0)
{
while(1)
{
printf("我是子进程 pid %d\n",getpid());
sleep(1);
}
}
else
{
int statu = 0;
printf("我是父进程,在等待子进程结束\n");
pid_t ret = waitpid(id,&statu,0);
if(ret > 0)
{
if(WIFEXITED(statu))
{
printf("子进程退出码 %d\n",WEXITSTATUS(statu));
}
else
{
printf("进程非正常退出\n");
}
}
else
{
printf("等待失败\n");
}
}
return 0;
}
这里就可以解释我们前面进程的第三种情况了,进程没有跑完,就异常了,这里也是.我们用野指针测试一下.
int main()
{
pid_t id = fork();
if(id == 0)
{
int count = 1;
while(1)
{
printf("我是子进程 pid %d\n",getpid());
sleep(1);
count++;
if(count == 15)
{
break;
}
}
// 这里我们使用 也指针
printf("注意,我们要使用 野指针了\n");
int* p = NULL;
*p = 10;
}
else
{
int statu = 0;
printf("我是父进程,在等待子进程结束\n");
pid_t ret = waitpid(id,&statu,0);
if(ret > 0)
{
if(WIFEXITED(statu))
{
printf("子进程退出码 %d\n",WEXITSTATUS(statu));
}
else
{
printf("进程非正常退出\n");
}
}
else
{
printf("等待失败\n");
}
}
return 0;
}
这应该就是我们在之前使用野指针报错的原因,要知道父进程也是bash的子进程,编译器多这个异常退出的进程进行报错处理.
阻塞等待
这里我们在前面谈到过,进程的等待分为阻塞和非阻塞,这里我们先来谈阻塞.前面我们在谈进程状态的时候遇到过阻塞的概念,由于是硬件的效率远远低于CPU的效率,所以要的条件就绪后,才会进入执行态.那么这里对于父进程面我们可是没有等待硬件资源,这是在等待软件.这里把阻塞态延伸一下,阻塞态只有当条件就绪后才会进入运行态,这个条件包括软硬件资源.