目录
1 进程创建
💭 在上一篇文章《进程的学习 —— Linux下的进程》中,频繁用到了fork来创建子进程。没错,fork正是Linux中创建进程的一个系统调用接口,下面将更深入地剖析fork的用法、作用、原理及特点。
1.1 认识fork
- fork是什么?
在linux中fork函数是非常重要的系统调用接口,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
- fork的工作机制?
🔎调用fork后,进程执行内核空间的fork函数代码,内核会做出如下操作:
- 开辟新的内存空间、创建新的进程控制块,供新进程使用
- 将父进程PCB数据结构部分拷贝至子进程中
- 添加子进程到系统进程列表中
- fork返回,调度器开始调度
⭕ fork前后执行流程图
- fork怎么用?
#include<unistd.h>// 包含头文件pid_tfork(void);// 函数声明
- fork的返回值问题
fork的返回值比较特殊,它是一个pid_t(可以视为是整型)类型的变量,若父进程创建子进程成功,fork给父进程返回子进程pid,给子进程返回0。若创建失败,则给父进程返回-1。
man手册中关于fork返回值的介绍
RETURN VALUE
On success, the PID of the child process is returned in the parent, and 0 is returned in the child.
On failure, -1 is returned in theparent, no child process is created, and errno is set appropriately.
❓这里比较奇怪的是,为什么一个函数会有两个返回值呢❓
💡 fork在内核中也有属于自己的代码,那么fork函数内部,在return之前,肯定是已经创建完子进程并且分两个执行流了,父进程执行流会返回子进程的pid,子进程执行流会返回0。(不考虑创建进程失败的情况)
1.2 进程创建的目的
💭 创建进程的目的一般有如下两种:
- 父进程希望生成一份自己的副本,执行同一个程序中不同的代码片段。
- 让子进程执行不同的程序。(涉及进程替换,后文详细分析)
1.3 写时拷贝
📝父子进程的数据是共享的,在父子进程都没有对共享数据进行修改之前,这些数据对于父子进程来说都处于相同的地址(虚拟地址和物理地址)。当父子有任一方对共享数据做出修改时,就会发生写时拷贝。
写时拷贝的具体操作:OS在物理空间上开辟一块新的空间,并将欲修改数据拷贝过去,修改数据方对应的虚拟地址不变,物理地址指向新的物理内存空间(页表改变),然后再做修改。
写时拷贝保证了进程的独立性,父子进程的运行不会互相影响。
对于两种不同目的的进程创建,都会发生写时拷贝,只不过一个是拷贝数据,一个是拷贝代码。
⭕修改共享数据前
⭕修改共享数据后
1.4 进程创建失败的场景
- 系统中有太多的进程
- 实际用户的进程数超过了限制
🎈进程创建了,运行结束后,进程的退出也是大有讲究,下面将要探讨进程退出。
2 进程退出
💭 目前我们知道,子进程在退出之后,父进程回收前,会保持僵尸状态,以保存其退出信息,并等待父进程回收。那么这里的退出信息是什么?父进程又是如何回收子进程的退出信息状态的?下面分析。
2.1 进程退出状态
📝一般来说,进程退出的场景有如下三种:
- 进程正常运行结束,运行结果正常
- 进程正常运行结束,但运行结果错误
- 进程异常终止。
🔎Linux中用 进程退出码(code) 表示进程正常结束的状态,用 进程退出信号(SIG) 表示进程异常退出的原因。二者的本质就是进程PCB中的两个数字,当进程处于僵尸状态时,程序退出,PCB保存着退出状态信息。
2.2 进程退出的方式
1️⃣正常退出
- main函数return退出
- 调用 exit 退出
- 调用_exit 退出
正常退出时,可以用echo指令在命令行上查看退出码。(正常退出时,退出信号为0)
echo$? // 查询最近结束的进程的退出码
2️⃣异常终止
- ctrl+c 终止前台进程
- 给进程发送终止信号
⭕图为kill指令提供的终止信号,一个数字代表一种信号。
2.3 exit、_exit、return
2.3.1 概念
- exit
🔎 exit是一个用户级的函数,功能是终止当前执行的进程,并返回指定的退出码。
#include<stdlib.h>// 所在头文件voidexit(int status);// status是返回的退出码
一般规定:
exit(0)
正常退出exit(!0)
异常退出
⭕ 写一段C代码验证:
#include<stdio.h>#include<stdlib.h>intmain(){printf("hello world\n");exit(123);}
⭕ 结果:
[ckf@VM-8-3-centos lesson7]$ ./test
hello world
[ckf@VM-8-3-centos lesson7]$ echo$?123 // 查询到退出码为我们exit参数指定的值
💡 exit的参数statue默认只有低八位有效,且视为无符号,所以当我们给exit传入-1时,退出码为255。退出码的范围是
[0~255]
。
⭕ 验证
#include<stdio.h>#include<stdlib.h>intmain(){printf("hello world\n");exit(-1);}
[ckf@VM-8-3-centos lesson7]$ ./test
hello world
[ckf@VM-8-3-centos lesson7]$ echo$?255
- _exit
🔎与exit不同的是,_exit是一个系统调用接口,是由系统提供的(exit函数是C函数库提供的)。而exit最后也要调用_exit接口,相当于exit是对_exit的封装,功能比_exit更多。
#include<unistd.h>// 所在头文件void_exit(int status);// 参数与exit函数相同
- return
⭕ return是最为常规的进程退出方式。在main函数中执行
return n
相当于执行
exit(n)
,因为调用main函数的运行时函数会将main函数的返回值当作exit的参数。需要注意的是,在main函数中return才能退出进程,在其它函数中return只起到函数返回的作用。
2.3.2 区别
- exit函数是C函数库提供的用户级函数,_exit是系统调用函数。
- return是C语言的关键字,exit、_exit是函数。
- return是语言级别的,表示调用堆栈的返回。而exit是系统调用级别的,表示一个进程结束
- return用于结束一个函数的执行,将函数的执行信息传出个其他调用函数使用;exit函数是退出应用程序,删除进程使用的内存空间,并将应用程序的一个状态返回给OS(操作系统),这个状态标识了应用程序的一些运行信息,这个信息和机器和操作系统有关,一般是 0 为正常退出,非0 为非正常退出。
- 非主函数中调用return和exit效果很明显,但是在main函数中调用return和exit的现象就很模糊,多数情况下现象都是一致的。
—— 参考文章《C语言中的exit()函数》
3 进程等待
3.1 理解进程等待
💭了解子进程如何退出并返回退出状态后,接下来就不得不提到,父进程该如何回收子进程的退出状态?
🔎进程退出后处于僵尸状态,程序退出,PCB暂时保留,其中保存着该进程的退出状态。父进程通过进程等待的方式获取子进程的退出状态,回收子进程资源。
为什么要进行进程等待?
防止内存泄漏!
一个进程一旦进入僵尸状态,即使用
kill -9
命令也无法将其杀掉,因为谁也没办法杀掉一个已经死去的进程。若父进程不回收处于僵尸状态的子进程,积少成多,很可能出现内存泄漏的问题
获知子进程的任务完成情况!
父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出
3.2 进程等待的方式
3.2.1 wait和waitpid
💭介绍两个用于进程等待的函数
- wait
功能:等待任一子进程退出,并回收子进程资源,获取子进程退出码
// 头文件#include<sys/types.h>#include<sys/wait.h>pid_twait(int* status);
返回值:成功,返回被等待进程pid;
失败(如父进程没有子进程),返回-1;
status:输出型参数,写入被等待进程的退出状态到该指针执行的空间中。不关心退出状态,可设置为NULL
- waitpid
功能:可通过pid指定某一子进程等待其退出,并回收子进程资源,获取子进程退出码
// 头文件#include<sys/types.h>#include<sys/wait.h>pid_twaitpid(pid_t pid,int* status,int options);
返回值:被等待进程正常退出,返回被等待进程pid。
若options参数传入WNOHANG,调用中waitpid发现没有已退出的子进程可回收,则返回0。
调用失败(如pid不合法等),返回-1。
pid:
pid>0:等待回收相应pid的子进程
pid=-1:等待任一子进程,等价于wait
status
写入被等待进程的退出状态
options:
WNOHANG:若pid相应的子进程没有结束,waitpid返回0,不予以等待。若正常结束,返回子进程pid。
⭕注意:
- 如果子进程已经退出(此时的子进程是僵尸状态),调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
- 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
- 如果不存在(对应的)子进程,则立即出错返回。
💬 非法情况验证
情况1️⃣
intmain(){int ret =wait(NULL);// 当前进程并没有子进程,无法进程等待,wait调用失败printf("%d\n",ret);return0;}
⭕执行结果:
情况2️⃣
#include<stdio.h>#include<stdlib.h>#include<unistd.h>#include<sys/types.h>#include<sys/wait.h>intmain(){pid_t id =fork();if(id ==0){printf("I am child\n");exit(0);}int ret =waitpid(id+1,NULL,0);// pid为id+1的子进程不存在,无法进程等待,waitpid调用失败printf("%d\n",ret);return0;}
⭕执行结果:
3.2.2 status位图结构
💭聊聊参数status
wait/waitpid都有一个参数status,该参数为一个输出型参数,是一个指针,指向一个int类型(32位)的变量,调用wait/waitpid时由OS向status指向的空间写入被等待进程的退出状态,若传入的status为NULL空指针,表示不关心被等待进程退出状态,OS则不会写入。不能将*status简单看成整型对待,其结构可视为位图结构(并且只研究低16位),具体如下图:
core dump标志:程序退出时,是否保存异常信息
- 基于这样的位图结构,我们可以通过位运算的方法,提取退出码或退出信号。
int stat =*status
退出码:(stat>>8)&0xff
退出信号:stat &0x7f
core dump标志:(stat>>7)&1
💬 来段代码测试一下:
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<sys/types.h>#include<sys/wait.h>intmain(){pid_t id =fork();if(id ==0){printf("I am child procss, and my pid is %d\n",getpid());sleep(5);exit(6);// 传个6作为子进程退出码}int status =0;waitpid(id,&status,0);// 等待五秒后,OS向status写入子进程退出状态printf("sig:%d\ncore dump flag:%d\nending code:%d\n",status &0x7f,(status>>7)&1,(status>>8)&0xff);return0;}
⭕ 子进程正常退出的情况,退出码、退出信号等信息符合预期
⭕ 子进程被信号杀掉的情况,符合预期。
- 除了位运算的方式,我们还可以利用宏函数来获取退出状态。
🔎wait/waitpid的头文件中还包含了一些宏函数,帮助我们通过status指向的值提取退出码、退出信号。这里简单介绍几个。
// 关于退出码WIFEXITED(status): 若子进程正常终止,则为真(非零)。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
// 关于退出信号WIFSIGNALED(status):若子进程异常终止(被信号所杀),则为真(非零)。(查看进程是否被信号所杀)
WTERMSIG(status):若WIFSIGNALED非零,提取子进程退出信号。(查看进程退出信号)
💬 来段代码测试一下:
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<sys/types.h>#include<sys/wait.h>intmain(){pid_t id =fork();if(id ==0){printf("I am child procss, and my pid is %d\n",getpid());sleep(5);exit(6);}int status =0;waitpid(id,&status,0);if(WIFEXITED(status)){int ending_code =WEXITSTATUS(status);printf("%d\n",ending_code);}if(WIFSIGNALED(status)){int sig =WTERMSIG(status);printf("%d\n",sig);}return0;}
⭕ 两种情况都符合预期。
3.3 阻塞等待和非阻塞等待
- 什么是阻塞等待?
💬 看如下代码图:
fork创建子进程后,子进程和父进程执行不同代码段,因为子进程要休眠5秒才退出,父进程waitpid无法直接回收子进程,所以父进程会阻塞在waitpid处,一直等待子进程退出并返回退出信息后,才会继续运行。这种情况的进程等待,称之为阻塞等待。
- 什么是非阻塞等待?
💭父进程阻塞等待子进程的过程无法进行其他操作,只能干等,降低了运行效率。若想让父进程在等待的过程还能执行其他任务,可以采用轮询的方法。
- 所谓轮询,就是让父进程间断性地去查询子进程是否退出,若子进程尚未退出,则不再阻塞等待,执行其他任务,若子进程已退出,则进行相关的回收操作。
- 父进程以轮询的方式等待子进程退出,这种方法我们称之为非阻塞等待
实现非阻塞等待的方法:
给waitpid的第三个参数options传入宏WNOHANG。传入后,若waitpid发现相应pid的子进程尚未退出,直接返回0。利用这一特点结合while循环,便可实现非阻塞等待
💬具体代码实现
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<sys/types.h>#include<sys/wait.h>#include<assert.h>voidother_task(){printf("The child process is still running, parent process is running other_task\n");}intmain(){pid_t id =fork();if(id ==0){// printf("I am child procss, and my pid is %d\n",getpid());printf("I am child process, and I am running\n");sleep(5);exit(0);}int status =0;while(1){pid_t ret =waitpid(id,&status,WNOHANG);assert(ret>=0);if(ret >0)// 子进程已退出{printf("ending code:%d\n",(status>>8)&0xff);break;// 退出循环,结束轮询}elseif(ret ==0)// 子进程尚未退出{other_task();// 父进程执行其他任务sleep(1);// 休眠一秒后再进行下一次轮询}}return0;}
⭕测试结果
4 进程替换
💭 还记得进程创建的目的吗?
- 父进程希望生成一份自己的副本,执行同一个程序中不同的代码片段。
- 让子进程执行不同的程序。
上文讨论的都是围绕进程创建第一个目的,而下面我们要谈谈如何让子进程执行不同的程序。
4.1 进程替换的原理
⭕ 想让子进程执行不同的程序,必须依赖于进程替换。使用fork函数创建子进程后,子进程与父进程的程序是相同的(只是可能执行不同分支),若想让子进程执行另一个程序,要用exec系列函数对子进程进行进程替换,执行新的程序。
值得注意的是,进程替换所替换的是进程的代码和数据,没有创建新的PCB,所以进程还是那个进程,pid不变。
另外,进程替换时会发生写时拷贝!保证父进程与子进程的独立性。从磁盘上加载新的代码程序到内存中时,子进程的页表会发生改变。
4.2 进程替换的函数
4.2.1 认识exec函数
💭 实现进程替换的函数是命名以exec开头的一系列函数,总共有六个
#include<unistd.h>// 所在头文件intexecl(constchar* path,constchar* arg,...);intexeclp(constchar* file,constchar* arg,...);intexecle(constchar* path,constchar* arg,...,char*const envp[]);intexecv(constchar* path,char*const argv[]);intexecvp(constchar* file,char*const argv[]);intexecvpe(constchar* file,char*const argv[],char*const envp[]);
参数:
- path: 欲替换程序的路径
- file: 欲替换程序的名称
- arg: 命令行参数
- argv[]: 命令行参数数组
- envp[]: 环境变量表
注意:参数必须以NULL结尾
💭很好理解,要进行进程替换就要解决两个问题。去哪找程序来替换?替换后如何执行?上面这六个函数的参数用以解决这两个问题,参数的不同代表解决方法不同,path和file解决了去哪找的问题,而arg、argv[]解决了如何执行程序的问题。最后,我们还可以通过envp[]设置子进程的环境变量。
🔎这些函数若调用成功则加载新的程序并执行,不会返回值。只有调用失败时,才会返回-1。所以exec系列函数只有出错的返回值而没有成功的返回值。
🌰举个栗子,父进程创建一个执行ls程序的子进程
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<sys/types.h>#include<sys/wait.h>intmain(){int id =fork();if(id ==0)// 子进程{execl("/usr/bin/ls","ls","-l","-a","--color=auto",NULL);exit(1);// 因为execl会替换新的程序,所以如果子进程走到这里替换必定失败}wait(NULL);// 父进程回收子进程return0;}
⭕对比命令行下执行ls,发现符合预期。
💭其实还有一个函数execve,属于系统调用接口。以上6个函数都是对它进行封装得到的,底层都是调用execve。
#include<unistd.h>intexecve(constchar*filename,char*const argv[],char*const envp[]);
4.2.2 exec函数的命名理解
💭 掌握exec函数的命名规则,理解其意义,才能灵活地调用。
函数名带意义l (list)以列表的形式传递参数v(vector)以数组的形式传递参数p(path)直接传程序名,OS会从环境变量PATH中的路径去找e(environ)可自定义环境变量
5. 制作一个简易的Shell
💡综合进程创建、进程退出、进程等待和进程替换的知识,我们可以模拟制作一个简易的shell
#include<stdio.h>#include<stdlib.h>#include<string.h>#include<unistd.h>#include<assert.h>#include<sys/types.h>#include<sys/wait.h>#defineMAX_NUM1024#defineARG_NUM64char buf[MAX_NUM]={0};intmain(){while(1){// 用户标识符char* p =getenv("PWD");
p+=strlen(p);while(*(p-1)!='/'){--p;}printf("[%s@%s %s]$ ",getenv("USER"),getenv("HOSTNAME"),p);fflush(stdout);// 获取参数char* s =fgets(buf,sizeof(buf)-1,stdin);
s[strlen(s)-1]='\0';assert(s!=NULL);// 分割字符串char* my_argv[ARG_NUM];
my_argv[0]=strtok(buf," ");int i =1;while(my_argv[i++]=strtok(NULL," "));// 进程替换pid_t id =fork();if(id ==0){execvp(my_argv[0],my_argv);exit(1);}wait(NULL);}return0;}
版权归原作者 超人不会飞— 所有, 如有侵权,请联系我们删除。