一.进程创建
fork()函数:
在进程概念这篇文章中,我们浅浅地了解了一下fork函数,它的功能是让父进程去创建一个子进程,并且有两个返回值,对应着父进程的返回值和子进程的返回值。那么,为什么会这样?接下来我们好好地讨论一下fork函数。
在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
pid_tfork(void); 返回值:子进程中返回0,父进程返回子进程id,出错返回-1
先来看三个问题:
1.如何理解fork函数有两个返回值的问题?
2.如何理解fork函数返回后,子进程中返回0,父进程返回子进程id?
3.如何理解同一个id值,为什么会保存两个不同的值,让if else if同时执行?
现象看下面代码:
intglobal_value=100; intmain() { pid_tid=fork(); if(id<0) { printf("fork error\n"); return1; } elseif(id==0) { intcnt=0; while(1) { printf("我是子进程, pid: %d, ppid: %d | global_value: %d, &global_value: %p\n", getpid(), getppid(), global_value, &global_value); sleep(1); cnt++; if(cnt==10) { global_value=300; printf("子进程已经更改了全局的变量啦..........\n"); } } } else { while(1) { printf("我是父进程, pid: %d, ppid: %d | global_value: %d, &global_value: %p\n", getpid(), getppid(), global_value, &global_value); sleep(2); } } sleep(1); }
结果:
先来解决第二个问题:2.如何理解fork函数返回后,子进程中返回0,父进程返回子进程id?
对于这个问题,我们可以结合现实:一个父亲可以有多个孩子,而每个孩子只能也必定有一个父亲。而对于孩子而言,父亲对于孩子来说是具有唯一性的,而孩子对于父亲来说,父亲需要给孩子取名,才能准确地从几个孩子中找到某个孩子,比如son1,son2,son3.
所以,父子进程也一样,子进程返回0,是因为父亲只有一位。而父进程返回的是子进程的id,即是孩子的名字。
然后来看第一个问题:1.如何理解fork函数有两个返回值的问题?
fork()函数,是操作系统提供的函数,在用户空间调用fork函数的时候,实际上就是在调用内核空间中的fork函数。在fork函数的函数主体中,就有创建子进程的相关指令,最后是返回 子进程的pid。那么在返回的时候,是分流了。因为在到达return指令之前,子进程就已经被创建好了,并且有可能已经在OS的运行队列当中,准备被调度,因此,此时对于fork函数的这个return指令,不仅仅是被父进程使用,还会给子进程拿去使用。所以,fork函数就有两个返回值,一个是返回子进程的,一个是返回父进程的。
第三个问题:3.如何理解同一个id值,为什么会保存两个不同的值,让if else if同时执行?
返回的本质就是写入。所以,对于pid_t id = fork();为什么会保存两个不同的值,就先看谁先返回,那就谁先写入id。比如父进程先返回,先写入id,此时id的值是子进程的pid,此时的子进程中的id,它的地址和内容,跟父进程的是一样的,就是指向了同一个地址。但是当子进程返回的时候,此时为了保证进程的独立性,OS就会进行写时拷贝,额外给子进程一个id的空间,此时的现象是:父子进程的id的地址是一样的,但是!内容不一样,这里就用到了上一篇的文章:进程概念 所了解到的进程地址空间的知识。这就是id为什么会保存两个不同的值。
然后,父子进程会共用上面那段代码,就是if else if的代码,当id的值对应着不同的判断条件,代码就指向那种指令。也就看到了if else if会同时执行的现象了。
写时拷贝
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
fork的常规用法
1、一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
2、一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
fork也有调用失败的时候,其原因很大可能是系统中有太多的进程或者实际用户的进程数超过了限制。
二、进程终止:
在谈进程退出情况之前,我们先来聊聊退出码的问题。
相信我们在写代码的时候,特别是使用C/C++写代码时,我们都会写main函数,然后最后写一个return 0。那么问题来了,return 0的含义是什么?0又是什么意思?为什么是0,而不是1,不是2等等。
其实return 0中的0,就是退出码的意思,而return 0,标定进程退出的结果是否正确。
来看下代码和对应的测试结果:
intadd(intbegin, intend) { inti=0; intsum=0; for (i=begin; i<end; i++) { sum+=i; } returnsum; } intmain() { //进程退出码的作用就是:让我们得知我们写的代码所完成的任务是否完成intnum=add(1, 100); if (num==5050) { return0; } else { return1; } //进程对出的时候,对应的退出码//标定进程执行的结果是否正确//return 0;}
运行后,我们通过echo $?来查看退出码的结果:
$?是环境变量的一种,$?的作用是永远记录最近的一个进程在命令行中执行完毕时对应的退出码(main---->return ?;)
可以看到,当执行了上面的那个程序之和,退出码的结果是1,但当我们再次执行echo $?的指令后,发现变为0了,是因为$?会对最近的一个程序进行判断。echo $?本身也是一个程序指令,所以后面的退出码为0.
接下来我们来看看不同数字的退出码代表着什么意思:
从图中可以得知,0代表着成功的意思,而非0的数字,代表着各种失败的提升。可以举的例子有:当我们在命令行写入:ls asdasdas,打开这样的一个文件,但是我们没有这样的文件,那么可以看到结果如下:
好了,在了解了退出码之和,我们可以谈谈进程退出的情况了。
进程退出情况
1.代码运行完毕,结果正确 ----return 0;
2.代码运行完毕,结果不正确 -------return !0 退出码在这个时候起效
3.代码异常终止-----这情况下退出码无意义
进程如何结束?
有两种办法:
1. 从main返回
2. 调用exit
第一种很好理解,我们的程序都是从main函数开始,最后由main函数的return 0来返回,终止程序。对于第二种,我们需要认识exit()函数。exit函数其实在平时写代码的时候,就用过几次。选择来了解一下它。
exit()函数的作用是终止进程,不管在哪调用它:不管是在main函数里面调用exit,还是在main函数调用的函数的内部使用它,只要执行了exit函数,整个进程都会终止。
还有一个功能与exit函数类型的,叫做_exit()。它们的区别就在于,exit函数是库函数,而_exit属于系统调用,并且,exit()函数会刷新缓冲区,_exit并不会刷新缓冲区。看下图:
温馨提示:库函数和系统调用的不同之处在于,库函数的调用,本质上就是建立在了系统调用之上,是操作系统提供给用户写代码时使用的函数。库函数——系统调用——OS三者的层次关系大概如下图:
当然啦,如果存在父子进程同时使用一段代码的时候,而且exit函数是在当fork函数返回值为0,也就是子进程执行的代码段的时候,终止的子进程。即谁调用谁终止。
执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数。
进程终止就到这。接下来我们来谈谈进程等待。
三、进程等待
进程等待可以解决僵尸进程问题。
所以,进程等待是很有比较性的:
1.子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
2.另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
3.最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
4.父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
进程等待方法
1.wait方法。
wait()是一个函数。通过man我们可以搜出其基本信息:
pid_t wait(int*status);
返回值:
成功返回被等待进程pid,失败返回-1。
参数:
输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
它的功能是让进程等待,从而时父进程回收子进程资源。
看下面的代码:
intmain() { pid_tid=fork(); if (id==0) { //子进程intcnt=10; while (cnt) { printf("我是子进程: %d, 父进程: %d,cnt: %d\n", getpid(), getppid(), cnt--); sleep(1); } exit(0);//进程终止 } //父进程sleep(15); pid_tret=wait(NULL); if (id>0) { printf("wait success: %d\n", ret); } return0; }
上面程序的功能:我们期望,子进程返回0,即进入while循环后,10秒的时间内,子进程在运行着,然后子进程终止,此时,父进程中的sleep的时间也过了10秒,还有5秒,在这5秒的时间内,子进程就是一个僵尸进程(Z)。我们期望,通过父进程中的wait,可以回收子进程的资源,从而解决僵尸进程。看下面结果:
可以看到,有在一段时间内,子进程的状态为Z,即僵尸状态,然后变成了STAT。
2.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。
①如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
②如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
③如果不存在该子进程,则立即出错返回
获取子进程status:
wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。如果传递NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图:
当子进程变成僵尸状态的时候,子进程的PCB内部就保存有子进程的退出码和退出信号,父进程通过status,将子进程的这些资源拿到手。
阻塞与非阻塞
阻塞:当父进程通过系统调用wait/waitpid去获取子进程的资源时,但子进程还没有退出,等待的这个状态,就叫做阻塞。
非阻塞:子进程还没退出,父进程就不等了,直接返回
下面的代码可以测试一下非阻塞
intmain() { pid_tid=fork(); (id==0) { //子进程intcnt=10; while (cnt) { printf("我是子进程: %d, 父进程: %d,cnt: %d\n", getpid(), getppid(), cnt--); sleep(1); } exit(0);//进程终止 } //父进程//sleep(15); // pid_t ret = wait(NULL);intstatus=0; while (1) { pid_tret=waitpid(id, &status, WNOHANG); if (ret==0) { //waitpid调用成功,子进程没有退出//子进程没有退出,waitpid没有等待失败,仅仅只是检查状态 printf("wait done, but child is running......\n"); } elseif (ret>0) { //waitpid调用成功,子进程退出了printf("wait success: %d, sig number: %d,child eixt code: %d\n", ret, (status&0x7F), (status>>8) &0XFF); break; } else { //waitpid调用失败printf("waitpid call failed\n"); break; } } return0; }
我们可以看到,父进程在进行轮询检测,直到子进程退出。
非阻塞的好处是不会占用父进程的资源,父进程在轮询的期间可以去做别的事。
四、进程替换
首先需要知道的是创建子进程的目的:
a. 让子进程执行父进程代码的一部分:执行父进程在磁盘上对应的一部分代码。
b、让进程执行一个全新的程序:让子进程加载磁盘上指定的程序,执行新程序的代码和数据——>这动作就叫做进程的替换
接下来将以四步来对进程的替换进行学习:①先见见猪跑,看看什么是进程替换;②理解原理(是什么,为什么,怎么办);③对应的方法;④应用的场景
4.1 先见见猪跑,看看什么是进程替换
需要用到替换函数(execl)
其实有六种以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[]);
函数解释:
这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
如果调用出错则返回-1
所以exec函数只有出错的返回值而没有成功的返回值。因为成功的返回值没有必要,都已经替换了进程了,即使返回了,这个值也用不了。
命名理解:
l(list) : 表示参数采用列表。意思是将参数一个一个地传入exec*
v(vector) : 参数用数组。意思是可以将我们需要传入的参数放在数组里面,然后统一传入。
p(path) : 有p自动搜索环境变量PATH
e(env) : 表示自己维护环境变量
温馨提示:int execl(const char *path, const char *arg, ...);第一个参数的意思是找到这个程序的路径,第二个参数的意思是如何执行这个程序,第三个参数 ... 是c语言中的可变参数列表,像scanf,printf等都有...)。
替换函数的功能就是将指定的程序 (注意是程序,不是进程) 加载到内存当中,让指定的程序执行。请看下面代码:
intmain() { printf("process is running..\n"); execl("/usr/bin/ls", "ls", "--color=auto", "-a", "-l", NULL); printf("process running done...\n"); return0; }
通过替换函数execl,我们可以执行别人的代码程序,比如ls,-a,-l。
可以看到,在代码里面的第二个printf没有将我们需要打印的内容打印出来,因此我们需要了解清除进程替换的原理。
4.2 进程程序替换原理
进程程序替换本质上就是将指定的程序的代码和数据,从磁盘上加载到物理内存的指定的位置上,并且把原来位置上的的数据和代码给覆盖掉,因此,在进程程序替换的时候,并没有创建新的进程。
所以我们回到上面的那个问题,为什么第二个printf没有执行?
答案就是:因为第二个printf是在execl之后的,在执行了execl后,第二个printf被覆盖掉了,所以也就没办法执行了。
我们再举一个例子,那就是再父子进程中,子进程进行程序替换:看下面代码:
intmain() { printf("process is running..\n"); pid_tid=fork(); assert(id!=-1); if (id==0) { sleep(1); execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL); exit(1); } intstatus=0; pid_tret=waitpid(id, &status, 0); if (ret>0) { printf("wait success: exit code:%d,sig: %d\n", (status>>8) &0xFF, status&0x7F); } return0; }
结果如下:
因为进程具有独立性,所以当子进程进行程序替换的时候,OS就会在物理内存中进行写时拷贝,页表的映射关系重新安排。由此,子进程的程序替换也不会影响到了父进程。
替换自己写的程序:
①C程序替换C程序:
那么接下来,我们试着去写一段程序, 然后用另外一段代码程序来执行,也就是说,上面程序替换是替换系统命令的, 现在是替换自己写的代码程序。
创建一个my_exec.c的C程序。
intmain() { printf("这是一段C程序\n"); printf("这是一段C程序\n"); printf("这是一段C程序\n"); return0; }
然后在my_test.c的C程序总,使用execl函数即可:
execl("./my_exec", "my_exec",NULL);
②C程序替换C++程序:
没错,在替换函数中,我们可以在C程序的代码中去替换CPP的程序,因为是系统调用,系统就是老大,系统想替换谁就是谁,而且程序替换,就是叫程序替换,不叫语言替换,C++、Java。shell和python都是没问题的。这里不演示了,演示的例子无非就是将后缀改为cpp,并且使用C++的语法,操作过程几乎差不多。
4.3 对应的方法
前面我们已经将了第一个方法:int execl(const char *path, const char *arg, ...);
那么接下来我们谈谈第二个方法:int execlp(const char *file, const char *arg, ...);
execlp的使用方法,就是不需要带路径:
execlp("ls", "ls", "--color=auto", "-a", "-l", NULL);
这里面的两个"ls",并不是重复,因为第一个"ls"的意思是要执行的对象,第二个"ls"的意思是如何执行。
第三个方法:int execv(const char *path, char *const argv[]);
带v,需要传的是数组的形式,即将需要传入的参数放入一个数组中,然后传入数组即可。
char*constavg[] = {"ls", "-a", "-l", "--color=auto", NULL}; execlv("/usr/bin/ls", avg);
第四个方法:int execvp(const char *file, char *const argv[]);
execvp可以看作是execv和execp的结合使用
execlvp("ls", avg);
第五个方法:int execle(const char *path, const char *arg, ...,char *const envp[]);
第四个参数,我们传的是环境变量。
在my_exec.c的程序中,加入环境变量:
intmain() { //系统就有printf("PATH:%s\n", getenv("PATH")); printf("PWD:%s\n", getenv("PWD")); //自定义环境printf("MYENV:%s\n", getenv("MYENV")); printf("这是一段C程序\n"); printf("这是一段C程序\n"); printf("这是一段C程序\n"); return0; }
①自定义变量:
然后拿到my_test.c中:
char*constenvp[] = {(char*)"MYENV=1122334455",NULL}; execle("./my_exec","my_exec",NULL,envp);
结果发现,没有将系统自带的环境变量的内容输出。
②系统自带的环境变量:
externchar**environ; execle("./my_exec","my_exec",NULL,environ);
结果发现,没有了自定义的环境变量
那么,我想把自定义的和系统自带的环境变量都输出:使用putenv函数,将自定义环境变量导入系统的环境变量表中。
putenv((char*)"MYENV=44332211");//将指定环境变量导入到系统中,即environ指向的环境变量表 execle("./my_exec","my_exec",NULL,environ);
可以看到,即有系统自带的,也有自定义的。
其实对于execle,我们可以与 int main(int argc,char *argv[],char *env[]){}结合起来谈谈。代码和数据加载到内存的操作,其实就是操作系统调用了exec*函数完成的,所以在Linux的系统中,exec*是加载器。exec*函数的功能就是将程序加载到内存嘛,这是谈的第一点。第二点就是,对于main函数而言,是先进行程序的加载,才会开始调用main函数,那么main函数的参数,就是由execle传参传过去的!
第六个方法:真正的系统调用的接口:
int execve(const char *filename, char *const argv[], char* const envp[]);
这个方法是真正的系统调用的接口,上面五个,都是基于系统调用的接口封装起来的,是为了有更多的选择性。
总结一下exec*家族的成员:
4.4 应用场景
综合前面的知识,实现一个简单的shell。
charlineCommand[NUM]; char*myargv[OPT_NUM]; intmian() { while (1) { //输出提示符printf("用户名@主机名 当前路径# "); fflush(stdout); //获取用户输入,输入的时候,回输入\nchar*s=fgets(lineCommand, sizeof(lineCommand) -1, stdin); assert(s!=NULL); (void)s; //清除最后一个\nlineCommand[strlen(lineCommand) -1] =0; //字符串切割myargv[0] =strtok(lineCommand, " "); inti=1; while (myargv[i++] =strtok(NULL, " ")); //条件编译for (inti=0; myargv[i]; i++) { printf("myargv[%d]: %s\n", i, myargv[i]); } //指向命令pid_tid=fork(); assert(if!=-1); if (id==0) { execvp(myargv[0], myargv); exit(1); } waidpid(id, NULL, 0); } }
最后,我们指向myshell的程序后,输入一些命令行指令,那么就可以通过execvp去将对应的程序加载到内存,就可以执行这些程序了!
本文结束~喜欢的话可以点波关注。