Linux进程创建
本文主要学习理解 fork 的返回值、写时拷贝的工作细节、为什么要存在写时拷贝;进程退出码、进程退出的场景及常见的退出方法、对比 man 2 _exit 和 man 3 exit;进程终止、操作系统怎么进行释放资源、池的概念;进程等待的价值、进程等待的方法 wait 和 waitpid(常用)、int* status、阻塞和非阻塞、如何理解等待、W
IFEXITED、WEXITSTATUS、WTERMSIG;什么是进程替换 && 为什么要进程替换、替换原理、7个exec系列的替换函数、模拟shell解释器;
一、进程创建
现阶段我们知道进程创建有如下两种方式,其实包括在以后的学习中这两种方式也是最常见的:
- 命令行启动命令 (程序、指令等)。
- 通过程序自身,fork 的子进程。
💦 fork函数
在 linux 中 fork 函数是非常重要的函数,它从已存在的进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include<unistd.h>
pid_t fork(void);
返回值:子进程返回 0,父进程返回子进程的 pid,出错返回 -1。
现在我们知道父进程被创建时,是有自己的 PCB、地址空间、页表的,在系统层面是通过用户级页表来维护地址空间和物理内存之间的映射关系的,而父进程只需要根据 PCB,找到地址空间,通过地址空间这样的窗口找到资源。不论是进程还是地址空间,它都是某种 struct 结构体变量,其中就包含很多属性和属性值。父进程 fork 时,子进程是以父进程为模板,人话就是子进程的大部分属性和属性值是继承父进程的,而小部分是指子进程的调度时间要重置、子进程的 pid、ppid 以及兄弟的要重置。其中上面的 PCB、地址空间、页表都在内核里由操作系统维护的,这也就意味着我们只需要调用操作系统提供的接口 fork,而具体工作细节由操作系统完成。
进程调用 fork 时,控制逻辑就由用户层转移至内核,内核做:
- 分配新的内存块和内核数据结构给子进程。
- 将父进程部分数据结构内容拷贝至子进程。
- 添加子进程到系统进程列表当中。
- fork 返回,调度器调度。
💦 fork函数的返回值
如下代码运行后共创建了多少个子进程,它的之间的关系是啥 ❓
💨 运行后:
当我们看到这样的结果时,也不要奇怪,这是由调度器决定的。这里 4407 fork 了 4409 和 4408,此时 4407 第 1 次 fork 的进程 4408 还要在 fork 4410。
这里共创建了 4 个子进程,其中 2714 fork 之后,创建了 2715 进程,最后 2714 和 2715 会再 fork 2716 和 2717。这里就算是 2717 进程,对于 test.c 中所有的代码都是共享的,只不过不会执行它以上的代码,其中 2717 进程是通过程序计数器 epi 指针知道自己该执行哪行代码的。
一般我们不会让父子进程做同样的事 ❓
💨 运行后:
结合《Linux进程概念——上》至现在的认识,我们知道 fork 是一个系统函数,其中它会完成创建 pcb,生成 pid、创建地址空间、创建页表、构建映射关系、将子进程的 pcb 链入调度队列、返回 pid 等工作,在返回之前,这些工作看起来是由父进程完成的,我们曾经说过函数在返回时,函数的主要逻辑已经执行完了。
父进程的 pid 是 29459,子进程的 pid 是 29460。子进程的 pid 并不是由父进程给予的,包括父进程的 pid 也不是父进程的父进程给予的,而是由操作系统给予的。也就是说进程的创建看起来是由父进程创建的,但其实并不是,而是父进程通过调用 fork 函数开始了创建新进程的过程,本质任何进程的创建还是要由操作系统去完成的。
我们根据 fork 的返回值,来执行不同的逻辑流。从这里我们需要回答两个问题:
为啥 fork 同时有两个返回值和用于接收 fork 返回值的 ret 变量是怎么做到 ret == 0 && ret > 0 ???
子进程创建之后,父子进程是共享代码的,我们认定 return 是代码,是和父子进程共享的代码,所以当我们父进程 return 时,这里的子进程也要 return,所以说这里的父子进程会 return 2 个值。
这里 pid_t ret = fork(),父进程调用 fork,在 return 时,子进程已经创建出来了,那么父进程就 return 子进程的 pid 来初始化 ret 局部变量,随后子进程就 return 0 ,此时必定是通过写时拷贝来完成数据的各自私有,虽然父子进程的 &ret 是一样的,但是物理内存一定是两块不同的空间。 当我们理解了为啥同一个变量,却可以是两个不同的值后,再看 fork 为啥会有两个返回值时就有了新的理解角度。
注意不是 fork 创建子进程,并写时拷贝,而是 fork 创建子进程之后,父子谁先写入谁就写时拷贝,这里发生写时拷贝的原因是父子进程 return 的值用于初始化局部变量 ret 了。
角度一 (好理解,因为不用理解写时拷贝):父子进程会使 fork return 2 个值。
角度二 (较为准确):返回时发生了写时拷贝。
最后我们就可以明确了写时拷贝的价值就是保证父子进程的独立性。
💦 写时拷贝
写时拷贝是一种机制或者策略,好比打仗时的敌退我打,敌进我撤,它根据实时情况来完成既定规则。同理写时拷贝是根据父和子谁先写入的实时情况来完成拷贝的,它是一种延时操作的策略。
通常,父子代码共享,父子不写入时,数据也是共享的,且它们都是只读的,当任意一方试图写入,一般情况下程序就会报错终止了 (这里的报错是系统层面的,但因为这里是父子关系,操作系统就需要做拦截工作),所以操作系统便以写时拷贝的方式生成一份副本于内存,修改页表的映射关系,并且更改权限为可读可写。具体见下图:
这里要强调的是这里的写时拷贝是针对数据的写时拷贝,这里留一个疑问 —— 代码会发生类似的写时拷贝的问题吗 ❓
答案是会的,在下面的进程替换会说明。
为什么存在写时拷贝 ❓
-
写时拷贝是为了保证父子进程的独立性。
-
节省内存和系统资源,提高 fork 的效率,减少 fork 失败的概率。
父子进程创建时,所有数据直接各自拷贝一份不行吗 ???
很明显,不使用写时拷贝也可以保证父子进程的独立性,为啥还要费劲使用写时拷贝。其根本原因是 a) 所有的数据,父和子并不是都必须写入数据,有可能它们仅仅需要读取,而此时的各自拷贝是没有意义的,而且会浪费内存和系统资源。b) fork 时,创建数据结构,如果还要将数据拷贝一份,那么 fork 的效率一定会降低。c) fork 本质就是向系统申请更多的内存资源,资源申请多了,fork 有可能就会失败。
💦 fork常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求,这个会在《Linux 网络编程》中学习。
- 一个进程要执行一个不同的程序。例如子进程从 fork 返回后,调用 exec 函数,这个会在本文中学习。
💦 fork调用失败的原因
fork 是操作系统的接口,所以失败的原因一定是系统级别的原因。
- 系统中已经存在太多的进程了。
- 实际用户创建的进程超过了限制。
- 点赞
- 收藏
- 关注作者
评论(0)