第 4 章 进程、线程与写时复制
本章拆开
fork/clone的省钱机制与线程的内核实现,是把并发模型讲到底层的那一章。
学习目标
读完本章,你应当能够:推导写时复制(copy-on-write, COW)的三步流程,说清页表在哪一步被改、引用计数在哪一步被减;估算 fork 一个 N GB 进程的耗时量级并解释它为什么不由数据量决定;用 clone 的 flags 组合现场"造出"线程或进程,说明哪几个 flag 之间是硬依赖;辨析 fork/vfork/clone/posix_spawn 各自共享什么,指出 vfork 什么条件下会踩坏父进程;给出 exit 的清理顺序,解释僵尸进程为什么"不可回收"、孤儿进程被谁收养;说明双 fork 的必要性,以及 NPTL(Native POSIX Thread Library)的 1:1 模型在 /proc 里长什么样。
前置知识检查
| 你需要知道 | 在哪一节补齐 |
|---|---|
| ELF 段与加载、动态链接 | 见第 2 章 2.2 节;本章 4.5 节只讲"映像被替换时内核释放了什么" |
| 文件描述符表 → 打开文件表 → inode | 见第 7 章 7.1 节;本章只讲 fork/exec 对 fd 的继承与 CLOEXEC |
| 分页、页表、缺页中断的分类与处理路径 | 见第 8 章 8.2、8.3 节;本章只讲 COW 这一类缺页的判定与动作 |
| 用户态/内核态切换与系统调用入口 | 见第 9 章 9.2 节;本章只引用"一次 clone 是一次系统调用" |
4.1 fork 的困境与写时复制的动机
fork 要交出"父进程此刻地址空间的一份副本",直白实现是复制全部页:分配同样多的物理页、逐页拷贝、再建一套页表。两个原因让它不可接受:绝大多数 fork 之后紧跟一次 exec(system()、shell 执行命令、容器运行时拉起子进程都是这个模式),刚复制出的地址空间会被整体丢弃;而且子进程只读不写时,复制本可用共享代替。于是内核把复制推迟到"真的写"的那一刻,这就是 COW。它的前提是硬件给出两个能力:页表项(page table entry, PTE)的每一位都是权限位,以及写一个只读页会触发缺页异常。
重要
COW 的准确表述是三层:① fork 只复制页表、不复制数据页;② 父子两套页表指向同一批物理页,并把这批映射标成只读;③ 任一方写这些页时触发缺页,内核才为写方复制一个私有副本。 只答"用到才分配"不算过关,因为漏掉了"靠只读权限位拦截写"这个机制核心。
4.2 COW 的完整机制
4.2.1 父子页表共享物理页
fork 完成后父子各有一套完整页表,但两套页表里指向**同一个物理页框(page frame)**的 PTE 是成对存在的:
| |
判据是 _mapcount(映射数)而非 _refcount(总引用数):内核临时引用或页缓存持有都会抬高 _refcount,却不该触发一次无意义的复制。
4.2.2 写入触发缺页:三步流程
任一方第一次写这些页时,MMU 发现 PTE 有效但 W=0 而本次是写 → 保护违例 → 缺页处理程序接管:
| |
警告
三个高频错点。第一,“把页标成只读"是改 PTE 的 W 位,不是给页加锁,答"内核给页加了读锁"是错的。第二,父进程不承担复制开销,成本与"谁写了多少页"严格相关。第三,引用计数分两个(_refcount 与 _mapcount),判据是后者。
注意
面试官会追问的下一层:共享页已被换出时,触发写的一方从 swap 读入一份新副本,复制来源是 swap 而非内存。另一个必答区分是 MAP_SHARED 与 MAP_PRIVATE:前者写不触发 COW、直接改共享页且对其他映射者可见;后者写触发 COW 并脱离页缓存成为匿名页,见第 12 章 12.5 节。
4.3 COW 的收益量化:10 GB 的 Redis 为什么能秒级 fork
fork 的耗时由四项组成,没有一项正比于"数据字节数”:
| |
结论:fork 的时间由"页表规模(映射页数 + VMA 数)“决定,不由"进程占多少 GB"决定,因为数据页一字节都不搬。10 GB 的 Redis 只复制约 21 MB 的 PTE;RDB 场景下子进程只读遍历全量数据再写文件,几乎不触发 COW,真实复制量接近零——这正是 Redis 用 fork 做 RDB 快照与 AOF 重写的性能基础(见第 27 章 27.4 节)。
表 4-1 fork 的成本项与影响因素
| 成本项 | 量级 | 与什么成正比 | 优化手段 |
|---|---|---|---|
描述符复制(task_struct 等) | 数 μs | 常数 | 无显著优化 |
| VMA 复制 | 数 μs ~ 数百 μs | VMA 个数(地址空间碎片化程度) | 用大块 mmap 替代大量小块分配,避免频繁 munmap |
| 页表复制(PTE + 上层页表页) | 数十 μs ~ 数 ms | 已映射页数、上层页表页数 | 大页把 512 条 PTE 合成 1 条 |
| 引用计数递增 | 与页表同量级 | 已映射页数 | 不可避免 |
| 数据页复制 | 0(被推迟) | 无 | —— |
| 后续 COW 缺页 | 每次约 1 ~ 2 μs | fork 之后实际被写的页数 | 降低 fork 后的写流量 |
大页的收益可以算清:4 KB 页下 10 GB 要 2.62 M 条 PTE;改用 2 MB 大页只需 10 GB ÷ 2 MB = 5120 条,PTE 从 21 MB 降到 40 KB,页表遍历次数按 512 倍下降。代价是 COW 粒度变成 2 MB——透明大页(transparent huge page, THP)会放大 fork 延迟与写放大,所以生产上通常关闭 THP。
小心
把 fork 答成"复制一份内存,所以内存越大越慢"会在追问下崩掉。面试官紧接着会问"那 10 GB 的 Redis,fork 要复制 10 GB 吗”,而你前面的话已经把自己堵死。正确说法是:fork 复制的是页表(约每 4 KB 页 8 字节,即内存的 0.2%),数据页靠 COW 懒复制;fork 本身是毫秒级,COW 的代价取决于 fork 之后父子双方各写了多少页。
4.4 fork / vfork / clone / posix_spawn
四者不并列:fork 与 pthread_create 都是库函数,底层都落到 clone;vfork 与 posix_spawn 是两种"为绕过页表复制而存在"的变体。
表 4-2 四种创建方式的对比
| 维度 | fork | vfork | clone | posix_spawn |
|---|---|---|---|---|
| 地址空间 | 复制页表 + COW | 共享(父子同一 mm) | 由 CLONE_VM 决定 | 默认共享(内部用 CLONE_VM) |
| 父进程是否阻塞 | 否 | 是,挂起直到子进程 exec/_exit | 由 CLONE_VFORK 决定 | 是(继承 vfork 语义) |
| 子进程如何启动 | 从 fork 返回处继续 | 从 fork 返回处继续 | 从 fn(arg) 开始 | 直接 exec,不回到用户回调 |
| 子进程可做的操作 | 任意(多线程下须限异步信号安全函数) | 仅 exec 族或 _exit | 任意,由调用者保证 | 由实现保证 |
| 主要收益 | 语义最通用 | 省掉页表复制 | 资源共享粒度完全可控 | 多线程进程里安全地 fork+exec |
| 主要风险 | 大内存 + 大写入量下的 COW 放大 | 子进程碰了父进程的栈就毁掉父进程 | flags 组合错误 → EINVAL | file actions 表达能力有限 |
vfork 的危险要讲机制:父子共享同一地址空间且父进程被挂起,子进程若修改栈变量或调用写全局状态的函数,父进程恢复后看到的是被改过的内存。POSIX 只允许子进程调用 exec 族或 _exit,正因为这两个动作不会改写父进程有意义的状态。glibc 把 vfork 实现为 clone(CLONE_VM | CLONE_VFORK | SIGCHLD),而 fork 也早已不是"复制内存",所以 vfork 的相对优势只剩"不复制页表"。
表 4-3 clone 关键 flags:共享与硬依赖
| flag | 共享的资源 | 不设该 flag 时 | 备注 |
|---|---|---|---|
CLONE_VM | 地址空间(mm_struct) | 子任务有独立地址空间 | 线程的第一必选项 |
CLONE_FS | 根目录、当前工作目录、umask | 各自一份 fs_struct | 不共享时 chdir 互不影响 |
CLONE_FILES | 文件描述符表 | 各自一份 fd 表(fork 的默认) | 共享时一个线程 close 让全体失效 |
CLONE_SIGHAND | 信号处理函数表 | 各自独立 | 隐含要求 CLONE_VM |
CLONE_THREAD | 放进同一线程组(同 tgid) | 新任务自成进程 | 隐含 CLONE_SIGHAND,后者隐含 CLONE_VM |
CLONE_VFORK | —— | 父进程不阻塞 | 父进程等到子进程 exec/退出 |
SIGCHLD | —— | 父进程 wait 不到子进程 | 与 CLONE_THREAD 互斥(线程不参与 wait) |
CLONE_NEW* | 否,隔离命名空间 | 共享初始命名空间 | 容器隔离入口,见第 10 章 10.5 节 |
| |
posix_spawn 解决一个非常具体的问题:多线程进程里 fork 后到 exec 前只能调用异步信号安全(async-signal-safe)函数——因为 fork 只复制调用线程,若另一线程此刻正持有 malloc 的锁,子进程再调 malloc 就永久死锁。posix_spawn 把"设置/重定向/关闭 fd + exec“下移给 libc 与内核,中间不回到用户的任意代码,因此在 Linux 上以 clone(CLONE_VM | CLONE_VFORK) + exec 实现,避免复制父进程页表。
提示
记忆锚点:把 clone 的 flags 看成"合并开关清单”——CLONE_VM 合地址空间、CLONE_FILES 合 fd 表、CLONE_SIGHAND 合信号表、CLONE_THREAD 合线程组。五个全开就是 pthread_create,全关(再加 SIGCHLD)就是 fork。
注意
面试官会追问的下一层:posix_spawn 一定比 fork + exec 快吗? 不一定。它的优势只在"父进程页表很大"且"父进程是多线程"时才明显;页表很小时差异可忽略,而它的 file actions 表达能力有限,有时反而要多写代码。
4.5 exec 家族与 fork + exec 的内存账
4.5.1 六个函数的差异速查
exec 不是系统调用而是一族库函数,唯一的系统调用是 execve。后缀可组合记忆:l = 参数列表(list,逐个给,必须以 NULL 结尾),v = 参数向量(vector),p = 用 PATH 搜索,e = 显式提供环境变量(environment),f = 直接给可执行文件。exec 成功时永不返回(映像被替换,原调用栈不复存在),失败返回 -1 并置 errno、旧映像完好——这个不对称性决定了 exec 之后不需要写返回值检查再继续的分支。
表 4-4 exec 家族速查
| 函数 | 参数形态 | 搜 PATH | 环境变量来源 | 最易踩的坑 |
|---|---|---|---|---|
execl | execl(path, arg0, ..., NULL) | 否 | 继承 environ | 变量参数漏写 NULL → 读到栈上垃圾,随机崩溃 |
execv | execv(path, argv[]) | 否 | 继承 environ | argv 必须以 NULL 收尾,否则同样越界 |
execle | execle(path, arg0, ..., NULL, envp[]) | 否 | 显式 envp | 名字里最早出现的是 arg0 不是 envp,排序反直觉 |
execve | execve(path, argv[], envp[]) | 否 | 显式 envp | 唯一系统调用;两个数组都需自行构造 |
execlp | execlp(file, arg0, ..., NULL) | 是 | 继承 environ | 参数含 / 时按路径处理,不再搜 PATH |
execvp | execvp(file, argv[]) | 是 | 继承 environ | PATH 含空串或 . 时会在当前目录找可执行文件(CWE-426/427 类问题来源) |
4.5.2 fork + exec 的内存账
| |
"exec 为什么不泄漏内存"的准确回答:内核不是逐块 free 旧内存,而是把整个地址空间描述符(mm_struct)连同其全部 VMA 一起解除映射并释放——释放单位是 VMA 与页表树,不依赖旧进程的正确性,也不给用户态留下"忘记释放"的机会。反过来,exec 失败时不做任何清理,这正是"失败返回 -1、旧映像完好"的实现基础。
重要
fork 后 exec 前只允许调用异步信号安全函数,这是正确性约束而非风格建议。多线程程序里要拉起子进程,优先用 posix_spawn,或 fork 后立刻 exec 且中间只调用 exec 本身。
4.6 进程退出与回收
4.6.1 exit 的清理顺序
exit() 与 _exit() 的差别是高频考点,答案必须是有序两段:
| |
4.6.2 僵尸为什么不可回收
进程进入 Z 后仍占用一个 PID 与一份 task_struct,ps 显示 <defunct>。它存在的唯一理由是:退出状态必须有结构体存放,直到有人来读。由此推出三条结论:僵尸不能做任何事(没有可运行代码与用户态栈,调度器永不选中它,“自己回收自己"在语义上不成立——回收是一次父进程或内核发起的读操作);kill -9 无效(它已经终止了,信号只能投递给进程,僵尸不接收);清僵尸的唯一办法是父进程 wait,或父进程自己退出让子进程被 init 收养并回收。
表 4-5 僵尸、孤儿与常见误解
| 概念 | 成因 | 谁负责回收 | 关键结论 |
|---|---|---|---|
僵尸进程(zombie, Z) | 子进程已退出、父进程尚未 wait | 父进程 | 占 PID 与内核结构体,不占数据页、不耗 CPU;kill -9 无用 |
| 孤儿进程(orphan) | 父进程先退出 | init(PID 1)或最近祖先 subreaper | 被收养后由 init 定期 wait,不会变成长期僵尸 |
| 僵尸堆积 | 父进程长跑且从不回收 | 需修父进程代码 | 耗尽 PID 表,fork 开始返回 EAGAIN/ENOMEM |
4.6.3 SIGCHLD 的默认行为与忽略它的效果
SIGCHLD 默认动作是忽略,但与显式设置 SIG_IGN 效果不同,这是高频陷阱:
表 4-6 SIGCHLD 三种处置方式
| 处置方式 | 子进程退出后的状态 | wait 的返回值 | 能否拿到退出码 |
|---|---|---|---|
默认(SIG_DFL) | 保持僵尸直到被 wait | 正常回收并返回 pid | 能 |
SIG_IGN 或 SA_NOCLDWAIT | 内核直接销毁,不产生僵尸 | 失败,errno = ECHILD | 不能,退出码永久丢失 |
| 安装 handler | 保持僵尸,同时收到信号 | 在 handler 或主流程中回收 | 能 |
POSIX 对 SIG_IGN 情形留了实现余地(wait 可能返回 ECHILD,也可能返回 pid 且状态为 0),可移植代码不能依赖单一行为;另一点常被忽略:默认动作虽是忽略,SIGCHLD 仍会打断阻塞的系统调用(EINTR),除非装了带 SA_RESTART 的 handler。
| |
4.7 进程组、会话与控制终端:双 fork 的原理
setsid 成功的条件是调用者不是进程组组长(“会话 ID 尚不存在"自动满足),因此双 fork 的必要性完全来自第一个条件:
| |
小心
只 fork 一次然后 setsid 是错的,而且常常"看起来能跑”:交互式 shell 里直接执行 ./svc 时原进程通常就是组长,fork 出的子进程不是组长,一次 fork 恰好够用;但原进程本身不是组长时,一次 fork 出的子进程会继承组长身份,setsid 直接返回 EPERM。双 fork 的价值就是保证与调用环境无关——它的必要性来自"你不能假设自己不是组长”。答"为了保险"不会过关。
4.8 线程的内核实现:NPTL 的 1:1 模型
Linux 内核不区分"进程"与"线程",它只调度任务(task),两者差别完全由 clone 时共享了哪些资源决定(回顾表 4-3):NPTL 自 glibc 2.3.2 起为默认实现,采用 1:1 模型,每个 pthread_t 对应一个内核调度实体,即轻量级进程(light-weight process, LWP),没有用户态调度器,因此阻塞式系统调用只阻塞该线程;/proc/[pid]/task/ 暴露线程组内每个任务,目录名是各任务的 TID,ps -T -p <pid>、top -H 都能看到。关键细节:getpid() 在线程里返回 tgid(线程组 ID,等于主线程 TID),gettid() 返回本线程 TID,同一进程的多个线程在 /proc 里是"一个进程目录 + 多个 task 子目录"。
表 4-7 线程共享与不共享的资源
| 资源 | 是否共享 | 内核依据 | 工程含义 |
|---|---|---|---|
| 地址空间(堆、全局变量、代码段、映射) | 共享 | CLONE_VM | 一个线程的野指针会破坏其他线程的数据(见第 6 章 6.1 节) |
| 文件描述符表 | 共享 | CLONE_FILES | 一个线程 close(fd) 影响整个进程,隐蔽的线上 bug 来源 |
| 信号处理函数表 | 共享 | CLONE_SIGHAND | 注册 handler 是进程级行为;CLONE_THREAD 强制要求它 |
工作目录 / 根目录 / umask | 共享 | CLONE_FS | chdir 影响所有线程 |
进程级属性(PID、rlimit、cgroup、oom_score_adj) | 共享 | 同属一个线程组 | 无法"只给某个线程"调 setrlimit |
| 已打开文件的文件偏移 | 共享 | fd 表里的 struct file 是共享对象 | 与第 7 章 7.1 节的三层结构对齐 |
| 栈(用户栈 + 内核栈) | 不共享 | 每任务独立 | 栈溢出只毁自己 |
寄存器上下文、errno、TLS | 不共享 | 每任务独立 | errno 靠 TLS 实现线程私有 |
| 信号屏蔽字、nice / 调度策略 | 不共享 | —— | 见下方提示;线程可各自绑核(见第 5 章 5.4 节) |
注意
面试官会追问的下一层:Linux 的信号屏蔽字到底共不共享? POSIX 要求 pthread_sigmask 只影响本线程,但 Linux 内核因历史原因让 CLONE_THREAD 的任务共享信号屏蔽字;glibc 通过在用户态为每个线程维护私有 sigmask 并适时同步来模拟 POSIX 语义,于是两者之间会出现难以理解的行为差异——这正是"多线程 + 信号"被普遍建议避开的原因:所有信号只在主线程或专门的信号线程接收,业务线程一律屏蔽。
| |
警告
说"线程比进程轻所以线程栈更小"是错的:线程栈大小是 libc 的策略(默认 8 MB),与内核无关;pthread_create 同样要进内核分配 task 与内核栈。判断"线程创建更快"的依据不是"栈更小",而是"不复制页表、不复制 task_struct 的大部分用户态资源"。
4.9 pthread_create 的开销构成与线程池的存在理由
表 4-8 pthread_create 的开销构成
| 阶段 | 做了什么 | 相对代价 | 线程池是否消除 |
|---|---|---|---|
| ① 用户栈分配 | mmap 8 MB 区域 + guard page,建立 VMA | 一次系统调用 + VMA 管理 | 是(栈复用) |
② clone 系统调用 | 建立 task_struct、内核栈,共享资源引用计数递增 | 一次系统调用 + 常数级复制 | 是 |
| ③ 描述符与 TLS 建立 | pthread 结构、TLS 块、DTV 初始化 | 若干次内存写 | 是(ThreadLocal 值需自行清理) |
| ④ 同步原语初始化 | 线程内部锁/条件变量、join 用的 futex 状态 | 常数 | 是 |
| ⑤ 首次运行的缓存/TLB 冷缺失 | 新栈、新 TLS 都要占页并冷启动 | 取决于栈触碰量 | 部分:线程仍在,但栈与 TLS 已热 |
| ⑥ 调度安置 | 新任务入运行队列,可能唤醒空闲核 | 微秒量级 | 是(已有线程直接取任务) |
量级对比(建立直觉用):fork 一个中等规模进程约 100 μs ~ 1 ms(由页表规模决定),pthread_create 约 10 ~ 30 μs,线程池内任务派发(入队 + 唤醒)约 1 ~ 5 μs,线程上下文切换约 1 ~ 5 μs(同进程内线程切换不需换页表,是较便宜的一端;跨进程切换要换 mm 并冲刷部分 TLB 而更贵,见第 5 章 5.5 节)。
线程池的收益来自三处叠加:摊掉 ①②③④ 的创建成本;把 ⑤ 的冷缺失提前到池预热阶段;用固定线程数把并发度变成可调容量参数,避免"每请求一线程"在尖峰时创建出成千上万线程、把内存与调度开销一起打爆。队列与拒绝策略的选择见第 31 章 31.1、31.2 节。
提示
面试话术:被问"为什么用线程池",不要只答"复用线程"。完整答案是三句:① 摊薄创建成本(用户栈 mmap + clone + TLS/描述符初始化);② 控制并发上限,把资源占用从"随请求数增长"变成"随池容量增长";③ 复用热栈与热 TLS,减少冷启动缺页与缓存缺失。 第三点是多数人漏掉的加分项。
本章小结
| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
fork 只复制页表不复制数据 | 成本正比于映射页数与 VMA 数,与数据量无关 | 说"内存越大 fork 越慢" |
| COW 靠 PTE 的只读位拦截写 | 缺页 → 判定 _mapcount → 复制 → 改 PTE → 刷 TLB | 说"给页加读锁",或只提一个引用计数 |
| 谁写谁付钱 | 只有写入方复制,父进程 PTE 不受影响 | 认为父子各复制一半,或复制发生在 fork 时刻 |
| 大页降低 fork 成本但放大 COW 粒度 | PTE 数按 512 倍下降,COW 粒度 4 KB → 2 MB | 认为开 THP 一定更快 |
clone 的 flags 决定资源共享 | 五个全开是线程,全关是进程;CLONE_THREAD 隐含 CLONE_SIGHAND | 说"线程是内核里的另一种对象" |
exec 靠整体拆建地址空间 | 释放单位是 VMA 与页表树;失败则不清理 | 说"exec 逐块 free 旧内存" |
exit 的清理顺序不可交换 | atexit(LIFO)→ stdio flush → 关 fd → _exit | 把 _exit 与 exit 当同义词 |
| 僵尸存在的理由是保存退出码 | 它没有执行能力,kill -9 无效,只能父进程 wait | 说"僵尸占大量内存/CPU" |
SIGCHLD 默认忽略但语义有别 | SIG_IGN/SA_NOCLDWAIT 不产生僵尸,代价是丢掉退出码 | 认为"默认忽略就不产生僵尸" |
| 双 fork 不是习惯而是必然 | 第二次 fork 保证 setsid 时不是进程组组长 | 答"为了保险" |
自测题
Q1(计算)x86-64 机器上一个 10 GB Redis 进程(数据页全部驻留,4 KB 普通页)执行 fork 做 RDB 快照。估算要复制的 PTE 数量与页表内存量,判断耗时量级;若 RDB 期间父进程写了 1.2 GB,估算 COW 带来的额外物理内存。
答案与解析
**页表**:页数 `= 10 × 2³⁰ ÷ 2¹² = 2 621 440`,约 **2.62 M 页**;PTE 每条 8 B,故 PTE 本身 `≈ 21 MB`;上层页表按"每个 PTE 页覆盖 2 MB、每个 PMD 页覆盖 1 GB"算,至少 5120 个 PTE 页 + 10 个 PMD 页。**结论:`fork` 新建页表约 20 ~ 40 MB,耗时毫秒级**,而"搬 10 GB 数据"按 10 GB/s 也要约 1 s。**COW 额外内存**:写 1.2 GB ≈ 314 573 页被修改,每页复制 4 KB,额外物理内存约 **1.2 GB**;若开 THP,粒度变 2 MB,改一字节也要复制 2 MB,写放大成倍上升。 **错在哪**:答"复制 10 GB,约 1 秒"把数据页与页表混为一谈;只算 PTE 不算上层页表偏低;认为"COW 代价在 fork 时就确定",实际由 fork 之后双方的写入量决定。**追问**:①"稀疏使用时?"——从未触碰的页没有页框,也就没有 PTE 要复制。②"为什么建议关 THP?"——COW 粒度放大 512 倍。③"Redis 还怎么绕开 COW?"——降低快照期间写入量、把持久化放从库,见第 27 章 27.4 节。Q2(推导)写出从"子进程对某个共享只读页执行一次写"到"写成功返回用户态"的完整步骤。父进程的页表在这一过程中被修改了吗?若父进程此刻已退出,这次写还会复制吗?
答案与解析
**步骤**:① MMU 翻译该地址,PTE `P=1` 但 `W=0`,本次是写 → 保护违例抛 `#PF`;② 用 error code 判断这是"保护违例"而非"页不存在",再查所属 VMA 是否允许写;③ 取到 `struct page`,确认可 COW 并检查 `_mapcount`;④ 若 `_mapcount > 0`,分配新页并 `memcpy` 旧页内容;⑤ 更新**子进程自己的 PTE**(页框号指向新页、`W` 置 1),旧页 `_mapcount`/`_refcount` 各减 1,新页计数置 1;⑥ 失效该地址的 TLB 表项;⑦ 返回用户态重执行写指令。**父进程页表完全不被修改**:共享的是它指向的物理页框,父子各持独立 PTE。**父进程已退出时不复制**:该页 `_mapcount` 减到 0,只剩一个映射者,内核走**原地加速路径**,只把 `W` 位置 1。 **错在哪**:把 ⑤ 说成"父子 PTE 一起改成可写"会破坏 COW 的隔离语义;把 ②③ 顺序写反也不对。**追问**:①"TLB 为什么必须失效?"——否则 MMU 继续用缓存的只读表项,同一条写指令反复缺页抖动。②"哪个计数才是判据?"——`_mapcount`,临时内核引用会抬高 `_refcount` 却不应触发复制。③"`MAP_SHARED` 呢?"——不触发 COW,写落在页缓存共享页上。Q3(辨析)"vfork 比 fork 快,所以拉起子进程应该用 vfork。“这句话对吗?给出 vfork 踩坏父进程的具体场景,并说明现代代码更该用什么。
答案与解析
**不严谨**。`vfork` 的收益只有一个来源——**不复制页表**(父子共享同一 `mm_struct`),代价是父进程被挂起直到子进程 `exec` 或 `_exit`;父进程页表很小时这点收益可忽略,而它的硬约束极易违反。**踩坏父进程的场景**:父子共享地址空间,子进程用的是**父进程的栈**;若子进程在 `exec` 前执行 `return` 或调用会写栈/全局状态的函数(`printf` 写 `stdout` 缓冲区与锁、`strcpy` 写到父进程的栈数组、`malloc`/`free`),父进程恢复后看到的是被改写的内存——栈帧损坏、`stdout` 缓冲区被改、锁状态错乱。POSIX 因此只允许子进程调用 `exec` 族或 `_exit`;另一个副作用是父进程挂起**改变了父子相对执行顺序**。**现代代码应优先用**:单线程进程直接 `fork` + `exec`;多线程进程用 `posix_spawn`,它在 `fork` 与 `exec` 之间不回到用户代码,规避了异步信号安全约束,同时以 `clone(CLONE_VM | CLONE_VFORK)` 实现,也不复制父进程页表。 **错在哪**:把"快"当无条件结论,忽略"收益与父进程页表大小成正比、约束却是硬性的";也有回答把 `vfork` 说成"复制页表但更快",正好说反。**追问**:①"`fork` 后 `exec` 前为什么不能调 `malloc`?"——`fork` 只复制调用线程,另一线程可能持 `malloc` 锁,子进程内该锁永远无人释放,直接死锁。②"`posix_spawn` 一定更快吗?"——只在父进程页表大且多线程时明显。③"`vfork` 的挂起有什么副作用?"——改变父子相对执行顺序,掩盖或引入竞态。Q4(分析)某服务每收到一个请求就 fork 一个子进程处理。运行数日后 ps 显示数千个 <defunct>,并开始出现 fork 失败。父进程装了 SIGCHLD handler,内容是一句 wait(NULL);。给出根因、修法,并说明把 SIGCHLD 设成 SIG_IGN 为什么能消除僵尸以及代价是什么。
答案与解析
**根因**:僵尸源于"子进程已退出但父进程没有 `wait`"。多个子进程几乎同时退出时,**标准信号不排队**——同类信号在未决位图上合并,内核只投递一次 `SIGCHLD`,handler 里只 `wait` 一次就只能收一个孩子,其余全留在僵尸态;数日后僵尸堆积耗尽 PID 表,`fork` 返回 `EAGAIN`/`ENOMEM`。僵尸**不占数据页也不耗 CPU**,"变慢"的真实原因是无法创建新进程。**修法**:① 把 `SIGCHLD` 处置设为 `SIG_IGN`(或带 `SA_NOCLDWAIT`),内核在子进程退出时直接销毁,不产生僵尸,代价是**永远拿不到退出码**;② 用 `while` + `WNOHANG` 反复回收,直到返回 0 或 -1 且 `errno == ECHILD`,并保存/恢复 `errno`、处理 `EINTR`;③ 用 `signalfd` 或专设回收线程。 **`SIG_IGN` 的代价**:砍掉"子进程退出码"这个信息源,因此不能判断子进程成功/失败、也不能区分"正常退出/被信号杀死",还不能配合 `waitpid` 做超时回收;可移植性上要容忍 POSIX 允许的替代行为(返回 pid 且状态为 0)。另外 `SIGCHLD` 默认动作虽是忽略,仍会打断阻塞调用(`EINTR`),与显式 `SIG_IGN` 的"不产生僵尸"是两回事。 **错在哪**:答"僵尸占内存所以要定期重启服务"治标且误解病因;答"用 `kill -9` 清僵尸"根本无效;答"`SIGCHLD` 默认忽略所以不会产生僵尸"混淆了 `SIG_DFL` 与 `SIG_IGN`。**追问**:①"僵尸占多少内存?"——不占数据页,只占一个 `task_struct` 与 PID(KB 级),真正危害是 PID 表耗尽。②"还有什么情况会产生长期僵尸?"——容器里 PID 1 没有正确实现回收语义时,被收养的孤儿无人回收,见第 10 章 10.1 节。Q5(设计)把前台服务改造成守护进程:脱离终端(终端关闭不被 SIGHUP 杀掉)、不占用启动目录、日志可控。写出完整启动步骤并说明每一步的"为什么”,重点解释为什么必须 fork 两次、以及第二次 fork 之后为什么再也不会获得控制终端。
答案与解析
**步骤与理由**:① **fork 一次,父进程 `_exit(0)`**——让 shell 立刻拿回控制权,子进程成为孤儿、父变为 init。② **`setsid()`**——创建新会话与新进程组,脱离原控制终端;**但它要求调用者不是进程组组长**,而一次 fork 出的子进程是否组长取决于原进程的组身份,**不能保证**。③ **再 fork 一次,中间进程也 `_exit(0)`**——孙进程 pid 是新号、pgid 继承自中间进程,**必然不是组长**,`setsid` 一定成功;代价是写 pid 文件与 `flock` 单实例检查都要放到这一步之后。④ **`chdir("/")`**——否则持有启动目录引用,该挂载点无法卸载。⑤ **`umask(0)`**——文件权限按守护进程自己的规则,不继承启动者 shell。⑥ **关闭或重定向 0/1/2 到 `/dev/null`(或日志文件)**——避免写向已消失的终端收到 `EIO`。⑦ 处理 `SIGCHLD`、必要时忽略 `SIGHUP`、写 pid 文件。**为什么不再获得控制终端**:控制终端只能由**会话首进程**通过打开一个尚未属于其他会话的 tty 取得;`setsid` 后该进程已脱离原终端且不再持有任何 tty,此后它不再打开 tty(或带 `O_NOCTTY`),终端事件(`SIGHUP`/`SIGINT`/作业控制)永远影响不到它。 **错在哪**:只 fork 一次就 `setsid`——原进程是组长时能跑通、不是组长时返回 `EPERM`,于是"测试环境正常、生产启动失败",把必然性当成了习惯。其他常见错误:忘记 `chdir("/")`(挂载点无法卸载)、不重置 `umask`(文件权限随启动方式漂移)、日志仍指向终端(静默失败无从排查)。**追问**:①"systemd 时代还要 double fork 吗?"——通常不需要,由 systemd 托管并管理生命周期与日志。②"孙进程的父是谁?"——init 或最近的 subreaper。③"pid 文件什么时候写?"——必须在第二次 fork 之后,且应是"打开 + `flock` + 写 pid"的原子组合,否则并发启动会互相覆盖。面试高频问题与陷阱
| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应小节 |
|---|---|---|---|
fork 一个 10 GB 的 Redis 进程,为什么毫秒级就完成了? | COW 的成本模型 | 陷阱:答"复制了一份内存所以很快"或"因为 Redis 是内存数据库"。加分:给出"复制页表约 21 MB、数据页零复制"的量化,并指出成本正比于映射页数与 VMA 数 | 4.2 / 4.3 |
| 写时复制具体怎么实现?内核在哪些地方动了手脚? | 缺页路径与页表权限位 | 陷阱:答"用到才分配内存",说不出"把 PTE 的 W 位清零来拦截写"这个核心。加分:讲清 _mapcount 判据、_mapcount == 0 的原地加速路径、TLB 必须失效的原因 | 4.2 |
| COW 时引用计数怎么变?父进程的页表会改吗? | 引用计数与隔离语义 | 陷阱:只提一个计数,或答"父子 PTE 一起改成可写"。加分:区分 _refcount(总引用)与 _mapcount(映射数,COW 判据),强调父进程 PTE 不动 | 4.2 |
| 线程和进程在内核里到底差在哪? | clone 的 flags 与资源共享 | 陷阱:复述"线程是更轻的进程"这类定义。加分:直接给出 CLONE_VM/FS/FILES/SIGHAND/THREAD 共享清单,并说明 CLONE_THREAD 隐含 CLONE_SIGHAND、后者隐含 CLONE_VM | 4.4 / 4.8 |
vfork 和 fork 有什么区别?为什么现在很少用 vfork? | 共享地址空间的风险 | 陷阱:答"vfork 复制得更快"(它根本不复制页表),或说"不安全"却说不出会毁掉父进程的哪种内存。加分:给出子进程 printf/malloc 破坏父进程栈与锁的具体链条 | 4.4 |
fork 之后马上 exec,中间为什么不能调 malloc? | 异步信号安全与多线程 fork | 陷阱:答"因为 malloc 慢"或"因为内存被复制了"。加分:指出 fork 只复制调用线程,另一线程可能正持 malloc 锁且被冻结,子进程取锁即死锁 | 4.4 / 4.5 |
exec 替换旧映像时原来的内存怎么释放?会不会泄漏? | 地址空间的整体拆建 | 陷阱:答"内核一块块 free 旧内存"或"exec 失败也先清空再重建"。加分:说清释放单位是 VMA 与页表树(exit_mmap 遍历 VMA),并指出失败时旧映像完好 | 4.5 |
僵尸进程怎么产生的?kill -9 为什么杀不掉? | 退出码的保存与回收语义 | 陷阱:答"僵尸占大量内存和 CPU"或"僵尸就是没退干净的进程"。加分:说清僵尸只占 task_struct 与 PID、真正危害是 PID 表耗尽,回收本质是父进程的"读"操作 | 4.6 |
我把 SIGCHLD 设成了 SIG_IGN,子进程还会变成僵尸吗? | SIG_DFL 与 SIG_IGN 的语义差异 | 陷阱:答"默认就是忽略所以不会",把 SIG_DFL 与 SIG_IGN 混为一谈;或答"忽略了还能 wait 到退出码"。加分:说明内核直接销毁子进程、wait 返回 ECHILD | 4.6 |
你装了 SIGCHLD handler,为什么线上还是出现大量僵尸? | 标准信号不排队与循环回收 | 陷阱:答"handler 没写对"却给不出机制,或以为"一个子进程一个 SIGCHLD"。加分:指出同类标准信号会被合并、必须 while + WNOHANG 循环回收 | 4.6 |
| 为什么守护进程要 fork 两次?一次不行吗? | 会话、进程组与组长身份 | 陷阱:答"为了保险"或"两次更彻底"。加分:指出 setsid 要求调用者不是进程组组长,双 fork 用"新建进程号"保证该条件必成立 | 4.7 |
| 一个进程起了 1000 个线程,会占多少内存? | 线程栈的分配方式与限制 | 陷阱:答"8 MB × 1000 = 8 GB 物理内存",把虚拟保留当物理占用;或说"线程栈由内核管理且更小"。加分:区分用户栈与内核栈,并给出 guard page 的越界行为 | 4.8 |
| 有了线程池,为什么还要限制最大线程数? | 创建开销与并发上限 | 陷阱:只答"减少创建销毁开销",漏掉"把资源占用从随请求数增长改成随池容量增长"。加分:给出 pthread_create 的开销构成,并说明池还能复用热栈减少冷缺失 | 4.9 |
延伸阅读
man 2 fork、man 2 clone、man 2 execve、man 2 vfork、man 3 posix_spawn、man 7 signal、man 2 setsid:本章系统调用语义的一手依据;clone(2)的 NOTES 一节明确写了CLONE_THREAD与其他 flags 的隐含依赖。- Linux 内核文档
Documentation/admin-guide/mm/transhuge.rst、Documentation/filesystems/proc.rst与man 5 proc:4.3 节大页与 COW 取舍、4.8 节/proc/[pid]/task的依据。 - W. Richard Stevens, Advanced Programming in the UNIX Environment(APUE)第 8 章"进程控制"、第 9 章"进程关系"、第 12 章"线程控制":
fork/exec家族、进程组与会话、atexit顺序、异步信号安全函数清单。 - Robert Love, Linux Kernel Development 第 3、4 章:
task_struct、copy_process、COW 与线程组(tgid)的内核视角;Ulrich Drepper, ELF Handling For Thread-Local Storage 是 4.9 节 TLS/DTV 的 ABI 依据。 - 交叉阅读:虚拟内存与缺页四类型见第 8 章 8.2 ~ 8.5 节;fd 表三层结构与
CLOEXEC见第 7 章 7.1 节;线程池参数与队列行为见第 31 章 31.1、31.2 节;fork在 Redis 持久化中的取舍见第 27 章 27.4 节。