一. 进程创建 — fork
1. 什么是fork()函数
头文件:#include <unistd.h> 函数原型:pid_t fork(void);
作用:从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
返回值:给子进程中返回0,父进程返回子进程的pid,创建失败返回-1。
当一个进程调用fork之后,就有代码完全相同的进程。而且它们都运行到相同的地方。通过判断fork的返回值并配合if语句可以让父子进程分流,执行各自的代码,看如下程序:
编译运行:
$ ./myproc
child pid is 1645, fork return 0
father pid is 1000, fork return 1645
2. fork函数的作用
fork函数是存在于内核空间的,进程调用fork,控制转移到内核中的fork代码,内核完成如下任务:
- 分配新的内存块(物理空间)和内核数据结构(PCB、页表、虚拟地址空间等)给子进程。
- 将父进程大部分数据结构的内容拷贝给子进程(采用写实拷贝,为了节省物理空间)。
- 添加子进程到系统进程列表当中(到这步时子进程已经创建成功)。
- fork返回,开始调度器调度,先返回谁由调度器决定。
3. fork补充
为何给子进程返回0,给父进程返回子进程的pid?
在现实生活中,父亲:孩子 = 1:n,即一个父亲可以有多个孩子,但一个孩子只能有一个父亲。父亲的多个孩子在一起时,父亲会具体叫某个孩子的名字,这样这个孩子才会知道父亲在叫自己;但所有孩子都只会叫他们的父亲爸爸。
进程也一样,一个父进程有多个子进程,每个子进程要执行父进程交给它们的任务,想要知道子进程执行的怎么样,父进程必须明确区分每个子进程,所以必须得到它们的pid,即子进程必须要被父进程特殊标识,而父进程不需要被子进程特殊标识。
子进程从哪里开始运行?
fork之后,父进程继续往后运行,而子进程也是从fork之后的位置开始运行,谁先运行有调度器决定。当然子进程也跟父进程代码是共用同一份的,只是子进程不执行fork之前的代码罢了。
什么是写实拷贝?
通常,父子代码共享,当二者都不对代码里的共用数据写入时,数据也是共享的,当任意一方试图写入,操作系统会另外给要写入的数据再开辟一块空间,并更新页表的映射关系。
fork使用场景
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个与父进程毫不相干的程序。例如子进程从fork返回后,调用exec函数。
fork调用失败的原因
- 系统中进程总的数量过多。
- 实际用户的可创建进程数超过了限制。
二. 进程退出
1. 进程退出的场景
1.1 正常退出
- 在main()函数中执行return代表进程的退出。其他普通函数的return不算。
- 任意位置调用 exit() 或 _exit(),都表示退出当前进程。
PS:通过进程的返回值(也叫作退出码)判断运行结果是否正确,一般规定返回0表示正确,非0表示错误。
我们可以通过命令:echo $? 来查看最近一次进程运行结果的退出码。
1.2 异常退出
进程收到某个信号,而该信号使程序终止。比如下面程序,我们有进行野指针的访问,编译器检查到后会报告给操作系统,之后系统发送段错误信号并终止进程:
PS:进程如果是异常退出,那么它的退出码是没有任何意义的。
2. exit 和 _exit
下面我们讨论进程正常退出时的其中两种方式,即exit和_exit,他们是两个不同的函数。
2.1 函数介绍
_exit函数
头文件:#include <unistd.h> 原型:void _exit(int exit_code);
作用:直接终止整个进程。
参数:进程的退出码。
exit函数
头文件:#include <unistd.h> 原型:void exit(int status);
作用:先执行用户通过 atexit或on_exit定义的清理函数,然后刷新缓冲区,最后调用_exit来终止整个进程。
参数:进程的退出码。
2.2 二者的区别
exit()函数的底层最终还是会调用_exit()来终止整个进程,不过在这之前会完成一些该进程相关清理工作。
通过一段代码感受一下,_exit因为没有刷新缓冲区,所以什么都没输出。
三. 进程等待
1. 什么是进程等待
子进程想要完全退出,最后必须由父进程调用wait、waitpid函数来等待子进程退出,回收资源和获取子进程退出状态。
2. 为什么要有进程等待
子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而出现内存泄漏,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。而且,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
总结进程等待的作用有两个:
- 回收子进程资源,防止内存泄漏。
- 获取子进程的退出状态。
3. 如何完成进程等待
有两个函数可以完成进程等待:wait和waitpid,它们两个都属于系统调用函数。
3.1 函数介绍
wait
头文件:#include<sys/wait.h> 和 #include<sys/wait.h> 原型:pid_t wait(int* status)
参数:输出型参数,获取任意一个子进程退出状态,不关心则可以设置成为NULL。
返回值:等待成功(包括子进程正常和异常退出)返回被等待进程pid、子进程还没结束就继续等、等待失败返回-1(进程不存在)。
waitpid
头文件:#include<sys/types.h> 和 #include<sys/wait.h> 原型:pid_ t waitpid(pid_t pid, int *status, int options)
参数:
- pid:指明要等待的子进程的pid,当设置为-1时代表等待任意子进程(此时与wait等效)。
- status:子进程的状态码,从中可以得到子进程的退出情况(正常退出还是异常退出)和退出码。不关心的话可以设置为NULL。
- options:当其为0时代表阻塞式等待,为WNOHANG时,为非阻塞式等待(检测到子进程还未退出,会返回0)。
返回值:
- 等待成功(子进程正常或异常退出)返回被等待进程的pid。
- 子进程若还没退出就继续等或者返回0,这取决于options是阻塞式等待或非阻塞式等待。
- 等待失败(子进程不存在)返回-1。
总结:waitpid就是wait的升级版,它相比于wait而言可以指定要等待那个子进程(wait是等待任意一个子进程)和可以实现非阻塞式等待(wait只能阻塞式等待)。
3.2 状态码
3.2.1 状态码的基本认识
wait和waitpid,都有一个status参数,即子进程的退出状态码,该参数是一个输出型参数,由操作系统赋值。如果传递NULL,表示不关心子进程的退出信息,否则操作系统会把这些子进程的退出信息(包括信号和退出码)通过status这个输出型参数反馈给父进程。
status不能简单的当作整形,而应当作位图来看待,我们只研究status低16比特位。
- 其中低7位(对应下标0 - 6)代表子进程的退出信号,如果它非0代表子进程异常退出。
- 次低8位(对应下标8 - 15)代表子进程的退出码,就是我们main函数中 return 和 exit或_exit的返回值。只有等待成功(即子进程正常或异常退出)退出码才有意义。
具体细节如下图:
3.2.2 状态码的解析方法
解析状态码的方法有两种:位运算和宏。
方法一:通过位运算解析状态码
- status & 0x7f :得到低7位的数据。即子进程的退出信号:如果为0表示子进程正常退出,非0表示异常退出。
- (status>>8) & 0xff:得到次低8位的数据,即正常退出前提下子进程的退出码。
样例:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main() { pid_t pid = fork(); if(pid==0)//子进程 { // 子进程等待30秒后才退出 sleep(30) exit(2);// 子进程退出码为2 } else if(pid>0)//父进程 { // 父进程里定义的输出型参数,传入wait,用来获取子进程的退出状态 int st=0; int ret=wait(&st); if(ret>0 && (st&0x7f)==0)//等待成功且子进程正常退出 { printf("child exit code is:%d\n",(st>>8)&0xff); } else if(ret>0 && (st & 0x7f)>0)//等待成功且子进程异常退出 { printf("child sig code is:%d\n",st&0x7f); } } return 0; }
正常情况等待30秒后输出:
child exit code is:2
如果在等待30s期间,在另外一个终端通过kill -9 杀死子进程,会出现:
child sig code is:9
方法二:通过宏解析状态码
1、WIFEXITED(status) 即 "wait if exited"缩写,若此值为真,表明进程正常结束。此时可通过 WEXITSTATUS(status) 即 "wait exit status"缩写,来获取进程退出码。
// 其中status为输出型参数,就是子进程的退出状态 if(WIFEXITED(status)) { printf("退出码为:%d\n", WEXITSTATUS(status)); }
2、WIFSIGNALED(status) 即"wait signaled"缩写,非0表明进程异常终止。此时可通过 WTERMSIG(status) 即"wait term signal"缩写,获取进程的退出信号。
// 其中status为输出型参数,就是子进程的退出状态 if(WIFSIGNALED(status)) { printf("退出信号为:%d\n", WTERMSIG(status)); }
样例:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main() { pid_t pid = fork(); if(pid==0)//子进程 { sleep(30); exit(2); } else if(pid>0)//父进程 { int st=0; int ret=wait(&st); if(ret>0 && WIFEXITED(st))// 等待成功且子进程正常退出 { printf("child exit code is:%d\n",WEXITSTATUS(st)); } else if(ret>0 && WIFSIGNALED(st))// 等待成功且子进程异常退出 { printf("child sig code is:%d\n",WTERMSIG(st)); } } return 0; }
等待30秒
child exit code is:2
如果在等待30秒期间,在另外一个终端kill -9 杀死子进程,会出现
child sig code is:9
3.3 进程的等待方式
3.3.1 阻塞式等待
运行到wait或waitpid时,如果子进程还没退出,父进程就停在这里不动直至子进程退出,这就叫做阻塞式等待。其中wait只能阻塞式等待,而waitpid的第三个参数option传0时才是阻塞式等待。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main() { pid_t pid = fork(); if(pid==0)//子进程 { sleep(30); exit(2); } else if(pid>0)//父进程 { int st=0; int ret=waitpid(-1,&st,0);//阻塞式等待 if(ret>0 && WIFEXITED(st)) { printf("child exit code is:%d\n",WEXITSTATUS(st)); } else { printf("wait child fail\n"); } cout<<"I'm here<<endl; } return 0; }
等待30秒
child exit code is:2
I’m here
3.3.2 非阻塞式等待
父进程运行到waitpid时,若子进程还在运行中就继续做父进程自己的事情,完成后再来检测子进程是否退出了,一直重复这个过程就是非阻塞式等待。waitpid的options传WNOHANG即"wait no hang",如果检测到子进程还未退出,返回0。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main() { pid_t pid = fork(); if(pid==0)//子进程 { sleep(3); exit(2); } else if(pid>0)//父进程 { int st=0; int ret=0; do { ret=waitpid(-1, &st, WNOHANG);//非阻塞式等待 // 检测到子进程还没有退出,父进程先做自己的事 // 做完成后,再来检测子进程是否退出 if(ret==0) { sleep(1); printf("haha\n"); } }while(ret==0); // 等待完成后的处理 if(ret>0 && WIFEXITED(st)) { printf("child exit code is:%d\n",WEXITSTATUS(st)); } else { printf("wait child fail\n"); } cout<<"I'm here<<endl; } return 0; }
编译运行:
haha
haha
haha
child exit code is:2
I’m here
四. 进程替换
1. 什么是进程替换
进程替换是在当前进程pcb并不退出的情况下,替换当前进程正在运行的程序为新的程序(加载另一个程序在内存中,更新页表信息,初始化虚拟地址空间)。
2. 为什么要有进程替换
子进程可以执行与父进程不同的程序,这样子进程的执行就会更加独立、灵活。
3. 进程替换的方法
3.1 exec系列函数介绍
其中有六种以exec开头的函数,这一系列统称exec函数。
头文头文件:#include <unistd.h> int execl(const char *path, const char *arg, ...); int execlp(const char *file, const char *arg, ...); int execle(const char *path, const char *arg,..., char * const envp[]); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execvpe(const char *file, char *const argv[],char *const envp[]);
返回值
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
- 如果调用出错则返回-1。原因包括:要替换程序的权限不允许、命令、选项不存在或者拼写错误。
- 所以exec函数只有出错的返回值而没有成功的返回值。
参数理解
- path:可执行程序的路径。
- file:可执行程序名称,默认到PATH环境变量下的各目录中搜寻该可执行程序。
- arg:即agrement,是一系列字符串指针,才开始到结束每一个字符串指针对应你要指向的命令或选项,最后必须以NULL结束。
- argv[]:即agrement value,是一个字符串指针数组,每一个元素对应可执行程序名称及其所带的参数,第一个参数为可执行文件名字,最后一个元素必须以NULL结束。
命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
- l : 即list,使用参数列表。
- v:即vector,把各个参数统一存储到一个数组里。
- p:使用可执行程序名,并从PATH环境变量下的路径里进行寻找该可执行程序。
- e:多了envp[]数组,使用新的环境变量代替调用进程的默认的环境变量。
3.2 exec系列函数使用
1、带p与不带p
带p的话第一个参数就不用写明可执行程序的路径(绝对路径或相对路径都可以),只需写出命令的名字就行,它会像系统执行命令一样到PATH环境变量里它所指定的各目录中搜寻该可执行文件。
我们有两个同一目录下编译后的可执行程序:myproc和myexec,他们的代码如下
我们运行myproc程序,里面又通过 execl() 函数把当前程序替换为另一个程序myexec。
$ ./myproc
**** before exec ****
agrv[0] = myexec
agrv[1] = hello world
可以看到,经过 execl() 函数替换后,原程序最后的“after exec”不再执行,而是去执行另外一个程序myexec去了,即替换成功后不再返回。
接下来我们使用带有p的 execlp(),这样我们第一个参数只需写我们想要执行的程序的名字就可以了。
$ ./myproc
**** before exec ****
**** after exec ****
看结果,我们并没有替换成功,因为环境变量PATH里的路径中没有myexec这个程序,我们把myexec拷贝到PATH下的其中一个路径/bin后在试试看:
$ sudo cp ./myexec /bin
$ ./myproc
**** before exec ****
agrv[0] = myexec
agrv[1] = hello world
这次成功了,所以对于带p的函数,必须先保证我们想要替换的程序必须能在环境变量PATH里找到才行。
2、 带l和带v
带l(即list)的函数:你需要把命令和选项作为参数依次、逐个的传入,最后要用空指针标识结束。
execl("myexec","myexec","hello world",NULL);
带v(即vector)的函数:要求把命令和选项同时放到一个数组里,最后要用空指针标识结束。调用时只需把数组传入即可。
char* const arr[]={"myexec","hello world",NULL}; execv("./myexec",arr);
3. 带e的函数
包括 execle()、execvpe(),替换前可以传递一个指向环境字符串的指针数组。
参数例如char* myenv[ ] = {“AA=111”,“BB=222”,“CC=333”,NULL},带e的话就表示该函数读取myenv[ ]数组,而不使用默认的系统配置的环境变量。即使用传入的环境变量,替换了默认的环境变量。
以 execle() 为例,我们重新编写同一目录下的myproc.c和myexec.c两个文件
生成可执行程序后,编译运行
$ ./myproc
**** before exec ****
AA=111
BB=222
CC=333
可以看到替换后的程序myexec确实使用了我们替换前传入的自己写的环境变量myenv。
3.3 小程序 — 实现一个简易的Shell
什么是Shell
hell是指提供使用者使用界面的软件,它接收命令,然后调用相关的应用程序。
Shell实现原理
shell作为父进程用fork建立子进程,用exec系列函数簇在子进程中运行用户指定的程序,父进程shell用wait命令等待其子进程结束。wait系统调用同时从内核取得退出状态或者信号序列以告知子进程是如何结束的。
代码实现
include <iostream> #include <vector> #include <string.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> using namespace std; int main() { while(1) { // 获取命令行 + 解析命令行 cout<<"[myshell]$ "; char* argList[20] = {nullptr}; string s; string tmp; int i = 0; vector<string> v(20); // 1、获取一行命令行,存储在string类型对象s中 getline(cin, s); // 2、遍历s,取出其中的每一个命令和选项,先放到数组v中,完成字符串内容的深拷贝 // 在把每一个元素的指针存到指针数组argList里 for(auto e : s) { if(e == ' ') { v[i] = tmp; argList[i] = (char*)v[i].c_str(); ++i; tmp.clear(); } else { tmp += e; } } // 最后一个命令还没有存放,因为我们输入的一行字符串最后一个字符不是以空格结尾的 argList[i] = (char*)tmp.c_str(); // 3、父子进程分流完成各自的任务 // 子进程:用execvp完成程序替换 // 父进程:等待子进程 pid_t id = fork(); if(id == 0)// 子进程 { execvp(argList[0], argList); exit(-1); } else if(id > 0)// 父进程 { int status = 0; int ret = wait(&status); if(ret > 0)// a、等待成功 { if(WIFEXITED(status))// 正常退出 { cout<<"child exit code is:"<<WEXITSTATUS(status)<<endl; } else// 异常退出 { cout<<"abnormal exited"<<endl; } } else// b、等待失败 { cout<<"wait fail"<<endl; } } } return 0; }
效果演示: