〇、要了解fork首先要了解一下进程控制进程控制一共分为五个话题序号话题内容1进程创建fork / vfork2进程终止exit、退出码3进程等待wait / waitpid4进程程序替换exec 系列函数5自定义 shell 命令与计时器手写 shell串起前四个话题前四个话题合起来就是一个进程从生到死的全过程。一、fork 基础1.1 fork 是什么fork 是一个系统调用作用是创建子进程头文件unistd.h没有参数只有返回值#includeunistd.hpid_tfork(void);1.2 返回值规则重点情况返回值给谁创建成功子进程的 PID 0父进程创建成功0子进程创建失败-1父进程一个函数两次返回两个不同的值—— 这是 fork 最反直觉的地方原理见第四章。1.3 代码验证fork 之后变成两个执行流#includestdio.h#includeunistd.hintmain(){printf(父进程开始运行, PID: %d\n,getpid());fork();// 不管返回值printf(进程开始执行, PID: %d\n,getpid());return0;}运行结果父进程开始运行, PID: 30382 进程开始执行, PID: 30382 ← 父进程 进程开始执行, PID: 30383 ← 子进程新出现的现象说明fork 之前只有一句话打印一个执行流fork 之后的那句话打印了两次且 PID 不同证明fork 之后一个执行流变成了两个执行流1.4 让父子执行不同的代码块#includestdio.h#includeunistd.h#includestdlib.hintmain(){pid_tidfork();if(id0){perror(fork);exit(1);// 创建失败}elseif(id0){// 子进程走这里while(1){printf(我是子进程, PID: %d, PPID: %d\n,getpid(),getppid());sleep(1);}}else{// 父进程走这里id 0id 就是子进程 PIDwhile(1){printf(我是父进程, PID: %d\n,getpid());sleep(1);}}return0;}运行结果我是父进程, PID: 1620 我是子进程, PID: 1621, PPID: 1620 我是父进程, PID: 1620 我是子进程, PID: 1621, PPID: 1620 ...注意命令行启动的进程它的父进程是bash。分流依据代码是共享的父子都能执行 fork 之后的代码只是各自拿到的id值不同父 0子 0于是进入不同的 if 分支。二、fork 时操作系统做了什么复习公式进程 内核数据结构PCB 代码和数据调用 fork 创建子进程时操作系统要完成四件事1. 分配新的内核数据结构 ├── task_struct 对象PCB ├── mm_struct 对象地址空间 └── vm_area_struct 对象区域描述可能是多组 2. 拷贝父进程的数据结构内容给子进程 ├── 父进程 PCB → 拷给子进程大部分属性相同 ├── 父进程地址空间 → 拷给子进程 └── 父进程页表 → 拷给子进程 3. 修改个别属性不能全一样的地方 ├── PID 要改 ├── PPID 要改 └── 时间片等调度属性可能也要改 4. 将子进程纳入系统管理 ├── 添加到系统进程列表全局链表 └── 按优先级插入调度队列如过期队列一个进程既可以属于全局链表又可以属于某个调度队列 —— 详见 Linux 内核中的链表实现struct list_head。三、为什么子进程从 fork 之后开始执行3.1 两个要点代码整体是共享的子进程理论上能看到父进程的全部代码但 fork 之前的代码已经执行过了子进程被创建时父进程已经跑到 fork 这里了子进程的程序计数器执行位置就在 fork 之后3.2 类比抄简历忘改电话有一个人把你的简历抄了一份改了名字但忘了改电话。此时两份简历都指向同一个人 —— 电话那一栏指向的还是你自己。两份简历 父子两个 PCB大部分内容相同拷贝而来名字改了PID 不同电话没改 代码和数据还是指向同一份结论子进程没有加载新的代码和数据所以默认共享父进程的代码和数据。四、fork 为什么有两个返回值重点难点4.1 共识函数执行到 return核心功能已经完成不管是排序、查找还是二叉树重建 —— 任何函数一旦走到return它的核心工作一定已经做完了。return 只是收尾。4.2 推理链条fork() 内部执行流程 ┌─────────────────────────────┐ │ 1. 创建子进程 PCB │ │ 2. 拷贝父进程数据结构 │ │ 3. 修改 PID/PPID 等属性 │ │ 4. 子进程加入进程列表/调度队列 │ ← 此时子进程已被创建 │ 5. return 返回值 │ 甚至可能已被调度运行了! └─────────────────────────────┘关键三点fork 执行到 return 时子进程已经创建完成甚至已经被调度了return 也是一条语句代码代码是父子共享的—— 所以父进程执行一次 return子进程也执行一次 return所以 return 被执行了两次fork 就有两个返回值。4.3 同一个变量 id 为什么既等于 0 又大于 0pid_tidfork();// 父: id 子PID(0) 子: id 0父子各自的id虚拟地址相同地址空间是拷贝的但页表各自独立映射到不同的物理地址父进程的物理内存里存的是子进程 PID子进程的物理内存里存的是 0详见《虚拟内存》4.4 为什么父进程拿子 PID、子进程返回 0因为父子比例是 1 : N。一个父进程可以有一个或多个孩子 → 父进程必须拿到每个孩子的 PID才能区分和管理不同的子进程一个子进程只有一个父亲 → 子进程不需要通过返回值拿父 PID随时可以getppid()获取返回值设计原因父进程 ← 子PID父有多个孩子需要 PID 来区分子进程 ← 0子只需知道自己创建成功父 PID 用 getppid() 就能拿到五、进程独立性第一性原理5.1 生活验证上课时画图板挂了会影响 CCTalk 上课吗不会抖音挂了会影响刚启动的微信吗不会爱奇艺崩了会引起 VS2022 崩掉吗不会每一款软件启动都是进程 ——一个进程挂掉不会影响另一个进程。5.2 结论进程具有独立性—— 即便是父子进程、兄弟进程一个挂了另一个照样运行。5.3 独立性是怎么保证的层面机制说明PCB 层各自独立父有父的 task_struct子有子的互不相干代码层只读共享代码本身不可修改父子都只能读不会互相影响数据层共享 写时拷贝默认共享谁写就给谁拷贝一份5.4 进程独立性的延伸内核级虚拟机原理进程是独立的进程有自己的代码和数据。如果进程要执行的代码是另一款操作系统呢在 Linux 上同时跑四五个不同的操作系统进程每个操作系统都是 Linux 上的一个普通进程互相隔离这就是内核级虚拟机的原理六、写时拷贝——最深层次讲解6.1 数据层面的问题父进程有一个全局变量quit父进程靠它判断是否退出intquit0;// 父进程: while(!quit) 循环如果子进程能直接修改父进程的quit0 改 1父进程就莫名其妙退出了 →独立性被破坏解决父子数据默认共享但任何一方尝试写入时操作系统在底层自动拷贝一份。父访问老的子访问新的或反之—— 这种技术叫写时拷贝。6.2 实现原理页表权限 缺页中断处理路径第一步fork 时把页表全部改成只读没创建子进程时代码段页表项只读本来就该只读数据段页表项是读写的一旦 fork 创建子进程操作系统把数据段的页表权限也改成只读这个过程对用户完全透明第二步写入时触发错误子进程尝试写入数据时操作系统/MMU 检查写入请求 │ ▼ 1. 地址合法吗 ──── 虚拟地址有映射合法 ✓ │ ▼ 2. 访问的是哪个区域 ──── 拿着虚拟地址对比 │ mm_struct 里的区间 │ (code_start~code_end / data_start~data_end) ▼ 3. 是数据段且映射存在 ──── 合理 ✓ │ ▼ 4. 但权限是只读──→ 触发异常类似缺页中断的处理路径操作系统怎么知道你访问的是代码段还是数据段拿着虚拟地址去 mm_struct 的各区域区间里比对落在代码区间就是代码落在数据区间就是数据。回顾三八线类比区域边界就是 start/end第三步缺页中断处理代码里做判断查页表出错有几种情况统一走中断处理在处理代码里区分出错类型原因处理真缺页中断物理块不在内存里加载/分配物理页真非法访问地址无映射杀掉进程段错误写时拷贝场景数据段 映射存在 权限只读 有人要写执行写时拷贝第四步执行写时拷贝1. 申请新的物理内存页 2. 把旧数据拷贝到新页 3. 修改写入方进程的页表映射 → 指向新物理页 4. 恢复页表权限为读写 5. 重新执行刚才的写入指令6.3 一句话总结实现机制写时拷贝 故意把数据段页表权限设为只读→ 写入时让操作系统出错 →借用缺页中断的处理路径判断出这是写时拷贝场景 → 完成拷贝并修正页表。七、为什么需要写时拷贝反方方案fork 时直接把父进程数据原封不动拷一份给子进程不就独立了吗这个方案有两个致命问题问题一创建子进程太慢父进程数据量特别大时拷贝数据要花很多时间fork 变得非常慢问题二浪费内存空间父进程有 100 个变量子进程可能只修改其中 60 个40 个根本不改不改的 40 个也拷贝一份 → 内存里出现大量重复数据 → 纯浪费写时拷贝的优势写时拷贝是最精细的内存控制—— 子进程只拷贝自己想改的不想改的就不拷。收益说明减少创建子进程的成本fork 时不用拷贝数据创建时间大幅缩短减少不必要的内存浪费按需拷贝只拷真正被写入的页写时拷贝本质上是一种延迟申请技术目的是提高整体内存使用率。呼应虚拟内存里缺页中断的按需分配—— 同样的思想八、核心结论汇总进程控制五大话题创建、终止、等待、程序替换、自定义 shell —— 前四个构成进程从生到死的全过程fork 是系统调用创建子进程无参数fork 三种返回值父收子 PID0、子收 0、失败收 -1fork 之后一个执行流变两个后续代码父子都会执行fork 时 OS 做四件事建内核结构task_struct/mm_struct/vma_struct→ 拷贝父进程数据 → 改 PID/PPID → 加入进程列表和调度队列子进程从 fork 之后执行因为 fork 之前的代码已经执行过了抄简历忘改电话类比return 也是一条语句fork 执行到 return 时子进程已创建甚至已被调度代码共享 →return 被父子各执行一次 → 两个返回值父收子 PID 的原因父子 1:N父要用 PID 区分多个孩子子的父 PID 用 getppid() 随时能拿进程具有独立性一个进程挂了不影响另一个父子兄弟也一样—— 理解进程问题的第一性原理独立性三层保证PCB 各自独立 代码只读共享 数据写时拷贝代码父子共享且只读永远不互相影响数据默认共享写入时才拷贝—— 写时拷贝保证数据独立性写时拷贝的实现fork 时页表全改只读 → 写入触发异常 → 走缺页中断处理路径判断 → 拷贝数据、改映射、恢复读写OS 判断代码段/数据段拿虚拟地址对比 mm_struct 的区域区间start/end写时拷贝两大收益减少 fork 成本时间 减少内存浪费空间写时拷贝本质是延迟申请技术提高整体内存使用率内核级虚拟机原理进程独立 进程执行另一款 OS 的代码 → 一个 OS 上以进程形式跑多个 OS九、常见问题 QAQ1: fork 之后是父进程先执行还是子进程先执行A:不确定。谁先被调度由调度器决定。甚至出现过子进程已被创建甚至已被调度的情况fork 的 return 还没执行完子进程可能已经在跑了。Q2: fork 为什么不用传参数A: 创建子进程不需要用户指定任何东西 —— 拷贝对象就是调用者自己父进程PID 等属性由操作系统分配。Q3: 父进程先退出子进程会挂吗A: 不会。进程具有独立性父进程挂了子进程照样运行子进程会被孤儿进程收养机制接管详见进程相关章节。Q4: 子进程能修改父进程的变量吗A: 不能。虽然数据初始共享但任何一方写入时都会触发写时拷贝写入方拿到的是自己的新副本另一方不受影响。Q5: 为什么我的代码 fork 之后的 printf 打印了两次之前的只打印一次A: 代码共享但 fork 之前的代码父进程已经执行完了子进程的执行位置就在 fork 之后所以只有 fork 之后的语句会执行两次。Q6: 写时拷贝和缺页中断是什么关系A: 写时拷贝借用了缺页中断的处理路径。两者都表现为页表访问出错 → 操作系统介入在中断处理代码里区分是真缺页、真非法访问、还是写时拷贝场景。