进程创建
进程创建我们使用的函数是fork函数,这是一个系统调用接口。那么fork究竟干了什么呢?子进程的创建是以父进程为模板的。当父进程去fork子进程的时候,它会
- 分配新的内存块和内核的数据结构给子进程,也就是task_struct等等。
- 将父进程的部分数据结构拷贝到子进程
- 将子进程添加到系统的进程列表中
- fork返回,开始调度。
我们知道进程实际上就是代码+数据。在默认情况下,父子进程是代码共享,数据独有。这里的共享不仅仅是after的代码共享,是所有的代码共享(即使用if分流后,所有代码也是共享,不使用的代码不代表不属于进程。),before的代码也是共享。只不过在fork之后,由于pcb中有程序计数器,判断子进程从after开始执行。 那么为什么要代码共享呢? 首先代码是只可读的,在代码段。这保证了共享的可能性。其次,父子进程的执行逻辑完全相同,不共享简直浪费空间。 那么为什么数据要独有一份呢? 因为进程具有独立性!如果不共享,那么子进程的修改就能影响父进程。这样进程之间肯定会相互破坏!但是不是所有的数据都是立刻要被使用的,也就是说不需要所有的数据被拷贝一份。如果立刻全部拷贝到子进程,那么就可能造成不需要的数据被多拷贝了,浪费时间空间,操作系统采用的方法是什么时候使用,使用哪块数据,就只拷贝那一块,这叫做写时拷贝。
写时拷贝
所谓写时拷贝,顾名思义,写的时候再进行拷贝。一开始fork的时候,实际上父子进程的数据也是共享,此时操作系统会将数据也变成只读的,一旦一方进行写入操作,那么操作系统就会发现这个错误,然后进行写时拷贝,只将需要拷贝的一小块数据分配到新的内存中。 可以看到,被修改的数据,父子进程的页表会改变只读权限,然后操作系统为子进程新选择一块内存,将它的数据映射过去,但是其他的数据保持不变!!
fork如何实现两个返回值?
fork函数在函数返回之前就已经创建出了子进程,所以return这个数据具有两份,父进程需要return,子进程同样需要return。
为什么fork给父进程返回子进程的pid,给子进程返回0?
因为父进程可以创建多个子进程,如果需要不同的子进程完成不同的工作,就需要父进程知道子进程的信息,这就是子进程的PID。而子进程只有一个父进程,不需要标记,所以返回0.
fork的常规用法
- 父进程希望进程分流,创建子进程执行不同的分支,执行不同的代码段。
- 利用程序替换,子进程执行不同的程序。
fork失败的原因
- 系统进程过多,资源不够。
- 一个用户创建的进程是有限的,用户创建的进程超过上限。
-
进程终止
进程退出一般有三种场景:
这三个有什么区别呢?
- return代表当前函数退出,如果在main函数中才与exit相同。
- exit 直接终止进程,但是终止之前会做一些清理工作,比如刷新缓冲区。
- _exit 直接干掉进程,别的啥也不做。
如果使用exit,那么hehe会被打印,因为exit会刷新缓冲区。但是_exit的使用不会打印hehe。 exit的常用法:我们知道父子进程共享代码,那么在fork的代码会被执行两次,如果有的代码不想子进程来执行,那么就可以使用exit来提前结束子进程。
#include <iostream>
#include <unistd.h>
using namespace std;
int main(){
pid_t id = fork();
if(id > 0){
cout << "I am father process." << endl;
}
else if(id == 0){
cout << "I am child process." << endl;
exit(0);
}
else{
cerr << "fork error." << endl;
}
cout << "这句话只能被父进程打印。" << endl;
return 0;
}
exit的参数
exit的参数是一个int型的参数,实际上它只有8个字节被使用。
int main(){
exit(11111);
return 0;
}
上述进程,11111明显在整形范围内。按照我们的理解,返回值应该是11111.但是,我发现返回值是103。那么103和11111是什么关系呢?11111转换成二进制的后8位就是103!
11111 -> 0000 0000 0000 0000 0010 1011 0110 0111 (11111的二进制序列) 103 -> 0000 0000 0000 0000 0000 0000 0110 0111 (103的二进制序列)
没错,一个进程的返回值最多就只占8位,就是255个返回值。
进程等待
进程等待的意义
-
进程等待要从僵尸进程说起。我们知道如果子进程的数据不被父进程接收,那么子进程就会变成僵尸进程。僵尸进程是很麻烦的,它刀枪不入,我们无法使用kill -9发送信号去干掉一个已经”死亡“的进程。 -
但是我们必须知道子进程把我们交给它的任务完成的怎么样,也就是说我们需要接收子进程的返回码。我们只能够等待父进程挂掉,将子进程转换成孤儿进程,然后由systemd来接收子进程的返回码。 -
所以我们需要父进程进行进程等待,接收子进程的退出码。 -
你也可能会说,这不是必须的。因为你完全可以不要进程等待,就等父进程挂掉,让1号进程接管子进程就ok。但是这样做的缺点就是,如果父进程运行时间很长,甚至一直运行,子进程就一直占用系统资源,造成内存泄漏。
我们常使用的进程等待函数有wait和waitpid。 其中wait的参数和waitpid的第二个参数一样。当我们在父进程里面使用wait/waitpid函数,父进程会堵塞(waitpid可以不阻塞),然后一直等到子进程运行完毕,返回退出码给父进程,然后继续工作。这里要介绍两个概念,阻塞和非阻塞:
- 阻塞:为了完成某个功能发起的一个调用,如果条件不满足,则一直等待,知道条件满足。
- 非阻塞:如果不满足实现这个功能的条件,直接报错返回。
wait :
- 父进程等待子进程退出,将退出码放到stat_loc中,然后返回子进程的PID。
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
using namespace std;
int main(){
pid_t id = fork();
if(id < 0){
cerr << "fork error ." ;
}
else if(id == 0){
int count = 10;
while(count){
cout << "I am child." << endl;
sleep(1);
--count;
}
exit(0);
}
else{
wait(NULL);
}
return 0;
}
上述代码父进程阻塞等待子进程,结果就是子进程先打印10次,然后父进程等待成功,打印“wait after”。
waitpid :
- waitpid的第一个参数pid是用来表示等待的目标。如果为-1,表示等待任意子进程退出。如果pid > 0,那么等待进程号为pid的子进程退出。
- 第二个参数是一个输出型参数,stat_loc指针指向的int变量中存储着程序的返回码或者报错信息。如果为NULL,表示不关系子进程的运行结果。
- 第三个参数是参数选项。options为0,表示阻塞等待。options为WNOHANG,表示非阻塞等待。
- RETURN VALUE:如果等待成功,则返回子进程的PID。如果有子进程,但是没有退出,则返回0。等待出错则返回-1。显然返回0是配合非阻塞等待使用。
- waitpid、wait会去处理退出的子进程,而不管这个子进程退出多久。
- 可以看出 wait(&status) == waitpid(-1, &status, 0);
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
using namespace std;
int main(){
pid_t id = fork();
if(id < 0){
cerr << "fork error ." ;
}
else if(id == 0){
int count = 10;
while(count){
cout << "I am child." << endl;
sleep(1);
--count;
}
exit(0);
}
else{
pid_t ret = waitpid(id, NULL, WNOHANG);
if(ret == 0){
cout << "child process is still running". << endl;
}
}
return 0;
}
上面这个父进程采用非阻塞的方式等待子进程,但是子进程没有退出的时候父进程已经等待完毕。由于是非阻塞等待,所以还是没有达到进程等待的目的。还是会产生僵尸进程。所以,非阻塞等待一般配合循环使用。
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
using namespace std;
int main(){
pid_t id = fork();
if(id < 0){
cerr << "fork error ." ;
}
else if(id == 0){
int count = 10;
while(count){
cout << "I am child." << endl;
sleep(1);
--count;
}
exit(0);
}
else{
int ret;
while((ret = waitpid(id, NULL, WNOHANG)) == 0){
cout << "child process is still running" << endl;
sleep(1);
}
if(ret > 0){
cout << "wait success ." << endl;
}
}
return 0;
}
非阻塞比起阻塞,它的好处是显然的:充分利用cpu资源。增加进程的执行效率。就好比你在河边钓鱼,你可以拿出一本C语言的书看一会书,看一眼鱼浮,而不是一直看着鱼浮。
关于waitpid的第二个参数
第二个参数是一个输出型参数status,它的内容由操作系统填充。它里面的内容是子进程的退出码和退出状态。但是1个int型的变量如果装载这么多信息呢?答案是,int型由32个比特位组成。我们将每一种错误信息映射成不同的数字即可。实际上,我们只研究status的低16位。
- 如果一个进程是正常退出(即运行到了代码结束的地方),那么在status的低16位中的低8位全是0,高8位则是退出码,即return或者exit等函数的返回码。(这也与我们对exit返回值的探究不谋而合)
- 如果一个进程因为异常被信号杀死,那么它的返回值将毫无意义。那么低7位会显示杀死它的信号。
- 我们可以使用位运算来获得返回值或者杀死它的信号。
int status;
waitpid(id, &status, 0);
cout << (status >> 8) & 0xff << endl;
cout << status & 0x7f << endl;
- 实际上,库函数也为我们提供了2个这样的函数:
- WIFEXITED(status):若位正常终止子进程返回的状态,则为真。(用于查看子进程是否正常退出,即查看status的低16位的低8位是否为0.)
- WEXITSTATUS(status):若WIFEXITED为零,提取子进程退出码。(查看子进程的退出码。即查看status的高8位。)
进程替换
- 前面我们对fork的子进程的使用是用来分流,然后执行父进程代码的一部分,而进程替换则是让子进程去执行新的程序。
- 所谓进程替换,像它名字一样,用一个进程去替换一个进程,而这种替换是覆盖式的替换,也会产生写时拷贝。一个进程必然有它自己的代码和数据,新的进程会完全覆盖掉旧的进程的代码和数据,但是数据结构等信息还是使用原先的,只是会重新更新信息。也就是说进程替换不会产生新的进程,它的PID不会变。
如图所示,新的进程会将它的数据和代码覆盖掉旧的进程,然后更新Pcb,虚拟内存和页表之间的映射关系。
exec家族
- 进程替换的实现是通过6个函数:
- 它们都包含在unistd库中。
- l,即list,表示参数是以链表的形式,一个一个传入。
- v,即vector,表示参数传入是以数组形式。
- p,即path,带有p表示使用默认的PATH中的路径,不带p则需要自己传入路径。
- e,即环境变量,表示自己维护环境变量。
- path表示程序所在的路径。而file表示程序的名称。arg和argv都是是命令行参数,注意的是,需要NULL结尾。envp代表环境变量的指针数组。
#include <stdio.h>
#include <unistd.h>
int main(){
printf("begin.............\n");
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
printf("end...............\n");
return 0;
}
- execl的目的是使用ls命令来替换这个程序。
- execl的第一个参数是ls这个命令所在的路径,据我测试,这个路径可以是绝对路径,也可以是相对路径。
- 我想执行的是 ls -a -l,所以后面的参数形式是"ls", “-a”, “-l”。
- 最后切记用NULL结尾,且就算你什么也不执行,后面也必须跟NULL。
- 这段代码的结果是,执行了ls -al的命令,end没有打印。证明进程替换是覆盖式的替换。,老程序的代码被覆盖掉,自然不会打印end。
#include <stdio.h>
#include <unistd.h>
int main(){
printf("begin.............\n");
execlp("ls", "ls", "-a", "-l", NULL);
printf("end...............\n");
return 0;
}
- 带有p表示不需要显示的提供路径,它会到环境变量PATH中去寻找,你只需要提供这个程序的名字即可。
- 两个ls,第一个是程序的名字,第二个是参数,不可缺少。
#include <stdio.h>
#include <unistd.h>
int main(){
printf("begin.............\n");
char *const argv[] = {
"ls",
"-a",
"-l",
NULL,
}
execv("/usr/bin/ls",argv);
printf("end...............\n");
return 0;
}
- 带有v实际上就是将命令行参数包含到一个指针数组中去,然后将指针数组作为参数传入。
使用自己写的程序去替换
你可能会说,难道只能用系统给的程序去替换吗?当然不。你也可以使用自己写的程序去替换自己的程序。例如:
- 其中mycmd.c去替换了myexe.c。
- 这里就要说明一下execle中最后一个e的作用,它代表的意思是将环境变量传入mycmd,而据我测试,这个环境变量的替换会覆盖掉bash中的环境变量,也就是说如果你传入的env中没有定义"PWD"这个环境变量,虽然这个环境变量是全局的,mycmd中也不会接受到,因为程序替换后,它的环境变量只来自env这个数组。
进程替换与fork分流
-
你以为进程替换就这吗?no,进程替换的强大远不在此。通过进程替换我们完成许多操作,比如使用C/C++来编写程序,然后替换成python,java写的程序!! -
或是使用fork分流,然后用子进程来程序替换。 -
这里程序执行的结果是ls -al,并且打印了exec success。 -
先是fork出子进程,然后用子进程进行进程替换去执行ls命令,父进程只需要进程等待。 -
你可能认为这没什么用处,但是bash的原理就是基于此! -
你只需要对父进程进行一个死循环,然后每次有命令被输入bash,bash就解析字符串,然后进行fork,进程替换,进程等待一系列的操作。
最后的怪想
- 程序编写最有意思的一点就是自由。关于程序替换,我突然想到的怪操作:程序替换之后再替换。甚至你还可以形成一个闭环:1替换2,0替换1,2替换0,哈啊哈,我没试过,也许你们可以试试看程序崩不崩,还是会一直执行下去。
(全文完)
|