文章目录
1、再次理解fork函数
1.1 fork函数回顾
①在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
②进程调用fork,当控制转移到内核中的fork代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
③关于fork函数的返回值:
- 在父进程中,fork返回新创建子进程的进程ID
- 在子进程中,fork返回0
- 如果出现错误,fork返回一个负值
④当一个进程调用fork之后,就有两个二进制代码相同的进程。子进程在fork函数调用后开始执行。
⑤fork之后,谁先执行完全由调度器决定。
1.2 独立、共享以及写时拷贝
先引入一个概念:父子进程具有共享性,也具有独立性。这句话并不矛盾。
在说这个话题之前,我们先要知道进程地址空间和页表。每个进程都有自己的进程地址空间(mm_struct),通过这个地址空间,我们可以知道每个变量,每个函数的地址。在Linux下地址下,这种地址叫做虚拟地址,不是实际的物理地址,所以我们在C/C++语言中所看到的地址全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。那OS又是如何知道虚拟地址所对应的物理地址呢?每个进程除了有独立的mm_struct之外,还有独立的页表。页表就是对虚拟地址和物理地址的一种映射,OS拿到虚拟地址,通过查页表就可以知道数据在物理地址中实际位置。
关于共享性:因为子进程是父进程通过fork函数创建的,所以子进程会继承父进程的绝大多数资源(包括环境变量、堆栈、共享内存等),但有些东西是不会继承的(包括PID、父进程号、挂起信号等)。继承的资源其中就有mm_struct和页表,通过mm_struct和页表就能找到物理地址所对应空间的数据。所以说父子进程具有共享性。
关于写时拷贝和独立性:写时拷贝是一种延时拷贝
,为了避免不必要的拷贝,从而产生的一种挺高性能而产生的技术(STL——string也使用的是这种技术)。其中就会用到引用计数
。引用计数的目的就是记录一块空间被多少指针指向的个数。当父进程通过fork创建子进程时,子进程继承了父进程的mm_struct和页表,子进程也能访问父进程的数据,因为页表映射到的是相同的物理地址。但仅限于读数据。当父子进程哪一方想修改数据时,OS就会介入,先查看引用计数,如果引用计数大于1的话,OS就会在为子进程开辟自己的空间,将数据拷贝进去,然后再修改页表的映射关系。后面子进程修改数据时,就不会修改父进程的数据了,这就很好的体现了父子进程的独立性。
总的来说,就是父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本
1.3 fork的常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数
1.4 fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
2、进程终止
2.1 进程退出场景
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
2.2 进程正常终止和异常退出(程序崩溃)
正常终止:
- 从main函数返回
- 调用exit
- 调用_exit
在Linux下,因为一个进程结束后,退出码会被父进程读取的,所以我们可以用echo $?(输出最近一次程序退出时的退出码) 指令查看进程的退出码
假设有以下代码:
#include<stdio.h>
int main()
{
printf("FL");
return 0;
}
main函数中的return后面所带的数字就是退出码,退出码为0,表示正常退出,所以我们写C程序的main函数时,返回的基本都是0
当我们再次输入这个命令:结果还是为0,是因为echo也是程序,这次输出的是上次echo命令的退出码
为什么会有退出码?
当程序正常运行结束,我们可以通过退出码判断该程序的结果是否正确(0表示success,!0表示failed)。为什么程序结果错误退出码需要用!0表示,因为!0有多个数,1,2,3,4等数字都可以表示!0。导致程序运行结构错误的原因可能有很多种,所以每一个!0的退出码,都对应着一个错误信息,用来表示结果为什么不对,这也是程序员需要关心的。
Linux中的退出码:
可以通过上述操作查看Liunx中的退出码,我们发现退出码最大为133,超过后就没有对应的错误信息了。
注意:程序正常退出,退出码才有价值,因为它表示程序结果是否正确
异常退出:
程序运行时,到了中途就异常退出了,这就叫程序崩溃
本来应该在/0操作后打印错误码所对应的错误信息,但结果不是是这么回事,原因大家也应该知道,对于/0,该操作是非法的。因为这个操作,导致程序异常退出,也就没有打印后面的信息了
此时的退出码为:
通过对比前面的退出码,我们发现136并没有对应的错误信息,这也更加验证了程序正常退出,退出码才有价值,如果程序崩溃(异常退出),退出码也就没有意义了
2.3 进程常见的退出方法
-
从main返回
main函数的return表示进程退出,是一种常见的退出方法,return后面所跟的数字就是进程的退出码,而非main函数的其他函数中的return表示函数返回 -
调用exit
我们可以通过调用exit函数来终止进程
对应的参数status表示进程退出时的退出码
注意:eixt最典型的特点是在程序任意的地方去调用,都代表进程退出
-
调用_exit
_exit和eixt类似,在程序的任意地方调用都能终止进程
关于刷新缓冲区——return、eixt和_exit的对比
对于return,假设有以下程序:
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
int main()
{
printf("hello fl");
sleep(4);
return 0;
}
对于exit,假设有以下程序:
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
int main()
{
printf("hello fl");
sleep(4);
exit(0);
return 0;
}
对于_exit,假设有以下程序:
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
int main()
{
printf("hello fl");
sleep(4);
_exit(0);
return 0;
}
通过对比我们发现,有return和exit的程序都打印了hello fl,而_exit则没有打印hello fl。其原因是前两者在终止进程时,都会进行收尾工作,比如刷新了缓冲区,将缓冲区中的内容打印到了前台,而后者却没有进行收尾工作,也就没有刷新缓冲区,这也导致了结果的不同
注意:这里的缓冲区是用户级缓冲区
扩展:执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数
2.4 进程退出,OS层面做了什么呢?
系统层面,少了一个进程:free PCB,free mm_struct,free 页表和和各种映射关系,程序的代码和数据申请的空间也要被释放掉,归还系统。
进程加载和进程退出,OS做的工作是相反的
3、进程等待
3.1 进程等待是什么以及为什么要有进程等待?
通过fork()创建子进程是为了帮助父进程完成某种任务,父进程就需要通过某种方式去获得(或者知道)子进程完成任务的情况如何(是完成了,还是没完成)。所以此时就需要父进程在fork之后,通过wait/waitpid等待子进程退出,这种现象就叫做进程等待。
为什么要让父进程等待?
- 通过获取子进程退出的信息,能够得知子进程的执行结果
- 可以保证时序问题:子进程先退出,父进程后退出
因为父进程需要获得子进程的退出信息,所以父进程一定会后于子进程退出。换句话说,如果父进程先退出了,子进程还在运行,那么父进程就无法获取子进程的退出信息。进程等待就保证了父进程活的时间比子进程长 - 子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。所以就需要父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
注意:进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程
3.2 进程等待的方式
wait方法:
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
pid_t id = fork();
if(id == 0)
{
int count = 5;
while(count)
{
//child
printf("child[%d] is running; count is: %d\n",getpid(),count);
count--;
sleep(1);
}
exit(0);
}
//parent
sleep(10);
printf("father wait begin!\n");
pid_t ret = wait(NULL);
if(ret > 0)
{
printf("father wait: %d, success\n",ret);
}
else
{
printf("father wait failed\n");
}
sleep(10);
}
利用fork函数创建子进程。最开始父子进程都为R状态,在5秒之内,子进程每隔1秒就会打印ID以及count,5秒之后子进程将从R状态转换为Z状态,因为子进程结束后,父进程还在sleep中,当父进程sleep完后,通过wait(),将子进程回收掉,此时我们发现只有父进程为R状态,而子进程已经消失,当父进程中的第二个sleep结束后,父进程也将被回收。
通过while :; do ps ajx | head -1 && ps ajx | grep "test"| grep -v grep; sleep 1;echo"###################################"; done查看进程父子进程的状态
waitpid方法:
返回值:
当正常返回的时候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。
代码异常终止的本质就是这个进程因为异常问题,导致自己收到了某种信号!
我们可以让父进程通过status得到子进程执行的结果,是正常终止还是异常终止
wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
如果传递NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
我们将上述代码修改一下:
如果是正常情况下,信号大部分都是0
关于status,不能简单的当做整形来看待,应当作位图来看待
看以下代码:
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
int count = 3;
while (count)
{
//child
printf("child[%d] is running; count is: %d\n", getpid(), count);
count--;
sleep(1);
}
exit(1);
}
//parent
//sleep(10);
printf("father wait begin!\n");
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0)
{
//printf("father wait: %d, success, status: %d\n",ret, status);
if (WIFEXITED(status))//没有收到任何退出信号
{
//正常结束,获取对应的退出码
printf("exit code: %d\n", WEXITSTATUS(status));
}
else
{
printf("error get a signal!\n");
}
}
else
{
printf("father wait failed\n");
}
//sleep(10);
}
运行代码,我们可以发现,子进程是正常退出的,退出码为1(因为子进程中有exit(1))
把代码稍微改一下:
注意:
如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
如果不存在该子进程,则立即出错返回。
关于options:
如果waitpid的第三个参数是0,则是默认行为,表示阻塞等待。如果参数是WNOHANG,则是非阻塞等待
阻塞等待和非阻塞等待的区别:
生活中的案例:
假如有位帅哥,他叫张三。张三有个女朋友,名字叫小花。有一天呢,张三去找小花,想让小花一起去逛街,当张三走到了小花居住地的楼下,然后给小花打了个电话,说:小花呀,我到楼下了,你下来吧。此时,小花却说到,我现在在做作业,而且必须要做,你等我30分钟左右吧。张三说:行吧。
此时张三有两种等待方式:
1.因为张三想时时刻刻了解到小花做完没有,所以就不挂断电话,也让小花不挂电话,就这么把手机放在耳边,眼睛一直盯着小花住房的窗子,随时了解小花是否然做完了,除此之外,什么也不干。
2.张三觉得如果一直干等着太浪费时间了,但又想了解小花的情况,所以就决定看视频,玩游戏,然后每隔2分钟就给小花打一个电话,了解一下小花的情况,直到小花说做完了,那么张三也就不需要打电话了
对于上面所说两种等待方式,我们将第一种称为阻塞等待。第二种称为非阻塞等待。
对于父进程,因为需要对子进程进行多次检测,所以采用基于非阻塞等待的轮询方案
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
int main()
{
pid_t id = fork();
int count = 3;
while (count)
{
count--;
sleep(1);
}
exit(1);
//parent
//sleep(10);
printf("father wait begin!\n");
int status = 0;
while (1)//需要轮询检测
{
pid_t ret = waitpid(id, &status, WNOHANG);
if (ret == 0)
{
//子进程没有退出,但是waitpid等待是成功的,需要父进程重复进行等待
printf("Do father things!\n");
}
else if (ret > 0)
{
//子进程退出了,waitpid也成功了,获取大了对应的结果
printf("father wait: %d, success, status exit code: %d, status exit signal: %d\n", ret, (status >> 8) & 0xFF, s tatus & 0x7F);
break;
}
else
{
perror("waitpid");
break;
}
sleep(1);
}
}
通过实验发现,子进程在运行的时候,父进程进行轮询检测(非阻塞等待)
阻塞等待(阻塞了)是不是意味着父进程不被调度执行了呢?
答案:是的。因为阻塞的本质其实就是进程的PCB被放入了等待队列,并将进程的状态改为S状态。相反的,返回的本质就是将进程的PCB从等待队列拿到运行队列,从而被CPU调度
3、进程程序替换
进程不变,仅仅替换当前进程的代码和数据的技术,叫做进程的程序替换
程序替换的本质就是把程序的进程代码+数据,加载到特定进程的上下文中
替换原理:
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变
替换函数
其实有六种以exec开头的函数,统称exec函数:
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[]);
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。如果调用出错则返回-1
所以exec函数只有出错的返回值而没有成功的返回值。
命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记
- l(list) : 表示参数采用列表
- v(vector) : 参数用数组
- p(path) : 有p自动搜索环境变量PATH
- e(env) : 表示自己维护环境变量
#include <unistd.h>
int main()
{
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);
exit(0);
}