操作系统之进程控制
文章目录
- 1. 进程创建
- 1.1 回顾fork函数
- 1.2 写时拷贝
- 2. 进程终止
- 2-1 进程退出场景
- 2.2 进程常见退出方法
- 2.2.1 退出码
- 2.2 _exit 和 exit
- 2.3 return退出
- 3. 进程等待
- 3.1 进程等待的必要性
- 3.2 进程等待的方法
- 3.2.1 wait方法
- 3.2.2 waitpid方法(常用)
- 3.2.3 阻塞等待与非阻塞等待的理解
- 4. 进程程序替换
- 4.1 替换原理
- 4.2 替换函数
- 4.2.1 函数解释
- 4.2.2 命名理解
1. 进程创建
1.1 回顾fork函数
在Linux中进程创建是由fork来完成的,fork函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
进程调用fork,当控制转移到内核中的fork代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
如下fork创建进程的流程图:
fork函数返回值:
- 子进程返回0
- 父进程返回的是子进程的pid
1.2 写时拷贝
通常,父子代码共享,父子在不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
因为有写时拷贝技术的存在,所以父子进程得以彻底分离!完成了进程独立性的技术保证!写时拷贝,是一种延时申请技术,可以提高整机内存的使用率。
2. 进程终止
进程终止的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。
2-1 进程退出场景
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
2.2 进程常见退出方法
正常终止(可以通过 echo $?
查看进程退出码):
- 从
main
返回- 调用库函数
exit
- 或者用系统调用
_exit
异常退出:
- Ctrl + C,信号终止
- 程序内部进行空或野指针访问
- 越界访问
进程一旦出现了异常,一般是进程收到了信号。
2.2.1 退出码
退出码(退出状态)可以告诉我们最后一次执行的程序的状态。在程序结束以后,我们可以知道程序是成功完成的还是以错误结束的。其基本思想是,程序返回退出码为0时表示执行成功,没有问题。除 0 以外的任何退出码都被视为不成功。
LinuxShell中的主要退出码:
- 退出码为 0 表示程序执行无误,这是完成程序的理想状态。
- 退出码为 1 我们也可以将其解释为“不被允许的操作”。例如在没有 sudo 权限的情况下使用yum/apt;再例如除以 0 等操作也会返回错误码 1 。
- 130 ( SIGINT 或Ctrl + C )和 143 ( SIGTERM )等终止信号是非常典型的,它们属于128+n 信号,其中n 代表终止码。(具体信号介绍后续文章会有)
- 可以使用strerror函数来获取退出码对应的描述。
举例代码:
正常退出(0号退出码)
#include <stdio.h>
int main()
{printf("Hello world\n");printf("%s\n", strerror(0));return 0;
}
2.2 _exit 和 exit
_exit
函数是系统调用
#include <unistd.h>
void _exit(int status);
// 其中参数:status 定义了进程的终⽌状态,⽗进程通过wait来获取该值
使用 _exit(-1) 观察现象:
#include <unistd.h>
int main()
{printf("hello world");_exit(-1);return 0;
}
现象:
说明:
- 虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行
echo $?
发现返回值是255
- 发现调用printf后没有换行,然后立即调用_exit,最后运行并不会打印出 hello world,这是因为_exit是系统调用,它调用结束后不会进行缓冲区的刷新。此外换行也可以进行缓冲区的刷新
exit
函数是C提供的库函数
#include <stdlib.h>
void exit(int status);
使用 exit(-1) 观察现象:
#include <stdlib.h>
int main()
{printf("hello world");exit(-1);return 0;
}
现象:
由之前的知识得,库函数是对系统调用接口进行封装的产物,也就是说exit底层会去调用_exit,但是在调用_exit前,还做了其他工作:
- 执行用户通过atexit或on_exit定义的清理函数
- 关闭所有打开的流,所有的缓存数据均被写入
- 调用_exit
总结两者的区别就是, _exit是系统调用接口,它不会对缓冲区进行刷新,exit是库函数,其是对_exit系统调用接口的封装,功能上更丰富,能进行缓冲区的刷新。其实也由此可推断出缓冲区一定不是操作系统内部的缓冲区,它应该是库缓冲区,也就是C语言提供的缓冲区。
2.3 return退出
return是一种更常见的退出进程方法。执行return n等同于执行 exit(n) ,因为调用main,运行时函数会将main的返回值当做 exit 的参数。注意 return 只有在main内执行时才是退出进程。
3. 进程等待
3.1 进程等待的必要性
- 之前讲过,子进程退出,父进程如果不管不顾,就可能造成“僵尸进程”的问题,进而造成内存泄漏。
- 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的 kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
- 最后,子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源(最重要的),获取子进程退出信息(可选的)
3.2 进程等待的方法
3.2.1 wait方法
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int* status);
返回值:成功则返回被等待进程的pid,失败则返回-1
参数:输出型参数,获取⼦进程退出状态,不关⼼则可以设置成为NULL
注:wait
方法是等待任意个退出的子进程,如果等待子进程,子进程没有退出,父进程会一直阻塞在wait调用处,这种叫做阻塞等待
3.2.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:默认为0,表示阻塞等待WNOHANG:若pid指定的子进程没有结束,则waitpid()函数循环返回0,不予以等待。若正常结束,则返回该⼦进程的PID。
注:waitpid
是wait的升级版,其功能更加丰富,选项option支持阻塞等待(设置为0)与非阻塞等待(设置为WNOHANG)两种。所谓非阻塞等待就是父进程进行一个类似非阻塞轮询的方式,每隔一段时间来获取下子进程有没有退出,若没有退出就返回0,然后父进程可以去运行自己后续的代码,若退出,就返回对应子进程的PID。
总结:
- 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息
- 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞
- 如果不存在该子进程,则立即出错返回
wait类似下图:
3.2.3 阻塞等待与非阻塞等待的理解
你叫小帅,你刚上大学就喜欢上了小美,经过你长达两年的不懈追求与暗示,小美终于在某一天同意与你一起出去约会,她叫你提前在学校8号公寓楼下等她,她说她要好好换个妆,来面对与小帅你的第一次约会,请你耐心等待一下!于是你很开心的早早来到了8号公寓楼下等着小美,这时你的任务就是等待小美出现与你约会,你不能离开一步,因为这是你们的第一次约会,如果小美下来没见到小帅你在8号楼下等她,那么你跟小美的感情也就完蛋了!功夫不负有心人,在等待了不久之后,小美化上了美美的妆容出现在了8号楼下,见到了你,最后你结束了等待,与小美一起开心的约会去了。以上这种就属于阻塞等待,父进程在接受到等待的指令后,只能停止自己的任务运行,在原地去等待子进程的任务完成后,然后获取子进程的退出信息并进行相应的资源回收与清理工作,最后父进程才能继续运行接下来的任务。
如下代码:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{printf("小美同意了小帅的约会请求,先让小帅在8号楼下等她化妆\n");printf("此时小帅知道了小美同意了他的请求,来到了8号楼下进行等待...\n");int ret = fork();if (ret == 0){// childprintf("小美正在化妆中...\n");int n = 10;while (n--){printf("小美化妆还有%d分钟\n", n);sleep(1);}exit(0); // 子进程退出}waitpid(-1, NULL, 0);// 阻塞等待printf("小帅一直在等待小美\n");printf("最后等待成功,与小美一同约会去了\n");return 0;
}
结果:
2.
观察结果可发现,在小美化妆的这10分钟内(子进程运行时),小帅是一直不做事的(父进程一直在等待),只有等待小美化妆结束后,小帅才进行后续的工作,这正好符合我们的预期。接下来理解下非阻塞等待。
你还是小帅,经过上次与小美的第一次约会后,你一直期待下次与小美约会的到来,于是趁着五一假期快要到来的时候,你决定邀请小美五一假期一起去北京旅游,你于是微信上给小美发去了信息,小美看到了你发的信息,回复到,你太木讷了,上次跟你约会,你像个傻瓜一样,什么话都不会说,不想跟你去约会,太无趣了,并回复了几个傲慢的表情。你这时候快要心灰意冷了,这时你宿舍的高情商哥看见你对着手机发呆,于是悄悄地瞥了一眼你的手机屏幕,马上对你说到,果然就像小美说的一样,你真木讷,要是人家女孩子想拒绝你的话,早就不回你信息或者假装没看见了,小美还指出你的缺点并回复傲慢的表情说明你还有戏,她的意思是不喜欢你傻傻的一句话都不说,这样的约会没有意思。你好似醍醐灌顶一样,可是小帅你是个程序猿,根本不懂得啥叫高情商,每天除了跟男生打交道就是跟电脑打交道,于是求着你宿舍的高情商哥让他当你军师给你支招,帮你把小美约出来。高情商哥是个热心肠,二话不说就同意了,于是在他的高情商发言下,小美最终同意你的约会请求,并同样让你在老地方8号楼下等着她。临出门时,高情商哥说,哎,你出去约会见面不能空手去见啊,你好歹手上拿点东西给人家,最近奶茶店又出新品了,你可以去买两杯奶茶先,然后在楼下等小美的时候也可以去网上学习下高情商约会技巧,这样才能不显得木讷。你此时对高情商哥非常感激,没想到他还能再帮你一手,于是你答应他,等你跟小美约会回来就请他单独吃一顿饭。然后你按照军师的指点,在小美化妆的期间,先去奶茶店买了奶茶,然后再在楼下苦学高情商约会技巧,有了第一次约会的经验,你也知道小美化妆一般需要多久,于是你一会注意下手机时间,一会苦学技巧,为的是提前做好等待小美的准备,而不是小美下来了,你还在傻傻学着高情商技巧。功夫不负有心人,过了一会,你掐准小美化妆的时间,提前把手机收了起来,拿出来奶茶,微笑着等待小美的出现并主动迎了上去帮小美拿包,同时把奶茶递给了她。小美对你这次的表现出乎意料的满意,于是很开心的跟你旅游去了。以上这种就属于非阻塞等待,在子进程自己执行它的任务期间,父进程并不是傻傻的一直在原地等待,而是可以由循环的方式采取非阻塞轮询的方式,每隔一段时间去获取子进程有没有退出,同时在等待子进程运行任务的时间内,父进程也可以去运行自己后续的内容。
如下代码:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
// 2. 非阻塞等待
int main()
{printf("小美同意了小帅的约会请求,先让小帅在8号楼下等她化妆\n");int ret = fork();if (ret == 0){// child printf("小美正在化妆中...\n");int n = 10;while (n--){printf("小美化妆还有%d分钟\n", n);sleep(1);}exit(0);}pid_t pid = 0;printf("小帅先去买了奶茶\n");do {pid = waitpid(-1, NULL, WNOHANG); // 由循环去控制非阻塞轮询,没等到子进程退出就返回0,等到子进程退出返回子进程pidif (pid == 0){sleep(2);printf("小帅然后来到了8号楼下,一边等待小美,一边苦学高情商技巧...\n");}} while (pid == 0);printf("小美正在下楼...\n");printf("小帅等到了小美,与小美开心的约会去了\n");return 0;
}
结果:
可以观察到非阻塞等待与阻塞等待最大的区别就是,非阻塞等待中的父进程可以采用循环控制的方式去非阻塞轮询,一边等待子进程退出,一边做自己后续的工作。
4. 进程程序替换
fork() 之后,父子各自执行父进程代码的一部分如果子进程就想执行一个全新的程序呢?那么进程的程序替换就可以完成这个功能!程序替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中!
4.1 替换原理
用fork创建子进程后执行的是和父进程相同的程序(但也有可能执行不同的代码分支),子进程往往要调用一种exec
函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
4.2 替换函数
其实有六种以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[]);
int execve(const char *path, char *const argv[], char *const envp[]);
4.2.1 函数解释
-
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回
-
- 如果调用出错则返回-1(调用出错一般是指替换的程序错误或者替换的程序没有)
-
- 所以exec函数只有出错的返回值而没有成功的返回值
4.2.2 命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记:
- l(list):表示命令行参数采用列表
- v(vector):命令行参数用数组
- p(path):有p自动搜索环境变量PATH
- e(env):表示自己维护环境变量
调用举例:
#include <unistd.h>
int main()
{// 自己组装的命令行参数表与环境变量表都是要以NULL结尾char* const argv[] = { "ps", "-ef", NULL };char* const envp[] = { "PATH=/bin:/usr/bin", "TERM=console", NULL };execl("/bin/ps", "ps", "-ef", NULL);// 带p的,可以使用环境变量PATH,⽆需写全路径 execlp("ps", "ps", "-ef", NULL);// 带e的,需要自己组装环境变量 execle("ps", "ps", "-ef", NULL, envp);execv("/bin/ps", argv);// 带p的,可以使用环境变量PATH,⽆需写全路径 execvp("ps", argv);// 带e的,需要自己组装环境变量 execve("/bin/ps", argv, envp);return 0;
}
事实上,只有execve
是真正的系统调用,其它五个函数最终都调用execve,所以execve在man手册第2节(系统调用栏),
其它函数在man手册第3节(库函数调用栏)。这些函数之间的关系如下图所示:
简单演示几个:
- execl(需写全路径):
int main()
{printf("我的程序要运行了!\n");// 无环境变量PATH,需写全路径 execl("/bin/ls", "ls", "-l", NULL);printf("我的程序结束了!\n");exit(0);
}
2. execlp(不需写全路径):
int main()
{printf("我的程序要运行了!\n");// 有环境变量PATH,不需写全路径 execlp("ps", "ps", "-ef", NULL);printf("我的程序结束了!\n");exit(0);return 0;
}
3. execve(需要自己组装环境变量并且需要写全路径):
int main()
{printf("我的程序要运行了!\n");// 自己组装的命令行参数表与环境变量表都是要以NULL结尾char* const argv[] = { "ls", "-l","-a", NULL };char* const envp[] = { "PATH=/bin:/usr/bin", NULL };// 需写全路径 execve("/bin/ls", argv, envp);printf("我的程序结束了!\n");return 0;
}
注意:程序替换不仅是可以替换同语言之间的程序,也可以替换不同语言之间的程序,也就是说,你的C程序中用了exec
系列函数,可以去运行其他语言写的程序,比如说Python,前提是你的机器上预装了Python的解释器,但有些程序是替换不了的,比如说前端一些程序,因为它是在浏览器上进行运行的。
如下代码:
hello.py
#!/usr/bin/python3
print ("hello python")
test.c
int main()
{printf("我的程序要运行了!\n");execl("/usr/bin/python3", "python", "hello.py", NULL);printf("hello C\n");printf("我的程序结束了!\n");
}
结果:
总结:
- 一旦程序替换成功,就去执行新代码了,原始代码的后半部分,已经不存在了!
- exec系列函数,只会有失败返回值,没有成功返回值,也就是说,exec系列函数,不用做返回值判断,只要返回,就是失败
- 在程序替换的过程中,并没有创建新的进程,只是把当前进程的代码和数据用新的程序的代码和数据进行覆盖式的替换