【Linux】进程控制 —— 进程创建 | 进程退出 | 进程等待 | 进程程序替换 | 实现简易shell
像突如其来温暖我的春天,像倾盆大雨我躲避的屋檐,爱不会被磨灭,逝去的人住在心间 ~
本文将介绍进程控制;最后自己实现简陋的shell,对于bash会有更深的理解。
前置文章:进程虚拟地址空间;环境变量。
正文开始
1. 进程创建
众所周知,可以通过./
或调用fork来创建进程。
1.1 回忆fork
#include <unistd.h>
pid_t fork(void);
//返回值:子进程返回0,父进程返回子进程id;创建失败返回-1
现在我们知道,创建一个进程,内核会为它分配新的内存块加载代码和数据,创建各种内核数据结构包括进程控制块PCB、地址空间、页表、构建映射关系;将父进程部分数据结构内容拷贝至子进程;添加子进程到系统进程列表(运行队列)当中;fork返回,开始调度器调度。
1.2 站在地址空间角度理解写时拷贝
默认情况下,子进程会继承父进程的代码和数据 ——
在父/子修改数据时,会发生缺页中断:OS再开辟一段空间,把数据拷贝过来(写时拷贝),重新建立映射关系;父子分开,更改读写权限。这时候再进行写操作。这样保证了父子进程的独立性。
1.3 fork的用法 & 调用失败的原因
💜 fork的用法
- 父子进程执行同一代码的不同代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec*函数(进程替换马上详谈)
💜 fork调用失败的原因
- 众所周知,创建进程成本很高(时间+空间),系统中有太多进程时,资源不足
- 用户创建的进程数超出了限制,为了防止某些用户恶意创建。
2. 进程退出
进程退出,在OS层面做了什么呢?系统层面,进程退出,意味着少了一个进程:free PCB;free mm_struct;free页表和各种映射关系
2.1 进程退出的三种场景
-
代码运行完毕,结果正确
-
代码运行完毕,结果不正确
-
代码异常终止
思考:为什么main函数总会return 0,意义何在?main函数的return的值就是进程退出码。
💜 查看最近一次进程退出时的退出码 ——来衡量代码跑完对不对的
echo $? 查看退出码
————————————————————————————————————————————————————————————————————————————————————————————————
1.代码运行完毕,结果正确 - 0: success
2.代码运行完毕,结果不正确 - !0: failed → 为什么不正确?有多种可能,错误码对应字符串(strerror)
3.代码异常终止 - 程序崩溃 → 退出码没有意义,return都不会跑(可以通过某种方式获得原因,进程等待详谈)
(bash是命令行启动的所有进程的父进程,bash一定是通过wait方式得到子进程的退出结果,所以echo $?能查到子进程的退出码)
🍓 1. 代码运行完毕,结果正确(0)
🍓 2. 代码运行完毕,结果不正确(!0)
返回非0值,这是因为结果错误有多种可能,通过错误码获得对应错误信息字符串,比如我们可以用strerror来查看 ——
运行结果 ——
🍓 3. 代码异常终止
这是运行时错误,即程序崩溃。除0错误 ——
程序崩溃时,退出码是没有意义的,return根本就没有执行 (可以通过某些方式获取原因,进程等待详谈)。
2.2 进程退出方法
2.2.1 从main返回
main函数return返回代表进程退出;非main函数return代表函数返回。
2.2.2 exit
💜 exit在任意地方调用,都代表终止进程,参数是退出码。
#include <unistd.h>
void exit(int status);
//status: 退出码 EXIT_SUCCESS and EXIT_FAILURE(我们_exit详谈)
2.2.3 _exit
在进度条代码时我们就观察过,显示器的刷新策略是行刷新,即\n
即进行刷新 ——
exit或main return除了进程退出外,本身都就会要求系统进行缓冲区刷新 ——
对比第三种进程退出方案 ——
#include <unistd.h>
void _exit(int status);
💜 强制终止进程,不会进行进程的收尾工作,比如刷新缓冲区
3. 进程等待
3.1 what & why?
💜 进程等待
fork后,父子进程谁先退出不确定
- 子进程:为了帮助父进程成某种任务
- 父进程:父进程就需要通过某种方式知道子进程任务完成的怎么样
因此,父进程fork后,就需要通过wait/waitpid等待子进程退出,来获取退出信息。
💜 为什么要进行进程等待
- 通过获取子进程退出的信息,能够得知子进程的执行结果
- 能保证时序问题:保证子进程先退出,父进程后退出
- 进程退出时,会先进入僵尸状态,若父进程不等待,会造成内存泄漏。需要通过父进程wait,释放子进程占用的资源(解决僵尸进程问题方法之一)。
3.2 进程等待的方法
3.2.1 wait
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
- 返回值: 等待成功,返回被等待进程pid;等待失败,返回-1。
我们写一段代码来验证。fork后父进程先sleep上6s,在前3秒,子进程正常跑;后3s,子进程退出,进入僵尸状态。父进程睡醒后,开始等待,可以观察到僵尸进程会消失。
再复制SSH渠道,写一个监控脚本 ——
while :; do ps ajx | head -1 && ps ajx | grep myproc | grep -v grep;sleep 1; echo "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"; done
运行结果,证明wait可以回收僵尸进程 ——
3.2.2 waitpid
#include<sys/types.h>
#include<sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
-
返回值: 等待成功,返回被等待进程pid;等待失败,返回-1。和wait返回值一样。
-
第一个参数 pid:
-
-1
:等待任意一个子进程,等价于wait -
0
:等待指定一个进程
-
3.3 通过status获取子进程退出信息
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
pid_t waitpid(pid_t pid, int *status, int options);
wait和waitpid都有status参数,它是一个输出型参数 - 最终让父进程通过status得到子进程执行的结果
打印status ——
打印结果,父进程等到什么结果,一定和子进程如何退出强相关 ——
3.3.1 位操作
status对于进程退出的三种情况 ——
由此我们可以通过对status进行位操作来获取异常信号和退出码。
🔸 对于代码能运行完的情况:
🔸 对于代码异常终止的:
-
例如,我们给子进程发送2号信号,把子进程提前干掉,此时可以看到退出码是无效的,退出信号即是我们发送的信号 ——
-
例如,除0错误异常终止
3.3.2 宏
我们也可以通过一组不用进行位操作的宏来获取退出码、判断有无异常信号。
WIFEXITED(status) 查看进程是否是正常退出。若为正常终止子进程返回的状态,则为真。
WEXITSTATUS(status) 查看进程的退出码。若WIFEXITED非零,提取子进程退出码。
只需要对上面代码做小小修改 ——
运行结果 ——
3.3.3 理解waitpid
3.4 options
pid_ t waitpid(pid_t pid, int *status, int options);
waitpid的第三个参数options,用来设置等待方式。
0
:默认阻塞等待WNOHANG
:设置为非阻塞等待
阻塞和非阻塞,比如啊,我啊,边通书,这学期马原课就没听过,学分绩课,同学们把最后一排门边儿的位置都留给我了,我啊,电脑一开,小风儿一吹;转到线上后我就把自己挂往那儿一挂,甚至常常都忘了挂(大家不要学我,我觉得我思辨能力在倒退,当然上课和这个也没有直接关联啦)。but要期末考试了,我就苦哈哈的追着大学霸同学划重点(现实中我根本就不会这么干),于是我来到A01楼下给他打电话,他说他在楼上看书让我等30min。fine! 等就等,电话别挂,你不完成,我就不返回,这就是阻塞状态;但是我这个istj怎么可能闲着呢,于是我在楼下打开电脑写5min代码就再给他打个电话,轮巡检测他的状态,不因为对方没准备好就卡在那里,这就是非阻塞状态。
阻塞等待和非阻塞等待都是等待的一种方式。对应到操作系统,谁等?父进程在等;等谁?子进程;等什么?子进程退出。
3.4.1 阻塞等待
阻塞的本质:意味着进程的PCB被放入等待队列中,并将进程状态由R改为S状态。
返回的本质:子进程退出时,父进程的PCB从等待队列拿回R队列,从而被CPU调度。
3.4.2 非阻塞等待
我们看到某些应用或者OS本身,卡住或长时间不动,叫做应用或者程序HANG住了。那么,WNOHANG
表示设置等待方式为非阻塞。
父进程在非阻塞等待子进程时,返回值有以下几种情况 ——
- 子进程根本就没退出
- 子进程退出,waitpid成功或失败(等待是有可能失败的,比如你等错了进程)
这就叫做基于非阻塞等待的轮询方案,运行结果 ——
注:关于waitpid的返回值补充
若等待成功时候waitpid返回收集到的子进程的进程ID;若等待出错,则返回-1,这时errno会被设置成相应的值以指示错误所在。
如果设置了选项WNOHANG
,而调用中waitpid发现没有已退出的子进程可收集,则返回0。
4. 进程程序替换
众所周知,创建子进程,可以让子进程子承父业,执行父进程代码的一部分(代码共享),那么如何让子进程执行一个“全新”的程序呢?那就要通过程序替换。
4.1 what & why?
进程不变,仅仅替换当前进程的代码和数据的技术,叫做进程程序替换。并没有创建新的进程。
程序替换本质就是把程序的代码+数据,加载到特定进程的上下文中。C/C++程序要运行,必须要先加载内存中,如何加载呢?是通过加载器,加载器的底层原理就是一系列的exec*程序替换函数。
如果我们fork子进程,让子进程进行程序替换 ——
可以看到子进程程序替换,父进程并没有受到它的影响,进程是具有独立性的。那么父子代码是共享的吗?事实上,进程程序替换会更改代码区的代码,也要发生写时拷贝。这样,就可以让子进程执行全新的程序。
关于exec*函数的返回值 ——
只要进程的程序替换成功,就不会执行后续代码,因此,exec*函数成功是不需要进行返回值检测;只要返回了,就一定是因为调用失败了,直接退出程序即可。
4.2 六个替换函数 & 它们之间的关系
有6个以exec*开头的调用接口,执行函数替换 ——
#include <unistd.h>`
int execl(const char *path, const char *arg, ...);
int execv(const char *path, char *const argv[]);
int execlp(const char *file, const char *arg, ...);
int execvp(const char *file, char *const argv[]);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execve(const char *path, char *const argv[], char *const envp[]);
这些函数名看起来很容易混淆,但是理解它们的命名含义就很好记 ——
替换函数接口 | |
---|---|
l(list) | 参数采用列表方式 |
v(vector) | 参数采用数组方式 |
p(path) | 自动搜索环境变量PATH |
e(env) | 自己维护环境变量,或者说自定义环境变量 |
我们依次来看,就能摸到其中的规律 ——
💜 execl
💜 execv
l
即参数用列表传递;v
即参数用数组传递。
int execl(const char *path, const char *arg, ...);
int execv(const char *path, char *const argv[]);
在环境变量一节讲过,main函数是可以携带参数的,argv是一个指针数组,指针指向命令行参数字符串。我们可以理解为,通过exec函数,把argv喂给了ls程序的main函数。
💜 execlp & execvp
带p
,表示会自动环境变量PATH中搜索,只需要知道程序名即可。
execlp("ls", "ls", "-a", "-l", "-n", NULL);
char* argv[] = { "ls", "-a", "-l", "-n", NULL};
execvp("ls", argv);
💜 execle & execve
带e
,表示自己维护环境变量,也就是我不想用你给我传的默认环境变量。(execve同理,只不过用数组传递命令行参数)
exec*也可以调用我们自己的程序。
注:Makefile默认只生成第一个目标文件,那么如何在一个Makefile文件中一次形成两个可执行文件呢?
所有的接口,看起来没有很大差别,只是调用参数的不同。这么多的接口,是为了满足不同的调用场景。
❤️ 操作系统只提供了一个系统调用接口execve
(2),其他库函数(3)都是对系统调用的简单封装。
4.3 程序替换运行其他语言程序
5. 实现一个简陋的shell
💜 写一个shell 命令行解释器,需要循环以下过程
- 打印提示行
- 获取命令行
- 解析命令行
- fork创建子进程;替换子进程
- 父进程等待子进程退出
来谈一谈各个阶段的注意点 ——
🍓 1. 打印提示行
[用户名@主机名 路径]提示符,这些都可以通过系统调用获取到,但是对于理解Linux意义不大,就直接写死了。
另外,写进度条时候就知道,显示器的刷新策略就是行刷新,不想换行还要刷新,可以调用fflush(stdout);
🍓 2. 获取命令行
注意,输入一整行,要用fgets。再这个字符串打印出来,发现多换了一次行,这是因为我们把回车也读入进command串儿中,需要把\n
处置0。
🍓 3. 解析命令行
解析字符串,要分割串,用strtok。传参给要解析的字符串、分隔符串儿,返回子串;第二次提取时,把还想提取的老串儿给NULL——
我们把子串儿都提取到char*的指针数组argv中。
🍓 4. fork创建子进程;替换子进程
不能用当前进程直接替换,会把前面的解析代码覆盖掉,因此要创建子进程。同时,父进程需要等待子进程退出。这也就解释了为什么在bash上执行出错了,echo $? 就能拿到退出码,这是因为子进程的退出结果是可以wait拿到的,如何拿到?回看3.3小节。
那么选择哪个进程替换函数呢?execvp
of course.
我们之前在命令行解释器一文中铺垫的:bash是一个进程;会获取用户输入、对命令行做解析,帮用户和内核打交道;还会创建子进程帮我们执行命令,就算子进程崩了也不会影响bash(王婆和实习生),曾经抽象的理论,现在就无比清晰了。
🍓 5. 内建命令
wait我们发现,对于|
管道和>
重定向是无法处理的,因为当前代码没有组合设置,我们实现的其实是一个相当简陋的shell。然而震惊的是,我们 cd..
路径没有回退?!这是因为执行回退的是子进程,并非是父进程bash.
fork要执行的命令是第三方命令,对于cd,现在我们不想再执行第三方命令,以内建命令方式运行(即不创建子进程,让父进程shell自己执行),实际上相当于调用了自己的一个函数。更改当前进程路径,有一个系统调用接口chdir ——
于是我们在添加一个检测是否要执行内建命令,我们这里只做的简单的匹配,且执行了内建命令,直接continue继续解析.
💜 shell完整代码
附:mini_shell.c
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/wait.h>
#include<unistd.h>
#define NUM 128
#define CMD_NUM 64
int main()
{
char command[NUM];
char* argv[CMD_NUM] = {NULL};//未设置就是NULL值
for(;;)
{
command[0]=0;//这种方式可以做到,时间复杂度O(1),清空字符串
//1.打印提示符
printf("[you-know-who@myhostname mydir]$ ");
fflush(stdout);
//2.获取命令行
fgets(command, NUM, stdin);
//printf("echo %s\n", command);// ls -a -l\n\0
command[strlen(command)-1] = 0;
//3.解析命令行字符串,argv[] - strtok
const char* sep = " ";
argv[0] = strtok(command, sep);
int i = 1;
while(argv[i] = strtok(NULL, sep))
{
i++;
}
if(strcmp(argv[0], "cd")==0)
{
if(argv[1])
chdir(argv[1]);
continue; //
}
//5.执行第三方命令
if(fork()==0)
{
//child
execvp(argv[0], argv);
exit(1);
}
waitpid(-1, NULL, 0);
}
return 0;
}
持续更新~@边通书
更多推荐
所有评论(0)