目录
1.进程等待
概念:通过wait、waitpid方式,让父进程对子进程进行资源回收的等待过程。
目的:
- 解决子进程因为处于僵尸进程带来的内存泄漏问题
- 通过进程等待方式,获取子进程退出的信息(实际上就是获取两个“数字”)
1.1.进程等待的两个接口
1.1.1.wait函数
下面我们通过两个代码来进行学习
代码块一:验证wait能够回收处于僵尸状态的子进程
#include <stdio.h> #include <stdlib.h> #include<string.h> #include<errno.h> #include<unistd.h> void func() { int count = 5; while(count) { printf("child process, pid: %d, ppid: %d, count: %d\n", getpid(), getppid(), count--); sleep(1); } } int main() { pid_t id = fork(); if(id == 0) { func(); exit(0); } else { // 休眠5~10秒钟子进程处于僵尸进程,父进程还在运行,但是子进程没回收就死了 sleep(10); // wait谁调用就是等待谁的子进程 pid_t rid = wait(NULL); if(rid == id) { printf("wait sucess, pid: %d\n", getpid()); } // 等待5s后父进程退出 sleep(5); } return 0; }
看这段代码,我们的预期是:
- 0~5s两个进程同时运行
- 5~10s子进程没有被父进程回收并退出这时候处于僵尸状态
- 10~15s验证wait函数能够回收处于僵尸进程的子进程,并退出
这里我们发现了一个很有意思的东西,我们之前讲过fork函数给父进程返回子进程的pid,而这里的进程等待判断关系 rid 和 id也是相等的,这也就说明了,fork函数给父进程返回子进程的pid的本质就是为了方便对子进程进行进程控制,在这里表现为等待子进程,并回收!!!
代码块二:进一步体会wait的作用
#include <stdio.h> #include <stdlib.h> #include<string.h> #include<errno.h> #include<unistd.h> void func() { int count = 5; while(count) { printf("child process, pid: %d, ppid: %d, count: %d\n", getpid(), getppid(), count--); sleep(1); } } int main() { pid_t id = fork(); if(id == 0) { func(); exit(0); } else { // 父子进程几乎同时运行 所以这个会出现在第一行 printf("before waiting for child\n"); pid_t rid = wait(NULL); // 子进程完成后,父进程完成等待 printf("finish waiting for child\n"); if(rid == id) { printf("wait sucess, pid: %d\n", getpid()); } // 等待5s后父进程退出 sleep(5); } return 0; }
看完这段代码,我们的预期是:
- 0~5s先打印出before waiting for child和子进程模块
- 5s后打印出finish waiting for child,并且父进程等待子进程并回收,这时子进程不会僵尸
- 再打印wait success和pid接着等待退出
控制台结果:
调用了wait函数,父进程会等待子进程完成后,才接收退出的子进程,一直处于等待。
翻译一下:如果使用了wait函数,那么父进程在wait完成后才会继续后续的代码,也就是父进程必须在wait上进行阻塞等待,直到子进程僵尸(瞬间状态,从Z -> X),wait自动回收,继续向下走!(这里感觉有点鸡肋啊)
我们之前在学习阻塞、挂起状态时,学习过:进程会等待硬件资源就绪之前,会并入等待队列,处于硬件资源等待。而通过wait函数的样例,这里父进程在等待回收子进程所处于的等待阻塞,就是处于软件资源等待 。一般而言,父子进程运行优先不知道,但是一般是父进程最后退出,通过阻塞等待来实现!
1.1.2.waitpid函数
ps:wait等价为waitpid(-1, NULL, 0)
#include <stdio.h> #include <stdlib.h> #include<string.h> #include<errno.h> #include<unistd.h> void func() { int count = 5; while(count) { printf("child process, pid: %d, ppid: %d, count: %d\n", getpid(), getppid(), count--); sleep(1); } } int main() { pid_t id = fork(); if(id == 0) { func(); exit(10); } else { printf("before waiting for child\n"); int status = 0; pid_t rid = waitpid(id, &status, 0); printf("finish waiting for child\n"); if(rid == id) { printf("wait sucess, pid: %d, status: %d\n", getpid(), status); } // 等待5s后父进程退出 sleep(5); } return 0; }
在该段代码中,我们定义了一个status变量,通过waitpid的函数说明,我们知道这个函数会接收子进程正常终止的退出码,也就是我们写的exit(10)。但是实际上打印的status却是2560,这是为什么呢?接下来我们讲一下status这个输出型变量:
int status不能单纯的认为就是一个整型变量,它的数据不是整存整取的,他是放置在32位bit的不同区域,使用时只考虑低地址的16个比特位,但是status的实际使用又对应着进程中断的两种情况
那么这个时候我们来分析,10(10) = 0000 1010 (2)对应着status的前8位,但是status实际上是只考虑16个比特位的,也就是2560(10) = 0000 1010 0000 0000 (2)
补充:我们可以通过位操作来实现打印 exit signal 和 exit code
printf("exit signal: %d, exit code: %d\n, status&0x7F, (status>>8)&0xFF);
为了方便用户查看退出码,C中也封装了两个宏,具体用法如下
// 当WIFEXITED(status)为1时,表示进程正常退出,打印退出码 if(WIFEXITED(status)) { printf("child normal exit, exit code: %d\n", WEXITSTATUS(status)); } // 为0时表示进程异常终止 else { printf("child exit unexpected\n"); }
1.1.3.非阻塞等待
如图:当我们使用wait接口调用时,无法避免的会进行阻塞等待,我们在之前的讲解中也觉得在复杂的场景中可能是一个十分鸡肋的功能,所以waitpid接口通过option变量支持“非阻塞等待”
int options:
- 0,表示阻塞等待
- WNOHANG,表示等待时,以非阻塞的方式等待
如何解释非等待阻塞呢?试想下面这个场景:
你和你喜欢的人约好了一起去外面玩,当你到她宿舍楼下时,发现她还没到,可能没化好妆、可能在洗头,于是你想要知道她什么时候可以就绪,也就是你什么时候可以完成等待!
- 方式一:你给她打微信电话,在她就绪之前,你一直不挂机,这个时候你除了打电话,你的手机无法进行其他事情,这里就是阻塞等待
- 方式二:你每隔一段时间打微信电话,如果得到她没有就绪的信息,那么你就挂掉电话,转而可以去刷个视频、打个游戏......这时你可以做其他事情,这里就是“非阻塞等待”
此时对于waipid的返回值有:
- rid大于0时,表示等待成功但是子进程已退出
- rid等于0,表示等待成功,但是子进程未退出,需要继续等待
- rid小于0,表示等待失败
实际场景下非阻塞等待,需要轮询使用,也就是需要再while循环中反复判断,当子进程退出或者等待失败时break出来。
while(1) { pid_t rid = waitpid(id, &status, WNOHANG); if(rid >0) { // 表示这一次等待结束,子进程已退出 break; } else if(rid == 0) { // 表示这一次等待结束,但是子进程没退出 // 父进程进入后续代码! } else{ // 这一次等待失败,无法获取子进程的情况 break; } }
现在我们写一个demo来体会一下,实现:父进程在等待结束时进行不同的任务
#include <stdio.h> #include <stdlib.h> #include<string.h> #include<errno.h> #include<unistd.h> #define TASK_NUM 3 // 类型转化,把void()函数定义为函数指针,通过函数名可以访问函数 typedef void (*task_t)(); void downLoad() { printf("downLoading ......\n"); } void printLog() { printf("print Log\n"); } void initTask(task_t tasks[], int task_num) { int i = 0; for(; i < task_num; i++) tasks[i] = NULL; } void pushTask(task_t tasks[], task_t t, int task_num) { int i = 0; int flag = 0; for(; i < task_num; i++) { if(tasks[i] == NULL) { tasks[i] = t; printf("push task into the taskLine successfully\n"); flag = 1; break; } } if(flag == 0) printf("failed to push task\n"); } void runTask(task_t tasks[], int task_num) { int i = 0; for(; i < task_num; i++) { if(tasks[i] != NULL) tasks[i](); } } void childFunc() { int count = 5; while(count--) { sleep(1); printf("child process: %d\n", count); } } int main() { task_t tasks[TASK_NUM]; initTask(tasks, TASK_NUM); pushTask(tasks, downLoad, TASK_NUM); pushTask(tasks, printLog, TASK_NUM); pid_t id = fork(); if(id == 0) { childFunc(); printf("child has been exited\n"); exit(0); } // father // while(1) { int status = 0; pid_t rid = waitpid(id, &status, WNOHANG); if(rid == 0) { // 当rid为0时表示父进程仍处于等待 printf("child process is still running\n"); sleep(1); runTask(tasks, TASK_NUM); } else if(rid > 0) { printf("father waits successfully\n"); sleep(1); printf("exit_signal: %d, exit_code: %d\n", WIFEXITED(status), WEXITSTATUS(status)); break; } else{ printf("wrong id warning\n"); sleep(1); break; } } return 0; }
1.2.进程等待的实现
如图是操作系统层面下的流程
具体如下:
- 首先父进程创建子进程后,通过wait、waitpid这两个系统调用接口进行“进程等待”
- 然后子进程准备退出,释放代码和数据,并返回退出信息(经过一定的变换)存储在父进程的指针变量status中
- 接着子进程进入“僵尸状态”,父进程快速回收,接着父进程退出“进程等待”,继续执行后续代码
这里我们需要注意的是:
- 用户无法跨越操作系统直接的访问子进程PCB中的退出信息,需要构建内外部的桥梁来间接的接收到这个退出信息。(这里我们也可以再次封装,在原先需要位运算转化,直接返回两个数字!具体就是上一部分的两个“宏”)
- 并且因为父进程也无法直接访问到子进程的数据(进程具有独立性),也就是为什么我们不能在代码中设置一个全局变量int status,而是需要通过系统调用呢?因为如果子进程需要对status这个属于父进程的全局变量进行写入,那么就会发生写时拷贝,开辟出一块新的物理空间,而父进程无法通过自己的页表访问到这个变量。也就是一旦子进程代码和数据释放后,这个status也就没了
这时候我们就会疑问:父进程是如何进行等待子进程的?
- 当父进程调用wait、waitpid时,父进程将连入子进程的等待队列,当子进程完成自己的代码后,退出时,处于Z状态,这时父进程离开等待队列,得到CPU调度,接着回收子进程的退出信息
- 对应进程在等待时,需要放入等待队列中,将等待的进程放在被等待进程的等待队列里,就是互相等待了!
- 那么这里我们在回顾一下,我们之前知道硬件等待时,需要连入硬件等待队列,同理这里软件、进程也需要连入各自的等待队列中,所以等待的本质,就是把一个进程、一个实体的抽象对象放入对应的等待队列中
2.进程程序替换
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数 以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动 例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
我们知道我们所创建的子进程执行的代码,都是父进程的一部分,但是更多的场景中,我们想让子进程执行全新的代码和全新的数据,与父进程区分开来,这就是“程序替换”
2.1.单进程的程序替换
我们一般通过excel函数来实现单进程的程序替换
int main() { printf("pid: %d, before excel\n", getpid()); execl("/usr/bin/pwd", "pwd", NULL); // execl("/usr/bin/ls", "ls", "-a", NULL); printf("pid: %d, after execl\n", getpid()); }
如图:我们发现执行到execl函数后,代码就不会进行后面的这一行after execl了,这里是为什么呢?并且当我们进行两次execl函数发现,也只生效第一次!这时我们猜测进入execl代码后就不再返回源代码模块了,穿越到“异世界”了。
带着这个疑问,我们来学习一下单进程替换的原理:
如图是:一个进程在创建过程中的示意图,我们知道对于一个进程会创建一份的代码和数据,当我们使用execl函数时,本质上就是加载另一个程序,用新程序的代码和数据来替换这一份的代码和数据,这时进程的属性不发生改变,发生改变的只有加载到物理内存的部分!
这里我们就可以看出代码和数据已经被替换了,那么不返回原本的代码模块那也很正常。
更具体一点是:excel进行程序替换,代码和数据被替换的同时,在寄存器字段中程序的程序计步器pc指针对应的值由当前代码的位置替换成新的程序的Entry字段,也就是pc指针被修改了,指向了新的程序。那么就无法回到源代码的后续模块了(找不到家了!!!)
2.2.多进程的程序替换
这里我们用父子进程这个简单的多进程场景来模拟!
#include<unistd.h> #include<sys/types.h> #include<sys/wait.h> int main() { pid_t id = fork(); if(id == 0) { printf("child pid: %d, before excel\n", getpid()); sleep(3); execl("/usr/bin/pwd", "pwd", NULL); // execl("/usr/bin/ls", "ls", "-a", NULL); printf("pid: %d, after execl\n", getpid()); // 这里我们发现不会运行!!! } else if(id > 0) { pid_t rid = waitpid(-1, NULL, 0); printf("child pid: %d, father pid: %d\n", rid, getpid()); } else{ return 1; } }
如图:我们发现进行程序替换后,原本进程的id不改变,也就对应我们上面说的进程的属性不受改变,所改变的只有代码和数据。
那么接下来我们来分析一下多进程情况下,程序替换和单进程有什么区别!
也没有什么区别,只是多了一步“写时拷贝”而已,这里就不赘述了,因为以前的博客详细讲过写时拷贝。那再提一嘴:程序在进行到execl函数时是怎么进入新的程序并进行的呢?
还是pc指针!在要执行的程序中ELF中存有“Entry”这个变量,存放着程序的起始地址,把他放进PCB寄存器中的pc指针中(进程上下文),待CPU调度后,进入程序开始执行!
讲到这里进程的程序替换我们就大概完成了!后面我们就讲一下几个程序替换的函数。
2.3.程序替换的函数
在我们进行程序替换时我们需要明确两个问题
- 这个可执行程序的位置在哪?需要获得路径和文件名
- 怎么通过exec函数接口执行
常见的6种exec函数接口:
#include<unistd.h>
- int execl(const char *path, const char *arg, ...);
// 绝对路径 + 程序名 + 调用的选项 + 空指针结尾 execl("/usr/bin/ls", "ls", "-a", NULL);
- int execlp(const char *file, const char *arg, ...);
// execl跟execlp的区别就是:execlp提供了环境变量,不用绝对路径了 execlp("ls", "ls", "-a", NULL);
- int execle(const char *path, const char *arg, ...,char *const envp[]);
char* const envp[] = {"PATH = /usr/bin/ls", NULL}; // 环境变量 + 程序名 + 选项 + 空指针 + 导入环境变量的指针数组 execle("ls","ls", "-a", NULL, envp);
- int execv(const char *path, char *const argv[]);
char* const argv[] = {"ls", "-a", NULL}; // 绝对路径 + 带有选项的指针数组 execv("/usr/bin/ls", argv);
- int execvp(const char *file, char *const argv[]);
char* const argv[] = {"ls", "-a", NULL}; // 环境变量 + 带有选项的指针数组 execv("ls", argv);
总结如图:
事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve,所以execve在man手册 第2节,其它函数在 man手册第3节。这些函数之间的关系如下图所示。
其他的5个衍生也只是分别进行了封装!
2.4.程序替换的应用
2.4.1.命令行参数和环境变量
命令行参数!!!
当我们调用exec函数时,我们可以通过“选项”来操作,跟一部分类似的就是“命令行参数”,我们可以大致的实现命令行参数结合程序替换
#include <stdio.h> #include<string.h> #include<unistd.h> #include<sys/types.h> #include<sys/wait.h> int main() { pid_t id = fork(); if(id == 0) { printf("child process change to cal.exe\n"); execl("./cal.exe","cal.exe" ,"-add", "10", "20", NULL); } else if(id > 0) { pid_t rid = waitpid(-1, NULL, 0); printf("child pid: %d, father pid: %d\n", rid, getpid()); } else{ return 1; } } /分割线/// // cal.exe的代码逻辑 #include<stdio.h> #include <string.h> #include <stdlib.h> int main(int argc, char* argv[]) { if (argc == 4) { int num1 = atoi(argv[2]); int num2 = atoi(argv[3]); if (strcmp(argv[1], "-add") == 0) { printf("%d + %d = %d\n", num1, num2, num1 + num2); } else if (strcmp(argv[1], "-sub") == 0) { printf("%d - %d = %d\n", num1, num2, num1 - num2); } else { printf("option illegal\n"); printf("the right input is: process option(-add, -sub) num1 num2\n"); } } else { printf("input illegal\n"); printf("the right input is: process option(-add, -sub) num1 num2\n"); } return 0; }
借助这一段代码,以及我们开始的main函数中,输入的选项导入替换函数的命令行参数
在这里我们也可以知道我们命令行参数控制程序替换的走向!
环境变量!!!
验证替换后的程序会继承原父进程的环境变量
我们知道程序替换后仅仅改变的只有进程的代码和数据,进程的属性不变,所以环境变量理论上也是原父进程的内容,下面我们验证一下:
int main() { // 先运行一遍,在取消注释运行一遍 // putenv("NEW_ENV=这是新的环境变量,请注意观察"); pid_t id = fork(); if(id == 0) { printf("test for env\n"); execl("./env.exe","env.exe", NULL); } else if(id > 0) { pid_t rid = waitpid(-1, NULL, 0); } else{ return 1; } } /// 分割线/ // 替换的程序 int main(int argc, char* argv[], char* env[]) { // 打印环境变量表 for(int i = 0; env[i] != nullptr; i++) { std::cout << i << ": "<< env[i]<< std::endl; } }
截取了部分输出的环境变量:
在这里我们的代码逻辑:在父进程中增加一个bash没有的环境变量,如果被替换的程序的命令行参数、环境变量的空间被覆盖那么,就不会包括父进程的环境变量。进而我们也可以通过减少环境变量,或者是导入不同的环境变量(自己构造环境变量表传入)来实现,具体的就不过多操作了。
2.4.2.调用自己写的程序
我们在上面进行程序替换时,调用的是Linux的内置指令,虽然它本质上也是程序(程序就是可执行文件),但是看起来还是有点不得劲,那么我们能不能在我们的代码中调用C语言程序,bash内的脚本语言,Python语言.....
样例一:调用CPP程序
我们只要在当前文件夹写一个cpp程序,并编译,接着通过程序名调用,这样子我们就能实现在我们原先代码中调用CPP程序了。
样例二:调用shell脚本
如图我们创建并写入test.sh文件,实现逻辑打印两句话和创建3个文件,最后我们验证时,发现,脚本语言也可以被exec函数进行程序替换
样例三:Python语言
// 这里需要注意的是实际上执行调用的是Python解释器,然后选择py文件 execl("/usr/bin/python","python" ,"p.py", NULL);
我们发现最终上述代码都可以实现我们的想法,那么为什么我们的程序替换能够替换系统指令程序,又可以替换我们自己写的程序?
这个问题我们看一下我们的标题“进程程序替换”,无论是什么语言写出来的程序,加载进内存时,都会成为一个进程,而exec函数叫做“进程程序替换”,那么就是通用的,只要是进程就能够替换,也就是系统大于一切,程序替换可以实现跨语言工作,也就是实际工作中我们可以结合各种语言的优势了!(讲的有点偏题了(捂脸))