进程控制——fork-and-exec、system、wait
forc-and-exec流程
父进程与子进程之间的关系十分复杂,最大的复杂点在于进程间相互调用。Linux下这一流程称为fork-and-exec。父进程通过fork的方式产生一个一模一样的子进程,然后被复制出来的子进程再以exec的方式来执行实际要进行的进程,最终成为一个子进程的存在。整个流程如下
API解释
fork
原型
#include <unistd.h> pid_t fork(void);
功能
从调用该函数的进程复制出子进程,被复制的进程则被称为父进程,复制出来的进程称为子进程。
复制后有两个结果:
1)依照父进程内存空间样子,原样复制地开辟出子进程的内存空间
2)由于子进程的空间是原样复制的父进程空间,因此子进程内存空间中的代码和数据和父进程完全相同
其实复制父进程的主要目的,就是为了复制出一块内存空间,只不过复制的附带效果是,子进程原样的拷贝了一份其实复制父进程的主要目的,就是为了复制出一块内存空间,只不过复制的附带效果是,子进程原样的拷贝了一份
参数
无
返回值
由于子进程原样复制了父进程的代码,因此父子进程都会执行fork函数,当然这个说法有些欠妥,但是暂且这么理解。
1)父进程的fork,成功返回子进程的PID,失败返回-1,errno被设置。
2)子进程的fork,成功返回0,失败返回-1,errno被设置。
代码演示
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <sys/types.h> 4 #include <unistd.h> 5 6 int main(void) 7 { 8 pid_t ret = 0; 9 10 11 printf("befor fork\n"); 12 13 ret = fork(); 14 if(ret > 0) 15 { 16 printf("parent PID = %d\n", getpid()); 17 printf("parent ret = %d\n", ret); 18 sleep(1); 19 } 20 else if(ret == 0) 21 { 22 printf("child PID = %d\n", getpid()); 23 printf("child ret = %d\n", ret); 24 } 25 26 printf("after fork\n\n"); 27 28 // while(1); 29 return 0; 30 }
依据fork返回值不同来区分父子进程,进而在父子进程中执行不同代码
浅析复制原理
Linux有虚拟内存机制,所以父进程是运行在虚拟内存上的,虚拟内存是OS通过数据结构基于物理内存模拟出来的,因此底层的对应的还是物理内存。复制时子进程时,会复制父进程的虚拟内存数据结构,那么就得到了子进程的虚拟内存,相应的底层会对应着一片新的物理内存空间,里面放了与父进程一模一样代码和数据。
父子进程各自会执行哪些代码
复制出子进程后,父子进程各自都有一份相同的代码,而且子进程也会被运行起来,那么我们来看一下,父子进程各自会执行哪些代码。
父进程
①执行fork前的代码
②执行fork函数。父进程执行fork函数时,调用成功会返回值为子进程的PID,进入if(ret > 0){}中,执行里面的代码。if(ret > 0){}中的代码只有父进程才会执行。
③执行fork函数后的代码
子进程
①fork前的代码。尽管子进程复制了这段代码,但是子进程并不会执行,子进程只从fork开始执行。
②子进程调用fork时,返回值为0,注意0不是PID。进入if(ret == 0){},执行里面的代码。if(ret == 0){}中的代码只有子进程执行。
③执行fork后的代码
子进程会继承父进程的哪些属性
子进程继承如下性质
①用户ID,用户组ID
②进程组ID
③会话期ID
④控制终端
⑤当前工作目录
⑥根目录
⑦文件创建方式屏蔽字
⑧环境变量
⑨打开的文件描述符
子进程独立的属性
①进程ID。
②不同的父进程ID。
③父进程设置的锁,子进程不能被继承。
exec加载器
父进程fork复制出子进程的内存空间后,子进程内存空间的代码和数据和父进程是相同的,这样没有太大的意义,我们需要在子进程空间里面运行全新的代码,这样才有意义。
怎么运行新代码?
我们可以在if(ret==0){}里面直接写新代码,但是这样子很麻烦,如果新代码有上万行甚至更多的话,这种做法显然是不行的,因此就有了exec加载器。有了exec后,我们可以单独的另写一个程序,将其编译好后,使用exec来加载即可。
exec函数族
exec的函数有很多个,它们分别是execve、execl、execv、execle、execlp、execvp,都是加载函数。其中execve是系统函数,其它的execl、execv、execle、execlp、execvp都是基于execve封装得到的库函数,因此我们这里重点介绍execve函数
原型
#include <unistd.h> int execve(const char *filename, char **const argv, char **const envp);
功能
向子进程空间加载新程序代码(编译后的机器指令)。
将新程序代码加载(拷贝)到子进程的内存空间,替换掉原有的与父进程一模一样的代码和数据,让子进程空间运行全新的程序。
参数
filename:新程序(可执行文件)所在的路径名。
可以是任何编译型语言所写的程序,比如可以是c、c++、汇编等,这些语言所写的程序被编译为机器指令后,都可以被execve这函数加载执行。正是由于这一点特性,我们才能够在C语言所实现的OS上,运行任何一种编译型语言所编写的程序。
疑问:java可以吗?
java属于解释性语言,它所写的程序被编译后只是字节码,并不是能被CPU直接执行的机器指令,所以不能被execve直接加载执行,而是被虚拟机解释执行。execve需要先加载运行java虚拟机程序,然后再由虚拟机程序去将字节码解释为机器指令,再有cpu去执行
argv:传给main函数的参数,比如我可以将命令行参数传过去
envp:环境变量表
返回值
函数调用成功不返回,失败则返回-1,且errno被设置。
代码演示
new_process.c
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 //extern char **environ; 5 6 int main(int argc, char **argv, char **environ) 7 { 8 int i = 0; 9 10 for(i=0; i<argc; i++) 11 { 12 printf("%s ", argv[i]); 13 } 14 printf("\n---------------------\n"); 15 16 17 for(i=0; NULL!=environ[i]; i++) 18 { 19 printf("%s\n", environ[i]); 20 } 21 printf("\n---------------------\n"); 22 23 24 return 0; 25 }
new_process.c编译成可执行文件new_pro
main.c
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <sys/types.h> 4 #include <unistd.h> 5 6 7 8 int main(int argc, char **argv) 9 { 10 pid_t ret = 0; 11 12 ret = fork(); 13 if(ret > 0) 14 { 15 sleep(1); 16 } 17 else if(ret == 0) 18 { 19 extern char **environ; 20 //int execve(const char *filename, char **const argv, char **const envp); 21 char *my_argv[] = {"fds", "dsfds", NULL}; 22 char *my_env[] = {"AA=aaaaa", "BB=bbbbb", NULL}; 23 execve("./new_pro", my_argv, my_env); 24 } 25 26 return 0; 27 }
Linux在命令行执行./a.out,程序是如何运行起来的
①窗口进程先fork出子进程空间
②调用exec函数加载./a.out程序,并把命令行参数和环境变量表传递给新程序的main函数的形参
Windows双击快捷图标,程序是怎么运行起来的
①图形界面进程fork出子进程空间
②调用exec函数,加载快捷图标所指向程序的代码.以图形界面方式运行时,就没有命令行参数了,但是会传递环境变量表。
system函数
如果我们需要创建一个进子进程,让子进程运行另一个程序的话,可以自己fork、execve来实现,但是这样的操作很麻烦,所以就有了system这个库函数,这函数封装了fork和execve函数,调用时会自动的创建子进程空间,并把新程序的代码加载到子进程空间中,然后运行起来。
原型
#include <stdlib.h> int system(const char *command);
功能
创建子进程,并加载新程序到子进程空间,运行起来。
参数
command:新程序的路径名
新程序的路径名如果包含在$PATH环境变量中,则可以直接写程序名。否则要写出新程序的路径(绝对路径 or 相对路径)
返回值
有点复杂,参考 Linux system函数返回值
进程资源回收
进程运行终止后,不管进程是正常终止还是异常终止的,必须回收进程所占用的资源。
为什么要回收进程的资源?
①程序代码在内存中动态运行起来后,才有了进程,进程既然结束了,就需要将代码占用的内存空间让出来(释放)。
②OS为了管理进程,为每个进程在内存中开辟了一个task_stuct结构体变量,进程结束了,那么这个结构体所占用的内存空间也需要被释放。
③等其它资源
由谁来回收进程资源
由父进程来回收,父进程运行结束时,会负责释放子进程资源。
僵尸进程
子进程终止了,但是父进程还活着,父进程在没有回收子进程资源之前,子进程就是僵尸进程。
为什么子进程会变成僵尸进程?
子进程已经终止不再运行,但是父进程还在运行,它没有释放子进程占用的资源,所以就变成了占着资源不拉屎僵尸进程。就好比人死后不腐烂,身体占用的资源得不到回收是一样的,像这种情况就是所谓的僵尸。
代码演示
# include <stdio.h> # include <stdlib.h> # include <sys/types.h> # include <unistd.h> int main(void) { pid_t ret=0; ret=fork(); if(ret>0) { while(1); } else if(ret==0) { } return e; }
ps查看到的进程状态
R 正在运行
S 处于休眠状态
Z 僵尸进程,进程运行完了,等待被回收资源。
孤儿进程
没爹没妈的孩子就是孤儿,子进程活着,但是父进程终止了,子进程就是孤儿进程。
为了能够回收孤进程终止后的资源,孤儿进程会被托管给我们前面介绍的pid==1的init进程,每当被托管的子进程终止时,init会立即主动回收孤儿进程资源,回收资源的速度很快,所以孤儿进程没有变成僵尸进程的机会。
代码演示
# include <stdio.h> # include <stdlib.h> # include <sys/types.h> # include <unistd.h> int main(void) { pid_t ret=0; ret=fork(); if(ret>0) { } else if(ret==0) { while(1); } return e; }
进程的终止
正常终止
①main调用return
②任意位置调用exit
③任意位置调用_exit
异常终止
如果是被某个信号终止的,就是异常终止。
①自杀:自己调用abort函数,自己给自己发一个SIGABRT信号将自己杀死。
②他杀:由别人发一个信号,将其杀死。
进程终止状态
return、exit、_exit的返回值严格来说应该叫“退出状态”,当退出状态被函数(return、exit、_exit)交给OS内核,OS对其进行加工之后得到的才是“进程终止状态”,父进程调用wait函数便可以得到这个“进程终止状态”。
OS是怎么加工的?
正常终止
进程终止状态 = 终止原因(正常终止)<< 8 | 退出状态的低8位
终止原因用一个数表示
不管return、exit、_exit返回的返回值有多大,只有低8位有效,所以如果返回值太大,只取低8位的值。
举例:返回值是1000
十进制1000
二进制0011 1110 1000
低八位1110 1000
低八位对应十进制232
异常终止
进程终止状态 = 是否产生core文件位 | 终止原因(异常终止)<< 8 | 终止该进程的信号编号
父进程调用wait函数,得到“进程终止状态”有什么用
父进程得到进程终止状态后,就可以判断子进程终止的原因是什么,如果是正常终止的,可以提取出返回值,如果是异常终止的,可以提取出异常终止进程的信号编号。当有OS支持时,进程return、exit、_exit正常终止时,所返回的返回值(退出状态),最终通过“进程终止状态”返回给了父进程。这有什么用,比如,父进程可以根据子进程的终止状态来判断子进程的终止原因,返回值等等,以决定是否重新启动子进程,或则做一些其它的操作,不过一般来说,子进程的终止状态对父进程并没有太大意义。
父进程从内核获取子终止状态
如何获取
①父进程调用wait等子进程结束,如果子进程没有结束的话,父进程调用wait时会一直休眠的等(或者说阻塞的等)。
②子进程终止返回内核,内核构建“进程终止状态”
参考 阴影文字
③内核向父进程发送SIGCHLD信号,通知父进程子进程结束了,你可以获取子进程的“进程终止状态”了。如果父进程没有调用wait函数的话,会忽略这个信号,表示不关心子进程的“进程终止状态”。如果父进程正在调用wait函数等带子进程的“进程终止状态”的话,wait会被SIGCHLD信号唤醒,并获取进“进程终止状态”。一般情况下,父进程都不关心子进程的终止状态是什么,所以我们经常看到的情况是,不管子进程返回什么返回值,其实都无所谓,因为父进程不关心。不过如果我们的程序是一个多进程的程序,而且父进程有获取子进程“终止状态”的需求,此时我们就可以使用wait函数来获取了。
wait函数
原型
#include <sys/types.h> #include <sys/wait.h> pid_t wait(int *status);
功能
获取子进程的终止状态,主动释放子进程占用的资源(释放资源这一条即使不调用wait,父进程也会自动释放)
参数
status:用于存放“进程终止状态”的缓存
返回值
成功返回子进程的PID,失败返回-1,errno被设置。
代码演示
父进程代码
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <sys/types.h> 4 #include <unistd.h> 5 #include <sys/wait.h> 6 7 8 int main(int argc, char **argv) 9 { 10 pid_t ret = 0; 11 12 ret = fork(); 13 if(ret > 0) 14 { 15 int status = 0; 16 17 wait(&status); 18 printf("status = %d\n", status); 19 if(WIFEXITED(status)) 20 { 21 printf("exited:%d\n", WEXITSTATUS(status)); 22 } 23 else if(WIFSIGNALED(status)) 24 { 25 printf("signal killed:%d\n", WTERMSIG(status)); 26 } 27 28 } 29 else if(ret == 0) 30 { 31 extern char **environ; 32 execve("./new_pro", argv, environ); 33 } 34 35 return 0; 36 }
子进程代码
1 #include <stdio.h> 2 #include <stdlib.h> 3 4 //extern char **environ; 5 6 int main(int argc, char **argv, char **environ) 7 { 8 int i = 0; 9 10 for(i=0; i<argc; i++) 11 { 12 printf("%s ", argv[i]); 13 } 14 printf("\n---------------------\n"); 15 16 //while(1); 17 18 19 return 20; 20 }
子进程需要使用命令gcc child.c -o new_pro
OS处理进程终止状态的带参宏
WIFEXITED(status)
提取出终止原因,判断是否是正常终止
①如果表达式为真:表示进程是正常终止的
②为假:不是正常终止的
WIFSIGNALED(status)
提取出终止原因,判断是否是被信号杀死的(异常终止)
①如果表达式为真:是异常终止的
②为假:不是异常终止的
wait的缺点
如果父进程fork创建出了好多子进程,wait只能获取最先终止的那个子进程的“终止”状态,其它的将无法获取,如果你想获取所有子进程终止状态,或者只想获取指定子进程的进程终止状态,需要使用wait的兄弟函数waitpid,它们的原理是相似的。