一、进程创建
1.1 fork的初步认识和基本使用
在linux中fork函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include <unistd.h>
pid_t fork(void);
返回值:子进程中返回0,父进程返回子进程pid,创建失败返回-1
详细内容请看另一篇文章:
【Linux进程】进程的基本概念 {PCB结构体,进程表,Linux中的task_struct,查看进程,获取进程PID,使用fork创建子进程}
1.2 fork的具体工作流程
进程调用fork,当控制转移到内核中的fork代码后,内核做:
- 创建:分配新的内核数据结构和内存块(用于加载代码和数据)给子进程
- 初始化:将父进程内核数据结构的部分内容拷贝至子进程
- 组织管理:添加子进程到系统进程列表当中,交由操作系统管理进程。
- 开始执行:fork返回,开始调度器调度,子进程开始执行。
1.3 写时拷贝
- 在进行写入操作之前,父子进程的页表会将他们的地址空间映射到同一段物理内存;
- 同时将所有页表项暂时设置成只读权限,表示该页表项指向的物理内存被父子共享不能随意修改。
- 当父子任意一方试图进行写入时,与页表项的只读权限发生冲突,操作系统才会将被写入的页表项指向的物理内存拷贝分离;
- 并将该页表项的只读权限取消,表示该页表项指向的物理内存被一个进程独占可以直接进行写入。
为什么要进行写时拷贝?
-
由于进程具有独立性,创建子进程时必须为其分配独有的内核数据结构。
-
但在子进程创建过程中,并不会为其重新加载一份代码和数据,起初父子进程会共享同一份代码和数据。
-
由于代码的只读属性,父子共享是没有问题的。但是数据可以被修改,所以必须通过某种方式进行分离。
-
为什么不在进程创建时直接拷贝分离呢?原因有二:
- 分配的数据空间可能不会被立刻用到,空间闲置就是对内存资源的浪费。
- 有些数据可能根本不会被子进程访问,或者在运行过程中只会被读取,对于这样的数据没有拷贝分离的必要。
-
基于这样的原因,操作系统选择了写时拷贝技术将父子进程的数据进行分离,保证了进程的独立性。写时拷贝是一种延时申请技术,可以提高整机内存的使用率。
父子进程共享的代码是fork之后的还是所有的?
当然是所有代码都会被父子共享。
事实上,我们的代码通过编译就变成了二进制指令。程序加载到内存后,每条指令都会有对应的地址。
由于现代CPU采用并发控制,所有进程在执行过程中都有可能被中断,CPU转而去执行其他进程。当CPU资源再次分配到该进程时,进程当然需要从之前的位置继续执行。进程的执行位置记录在进程上下文中的PC值中,即程序计数器PC(Program Counter)。它是CPU内的寄存器数据,专门用于记录下一条要执行的指令的地址。
实际上所有的代码都会被父子进程共享。但在创建子进程时,父进程的进程上下文也会拷贝给子进程。由于PC值相同,父子进程会从fork之后的同一个位置开始向下执行。所以看起来就像是共享fork之后的代码一样。当然如果你愿意,子进程也可以去执行fork之前的代码。
1.4 fork的常规用法
- 一个父进程希望复制自己,使父子进程同时执行同一个程序的不同代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
1.5 fork调用失败的原因
-
系统资源不足:当系统中可用的资源(如内存、进程表项等)不足以支持新的进程创建时,fork调用会失败。
-
进程数量限制:操作系统可能对同时运行的进程数量进行限制,当已达到最大进程数量时,fork调用会失败。
-
系统错误:可能存在其他系统错误导致fork调用失败,例如操作系统内部错误或者硬件故障。
测试代码:父进程不断创建子进程,子进程死循环不退出,直至创建失败!
#include <stdio.h>
#include <unistd.h>
int main(){
int max_proc = 0;
while(1)
{
pid_t id = fork();
if(id == -1)
{
perror("fork error:");
printf("max_proc:%d\n", max_proc);
return 1;
}
else if(id == 0)
{
while(1){
sleep(1);
}
}
else
{
++max_proc;
}
}
}
运行结果:
我的虚拟机配置较低,只创建了4093个进程就造成了系统资源不足的问题。此时系统处于崩溃边缘,很多命令无法正常运行。
二、进程终止
进程终止时会释放占用的系统资源,包括CPU资源和内存空间(内核数据结构和对应的数据和代码),最后结束进程。
2.1 进程的退出情况
- 代码跑完,结果正确
- 代码跑完,结果不正确
- 代码没有跑完,程序崩溃
其中,代码跑完结果是否正确及错误原因通过退出码返回给上一级进程(父进程)
如果程序崩溃,崩溃原因通过退出信号返回给上一级进程
2.2 进程退出码
-
进程退出码返回给上一级进程,是用来评判该进程执行结果用的,可以忽略。
-
进程退出码用0表示运行成功且结果正确。非0表示运行结果不正确。
-
非0值有很多个用于表示不同的错误原因,当程序运行结束后方便定位错误。
-
在Linux系统中,退出码是无符号整数,占8位,范围[0,255]。
提示:使用
echo $?
获取最近一个进程执行完毕后的退出码。
得到退出码后如何将退出码转换成错误描述呢?
利用strerror函数打印0~15错误码的字符串描述:
ls 和 kill命令在执行时同样也是进程,进程退出就有退出码:
-
ls命令的错误描述与其退出码相对应,说明他使用的是库函数的预置退出码方案。
-
kill命令的错误描述与其退出码不对应,说明他使用的是自定义退出码方案。
-
我们的程序可以使用库函数预置的退出码解释方案。当然,如果你愿意也可以自己设计一套退出码解释方案!但是不要随意制定退出码方案,否则会导致误解退出码含义。
2.3 进程退出信号
进程崩溃:
- 程序崩溃,崩溃原因通过退出信号返回给上一级进程,退出信号在下一节 “进程等待” 章节的 “进程状态” 部分讲解
- 程序崩溃时,退出码无意义。因为return/exit没有被执行,此时的退出码是一个随机值。
2.4 进程常见的退出方法
2.4.1 return语句
-
return是C/C++语句
-
其他函数内的return语句表示该函数退出。
-
而main函数内的return语句表示进程退出,return后跟的数字就是退出码。
2.4.2 _exit函数
- _exit是Linux系统调用
- _exit在任何地方调用,都表示直接终止进程!
- _exit直接终止进程,不考虑缓冲区的问题。
2.4.3 exit函数
-
exit是C库函数,底层封装_exit系统调用。
-
exit在任何地方调用,都表示直接终止进程!
-
exit在终止进程之前会将缓冲区中的数据刷新到显示器上。
-
推荐使用exit终止进程
注意:我们之前讨论的缓冲区,实际是由C标准库为我们维护的,而不是操作系统。