目录
前言
如果友友们对fork还不熟悉可以先看这篇文章提前了解一下~
一. 进程创建
写时拷贝
本文我们接着对fork的写时拷贝进行讲解~
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。
物理内存中也会去划分,代码段就去开辟存放代码的空间,然后与虚拟地址建立映射~代码默认不进行修改~
数据段就去开辟存放数据的空间,然后与虚拟地址建立映射~而数据是可以被写入的,这也引出了我们需要了解的内容——写时拷贝~
为什么要有写时拷贝呢?难道创建子进程的时候不能直接拷贝一份数据给子进程吗?就不要去共享了~
因为os不清楚子进程是否需要写入数据,为了节省资源所以才有写时拷贝~
那为什么还要把以前的数据也给拷贝进去呢?只开辟空间不好吗?
子进程若要修改数据那也只是做局部的增删查改,它的本质是帮助父进程完成工作,肯定得参照父进程的数据~
那么写时拷贝是如何做到的呢?真的就简简单单开辟空间拷贝原数据吗?
除此之外,还需要页表的配合~
页表其实除了地址之间的映射,里面还存在其他的数据,其中主要的一项数据就是关于这段虚拟地址的权限问题~
下面我们用一个例子来说明~
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { char* str = "hello"; *str = 'H'; return 0; }
最后我们发现写入失败~这是为什么呢?因为常量字符串有常性,不可被修改~ 那凭什么具有常性呢?总不能说有就有吧~
那是因为str存储的字符串起始地址为虚拟地址~当我们尝试写入修改数据时就会触发虚拟地址转物理地址(开空间后映射),但是在页表中的权限栏里仅是可读,所以才不可被修改~
这种叫做运行时的报错~ 而我们平时加上const修饰变量只是提醒作用,它并没有限制你修改的功能,它只是把运行报错提前到了编译报错~
写时拷贝流程线:
一般在父进程创建出子进程后,父进程的数据段权限就会从rw变为r,只有当我们尝试去写入的时候——>页面中断——>OS就会去检查写入是否合理(如对常量进行修改本身就是不合理的)——>判断合理后就去物理内存申请空间放入数据(写时拷贝)——>建立映射关系——>修改权限~
二. 进程终止
其实main函数也是有返回值的~而这类返回值我们称为进程的退出码~
return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数。return返回的时候也就代表进程退出的时候,return xxx,退出码,我们还可以设置退出码的字符串一样
一般来说退出码0代码进程执行成功,而非0的数字代表进程失败,并且会带有对于进程失败的描述,当然我们也可以像上面所说一样自定义退出码及其失败描述~
指令echo $? 可以查看最近一次进程完毕的退出码
int main() { printf("hello\n"); return 10; }
strerror函数:获取失败进程描述
int main() { for(int i = 0; i < 200; i++) { printf("%d: %s\n", i, strerror(i)); } return 0; }
我们最后测试到这些失败描述是和退出码是对应上的~
扩展:进程退出有退出码,而函数退出同样会有返回值,这里我们叫做错误码~
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <string.h> enum{ success=0, open_err, malloc_err }; const char* errorToDesc(int code) { switch(code) { case success: return "success"; case open_err: return "file open error"; case malloc_err: return "malloc error"; default: return "unknown error"; } } int main() { int code = malloc_err; printf("%s\n", errorToDesc(code)); return code; }
fopen函数,失败了返回一个退出码errno与失败描述
int main() { FILE *fp = fopen("./log.txt", "r"); printf("%d:%s\n", errno, strerror(errno)); return 0; }
现在我们再来回到进程的话题~
进程退出的场景分为以下三种:
- 进程代码执行完,结果正确
- 进程代码执行完,结果错误
- 进程代码没有执行完,出异常了,发出异常信号
往往都是先判断最后一种场景,只有不出现异常才会继续看前两种场景
这就好比你通过作弊而考到了100分,但老师更注意的是你作弊了这件事而不是100分。
int main() { while(1) { printf("process is running, pid: %d\n\n", getpid()); sleep(1); } return 0; }
这是我们人工使用的异常(与下方的除0异常类似)
int main() { while(1) { printf("process is running, pid: %d\n\n", getpid()); sleep(1); int a = 10; a/=0; } return 0; }
这是自主出现的异常,通过异常信号可以解释为除0异常
int main() { int*p = null; while(1) { printf("process is running, pid: %d\n\n", getpid()); sleep(1); *p = 100; } return 0; }
这是自主出现的信号异常,对应编号为11 ,我们也可以通过kill -11 pid 自主产生异常
了解完退出码,异常信号后我们就来进行进程终止exit(退出码)~
int main() { printf("hello Linux, hello bite"); sleep(3); exit(1); }
其实当前我们认识的exit为c语言的调用接口,是一个库函数~
而还有一种系统调用的进程终止 _exit,它与exit唯一的区别就是exit在进程终止时会强制刷新缓冲区,而_exit并不会刷新缓冲区。(这也侧面说明了缓冲区不在操作系统内,否则系统调用怎会无法查到缓冲区~)
而且exit里面是封装了_exit系统调用函数,目的就是为了代码的可移植性与跨平台性~
三. 进程等待
进程退出的时候在pcb中需要保留哪些退出信息呢?
退出码,信号编号,这样才能让父进程知道子进程退出了,需要去回收子进程的资源~
进程等待的必要性:
- 父进程在等待阶段去回收子进程的资源(必要)
- 父进程在等待阶段获取子进程的返回结果(可选)(因为子进程一般都是被父进程委托去做某事,所以有时候需要一个结果)
wait方法#include<sys/types.h>#include<sys/wait.h>pid_t wait(int*status);返回值:成功返回被等待进程pid,失败返回-1。参数:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
waitpid方法
pid_ t waitpid(pid_t pid, int *status, int options);返回值:当正常返回的时候waitpid返回收集到的子进程的进程ID;如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;参数:pid:Pid=-1,等待任一个子进程。与wait等效。Pid>0.等待其进程ID与pid相等的子进程。status:WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)options:WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { pid_t id = fork(); if(id == 0) { // child int cnt = 5; while(cnt) { printf("Child is running, pid: %d, ppid: %d\n", getpid(), getppid()); sleep(1); cnt--; } printf("子进程准备退出,马上变僵尸进程\n"); exit(0); } printf("父进程休眠\n"); sleep(10); printf("父进程开始回收\n"); //father pid_t rid = wait(NULL); // 阻塞等待 if(rid > 0) { printf("wait success, rid: %d\n", rid); } printf("父进程回收僵尸进程成功\n"); sleep(3); }
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { pid_t id = fork(); if(id == 0) { // child int cnt = 5; while(cnt) { printf("Child is running, pid: %d, ppid: %d\n", getpid(), getppid()); sleep(1); cnt--; } exit(1); } int status = 0; pid_t rid = waitpid(id, &status, 0); // 阻塞等待 if(rid > 0) { printf("wait success, rid: %d, status: %d\n", rid, status); } return 0; }
这里我们用waitpid来获取子进程的退出码与信号~
int main() { pid_t id = fork(); if(id == 0) { // child int cnt = 5; while(cnt) { printf("Child is running, pid: %d, ppid: %d\n", getpid(), getppid()); sleep(1); cnt--; } exit(1); } int status = 0; pid_t rid = waitpid(id, &status, 0); // 阻塞等待 if(rid > 0) { printf("wait success, rid: %d, status: %d, exit signo: %d, exit code: %d\n", rid, status, status&0x7F, (status>>8)&0xFF); } return 0; }
我们可以通过对status和数字相与从而固定住位数,使得退出码与信号编号分隔开来~
ps:由于进程相互之间都是独立的,父进程要想查看到子进程的退出码等只能通过调用系统调用来查看数据的变化,因为会发生写时拷贝,父进程也查看不了,这些数据都是属于操作系统管理的~
在讲解阻塞与非阻塞等待之前我们先拿一个故事来类比一下~
小红在学校中是一个努力型的学霸,而小明则妥妥学渣一枚~期末考试临近了,小明去小红楼下想找她一起复习,顺便蹭蹭她的笔记~两人电话沟通后小红表示等她一会,小明又等了几分钟又拨了小红电话,结果发现还是要等一会,无聊的他不想干等只好拿起手里的复习资料先看一会,然后过一阵子再拨给小红,这时候小红终于下来了,两人也出发去图书馆复习~
不久又有一场考试临近,小明再次想到找小红一起复习,不过与上次不一样的是这一次他告诉小红不要挂断电话,我就这样一直等你顺便听听你在干什么,直到小红全部准备好下楼后两人才挂断电话~
这两种情况也引出了我们下面所学的内容,打电话的本质就是检测进程的状态~
- 第一种非阻塞的轮询访问相当于拿不到进程的退出状态就会一直去系统调用(多次)去寻求一个结果,但是访问的期间可以去做其他事情~这也叫做非阻塞等待
- 第二种是拿不到进程的退出状态(结果)那么系统调用(一次)就不返回,直到拿到结果。
阻塞等待
int main() { pid_t id = fork(); if(id == 0) { // child int cnt = 5; while(cnt) { printf("Child is running, pid: %d, ppid: %d\n", getpid(), getppid()); sleep(1); cnt--; } exit(1); } int status = 0; pid_t rid = waitpid(id, &status, 0); // 阻塞等待 if(rid > 0) { if(WIFEXITED(status)) { printf("wait success, rid: %d, status: %d, exit code: %d\n", rid, status, WEXITSTATUS(status)); } else { printf("child process error!\n"); } } return 0; }
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
一旦退出且成功即打印出状态~
非阻塞等待
非阻塞等待中是把waitpid参数中的0变为WNOHANG
#define NUM 5 typedef void(*fun_t)(); fun_t tasks[NUM]; //任务/// void printLog() { printf("this is a log print task\n"); } void printNet() { printf("this is a net task\n"); } void printNPC() { printf("this is a flush NPC\n"); } void initTask() { tasks[0] = printLog; tasks[1] = printNet; tasks[2] = printNPC; tasks[3] = NULL; } void excuteTask() { for(int i = 0; tasks[i]; i++) tasks[i](); // 回调机制 } int main() { initTask(); pid_t id = fork(); if(id == 0) { // child int cnt = 5; while(cnt) { printf("Child is running, pid: %d, ppid: %d\n", getpid(), getppid()); sleep(1); cnt--; } exit(1); } int status = 0; while(1) { pid_t rid = waitpid(id, &status, WNOHANG); if(rid > 0) { printf("wait success, rid: %d, status: %d, exit code: %d\n", rid, status, WEXITSTATUS(status)); break; } else if(rid == 0) { printf("father say: child is running, do other thing\n"); printf("##############task begin#####################\n"); excuteTask(); printf("##############task end #####################\n"); } else { perror("waitpid"); break; } sleep(1); } }
只要发现进程还没退出就去执行其他事情~
四. 进程替换
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。
而在替换数据与代码的过程中并不会产生新的进程,替换的本质系统调用对数据进行修改,当然若是涉及父子进程那就是对数据进行写时拷贝~
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { printf("I am a process, pid: %d\n", getpid()); printf("exec begin...\n"); execl("/usr/bin/ls", "ls", "-a", "-l", NULL); //NULL 不是 "NULL" printf("exec end ...\n"); return 0; }
我们可以看到最执行到替换的函数execl时,后面的内容全部被替换成了ls指令的进程操作,而原代码中的printf("exec end ...\n")则被覆盖掉了~
如果我们的路径搞错那就不会去替换代码,因为execl找不到~
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { printf("I am a process, pid: %d\n", getpid()); printf("exec begin...\n"); execl("/usr/n/ls", "ls", "-a", "-l", NULL); //NULL 不是 "NULL" printf("exec end ...\n"); exit(1); }
ps1:
这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。如果调用出错则返回-1所以exec函数只有出错的返回值而没有成功的返回值。
下面我们更改为多进程版本
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { pid_t id = fork(); printf("I am a process, pid: %d\n", getpid()); if(id == 0) { printf("exec begin...\n"); execl("/usr/bin/ls", "ls", "-a", "-l", NULL); //NULL 不是 "NULL" printf("exec end ...\n"); exit(1); } pid_t rid = waitpid(id, NULL, 0); if(rid > 0) { printf("wait success\n"); } exit(1); }
子进程发生进程替换并不会影响到父进程的数据,这是因为发生了写时拷贝~
下面我们来学习一下更多关于与进程替换的函数接口~
ps2:
- l(list) : 表示参数采用列表
- v(vector) : 参数用数组
- p(path) : 有p自动搜索环境变量PATH
- e(env) : 表示自己维护环境变量
我们只需要输入指令即可,不用再添加路径~它会自动搜索路径
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { pid_t id = fork(); printf("I am a process, pid: %d\n", getpid()); if(id == 0) { printf("exec begin...\n"); execlp("ls", "ls", "-a", "-l", NULL); //NULL 不是 "NULL" printf("exec end ...\n"); exit(1); } pid_t rid = waitpid(id, NULL, 0); if(rid > 0) { printf("wait success\n"); } exit(1); }
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { pid_t id = fork(); printf("I am a process, pid: %d\n", getpid()); if(id == 0) { execl("./mytest", "mytest", NULL); //NULL 不是 "NULL" printf("exec end ...\n"); exit(1); } pid_t rid = waitpid(id, NULL, 0); if(rid > 0) { printf("wait success\n"); } exit(1); }
//mytest.cc #include <iostream> #include <unistd.h> using namespace std; int main(int argc,char* argv[],char* env[]) { for(int i = 0; env[i]; i++) { printf("env[%d]: %s\n", i, env[i]); } cout << "hello C++" << endl; cout << "hello C++" << endl; return 0; }
//makefile .PHONY:all all:myprocess mytest myprocess:myprocess.c gcc -o $@ $^ mytest:mytest.cc g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f myprocess mytest
我们发现对子进程进行进程替换后(替换的是我们自己写的程序),然后还获取到了环境变量
我们尝试在子进程与父进程里添加新的环境变量
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> int main() { char *const env[] ={ (char*)"haha=hehe", (char*)"PATH=/", NULL }; pid_t id = fork(); printf("I am a process, pid: %d\n", getpid()); if(id == 0) { execle("./mytest","mytest",NULL,env); printf("exec end ...\n"); exit(1); } pid_t rid = waitpid(id, NULL, 0); if(rid > 0) { printf("wait success\n"); } exit(1); }
//mytest.cc #include <iostream> #include <unistd.h> using namespace std; int main(int argc,char* argv[],char* env[]) { for(int i = 0; env[i]; i++) { printf("env[%d]: %s\n", i, env[i]); } cout << "hello C++" << endl; cout << "hello C++" << endl; return 0; }
bash进程发现不了新的环境变量hh,而在其mytest进程里反而可以查到~
这说明新增的环境变量(putenv)只会影响到当前及以后的进程,并不会影响到之前的进程(例如bash)
正常来说通过地址空间继承的方式可以让所有的子进程拿到环境变量,而进程的替换并不会替换环境变量。大家都是一样的,除非你私自添加(putenv或execle)~
int main() { pid_t id = fork(); printf("I am a process, pid: %d\n", getpid()); if(id == 0) { char *const argv[] = { (char*)"ls", (char*)"-a", (char*)"-l" }; // printf("exec begin...\n"); // execl("/usr/bin/ls", "ls", "-a", "-l", NULL); //NULL 不是 "NULL" // execl("./mytest", "mytest", "-a", "-b","-c", NULL); //NULL 不是 "NULL" execvp("ls", argv); //NULL 不是 "NULL" printf("exec end ...\n"); exit(1); } pid_t rid = waitpid(id, NULL, 0); if(rid > 0) { printf("wait success\n"); } exit(1); }