本章拆开 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 是成对存在的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fork 之后:两套页表各自独立,但 PTE 指向同一物理页、且均为只读
  父页表 PTE@VA 0x7f2a...1000        子页表 PTE@VA 0x7f2a...1000
    PFN = 0x1A3C0  P=1  W=0  U=1       PFN = 0x1A3C0  P=1  W=0  U=1
            └──────────────┬──────────────┘
                           ▼
              ┌──────────────────────────────┐
              │ 物理页框 0x1A3C0(只有一份数据)│
              │   _refcount = 2  总引用数     │                │   _mapcount = 2  被几个页表映射│  ← COW 判据
              └──────────────────────────────┘
  权限位:bit0 P present 映射有效;bit1 W writable 可写 —— COW 拦截写的全部依据就是把它清 0。
  ★ 父子共享的是"它指向的页框",不是 PTE 本身:改一方可写不必动另一方。

判据是 _mapcount(映射数)而非 _refcount(总引用数):内核临时引用或页缓存持有都会抬高 _refcount,却不该触发一次无意义的复制。

4.2.2 写入触发缺页:三步流程

任一方第一次写这些页时,MMU 发现 PTE 有效但 W=0 而本次是写 → 保护违例 → 缺页处理程序接管:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
一次 COW 写的完整路径(子进程写地址 A);子进程: mov [A], eax
  ① MMU 翻译 A:PTE 有效(P=1)但 W=0 且本次是写 ⟹ 保护违例 → #PF →
     陷入 do_page_fault()(与"页不在内存"共用入口,靠 error code 区分
     protection / not-present)
  ② 判定是否可 COW:查 VMA 权限(不允许写 → SIGSEGV,与 COW 无关);
     匿名页或 MAP_PRIVATE 私有文件映射 → 候选;_mapcount > 0 ⟹ 还有人映射它
     ⟹ 必须真复制;若 _mapcount == 0(对方已退出/页已换出)⟹ 原地加速路径:
     不分配不拷贝,只把 PTE 的 W 位置 1
  ③ 分配新物理页 B,memcpy(旧页 → B),改**子进程** PTE:PFN 0x1A3C0 →
     0x2B77F、W 0 → 1;旧页 _mapcount -=1、_refcount -=1,新页计数置 1;
     TLB 该地址旧表项必须失效(否则反复缺页)
  ④ 返回用户态重执行那条写指令:PTE 已可写,命中完成
  ★ 父进程 PTE 完全没有被改动,仍指向 0x1A3C0 且仍只读 ——
     "谁写谁付钱":只有触发写的一方分摊复制成本。

警告

三个高频错点。第一,“把页标成只读"是改 PTE 的 W 位,不是给页加锁,答"内核给页加了读锁"是错的。第二,父进程不承担复制开销,成本与"谁写了多少页"严格相关。第三,引用计数分两个(_refcount 与 _mapcount),判据是后者。

注意

面试官会追问的下一层:共享页已被换出时,触发写的一方从 swap 读入一份新副本,复制来源是 swap 而非内存。另一个必答区分是 MAP_SHARED 与 MAP_PRIVATE:前者写不触发 COW、直接改共享页且对其他映射者可见;后者写触发 COW 并脱离页缓存成为匿名页,见第 12 章 12.5 节。

4.3 COW 的收益量化:10 GB 的 Redis 为什么能秒级 fork

fork 的耗时由四项组成,没有一项正比于"数据字节数”:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  T_fork ≈ T_task + T_vma + T_pagetable + T_ref
    T_task      :复制 task_struct、线程组信息、信号队列、fs_struct —— 常数级、μs
    T_vma       :复制 VMA(虚拟内存区域)描述符 —— 正比于"区域个数"而非字节数;
                  频繁 mmap/munmap 造成碎片化时显著上升
    T_pagetable :遍历页表,为每个**已映射页**写一条新 PTE —— 大内存进程的主要成本
    T_ref       :每个匿名页 _refcount += 1 —— 每页一次原子加
  换算(x86-64,4 级页表,4 KB 页,PTE 8 字节):
    4 GB  → 4×2³⁰ ÷ 4096 = 2²⁰ ≈ 1.05 M 页;10 GB → 2.62 M 页
    仅 PTE 本身 = 2.62 M × 8 B ≈ 21 MB
    上层页表:每个 PTE 页覆盖 512 × 4 KB = 2 MB,每个 PMD 页覆盖 1 GB
             ⟹ 10 GB 最少 5120 个 PTE 页 + 10 个 PMD 页,碎片化后多用几倍
    ⟹ fork 要新建的页表总量在 20 ~ 40 MB,耗时毫秒级

结论: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 ~ 数百 μsVMA 个数(地址空间碎片化程度)用大块 mmap 替代大量小块分配,避免频繁 munmap
页表复制(PTE + 上层页表页)数十 μs ~ 数 ms已映射页数、上层页表页数大页把 512 条 PTE 合成 1 条
引用计数递增与页表同量级已映射页数不可避免
数据页复制0(被推迟)无——
后续 COW 缺页每次约 1 ~ 2 μsfork 之后实际被写的页数降低 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 四种创建方式的对比

维度forkvforkcloneposix_spawn
地址空间复制页表 + COW共享(父子同一 mm)由 CLONE_VM 决定默认共享(内部用 CLONE_VM)
父进程是否阻塞否是,挂起直到子进程 exec/_exit由 CLONE_VFORK 决定是(继承 vfork 语义)
子进程如何启动从 fork 返回处继续从 fork 返回处继续从 fn(arg) 开始直接 exec,不回到用户回调
子进程可做的操作任意(多线程下须限异步信号安全函数)仅 exec 族或 _exit任意,由调用者保证由实现保证
主要收益语义最通用省掉页表复制资源共享粒度完全可控多线程进程里安全地 fork+exec
主要风险大内存 + 大写入量下的 COW 放大子进程碰了父进程的栈就毁掉父进程flags 组合错误 → EINVALfile 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 节
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static char shared_buf[64] = "before";
static int child_ret(void *arg) {
    // 为什么直接改静态变量:CLONE_VM 下父子共用同一地址空间,
    // 这次写对父进程立即可见 —— 这正是"线程"与"进程"的分水岭。
    snprintf(shared_buf, sizeof(shared_buf), "written by child");
    return 0;
}

int main(void) {
    // 5 个 flag 的并集 = 一个线程;去掉 CLONE_VM|CLONE_THREAD = 一个进程。
    // 为什么必须凑齐:CLONE_THREAD 强制要求同时给出 CLONE_SIGHAND,
    // 否则 clone 直接返回 EINVAL —— 这是硬依赖,不是建议。
    const int flags = CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD;
    char *stack = malloc(1 << 20);   // 线程要自己的栈,由调用者负责分配
    if (stack == NULL) return 1;
    if (clone(child_ret, stack + (1 << 20), flags, NULL) == -1) {
        perror("clone");             // 失败原因常见于 flags 组合非法
        return 1;
    }
    sleep(1);
    printf("parent sees: %s\n", shared_buf);
    return 0;
}

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环境变量来源最易踩的坑
execlexecl(path, arg0, ..., NULL)否继承 environ变量参数漏写 NULL → 读到栈上垃圾,随机崩溃
execvexecv(path, argv[])否继承 environargv 必须以 NULL 收尾,否则同样越界
execleexecle(path, arg0, ..., NULL, envp[])否显式 envp名字里最早出现的是 arg0 不是 envp,排序反直觉
execveexecve(path, argv[], envp[])否显式 envp唯一系统调用;两个数组都需自行构造
execlpexeclp(file, arg0, ..., NULL)是继承 environ参数含 / 时按路径处理,不再搜 PATH
execvpexecvp(file, argv[])是继承 environPATH 含空串或 . 时会在当前目录找可执行文件(CWE-426/427 类问题来源)

4.5.2 fork + exec 的内存账

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  T0  父进程:页表 ~0.002 × N GB,数据页 N GB
   │  fork()  ▼
  T1  父 mm#1 ─┐
      子 mm#2 ─┴→ 共享 N GB 物理页(_mapcount = 2,全只读);新增 = 第二套
      页表(MB 级、毫秒级)+ 每页一次引用计数递增
   │  子进程 execve()  ▼
  T2  子进程旧 mm 被整体丢弃:flush_old_exec() 卸下旧映射、信号处置回默认、
      清 FPU/线程状态;exec_mmap() → mmput(old_mm) 引用计数减 1,归零则
      __mmput() → exit_mmap() 逐 VMA 解除映射,每页 _mapcount 减 1、
      _refcount 减 1,减到 0 的页归还 buddy 分配器
      ★ 这些页仍被父进程映射着,所以一个也不会真正释放
  T3  子进程建立**全新的** mm:新 ELF 的 PT_LOAD 段、新栈(argv/envp)、新堆、
      新 vdso/vvar、新 mm_struct ⟹ 从"复制 N GB"变成"复制页表 + 整体拆建"

  两个不随 exec 消失的例外:shmat 附加的 System V 共享内存段、部分系统上的
  MAP_SHARED 映射(行为依赖实现,不要依赖它)。fd 默认全部继承,除非带
  FD_CLOEXEC(O_CLOEXEC)⟹ 大 fd 进程(网关、DB)必须在创建时就加 CLOEXEC。

"exec 为什么不泄漏内存"的准确回答:内核不是逐块 free 旧内存,而是把整个地址空间描述符(mm_struct)连同其全部 VMA 一起解除映射并释放——释放单位是 VMA 与页表树,不依赖旧进程的正确性,也不给用户态留下"忘记释放"的机会。反过来,exec 失败时不做任何清理,这正是"失败返回 -1、旧映像完好"的实现基础。

重要

fork 后 exec 前只允许调用异步信号安全函数,这是正确性约束而非风格建议。多线程程序里要拉起子进程,优先用 posix_spawn,或 fork 后立刻 exec 且中间只调用 exec 本身。

4.6 进程退出与回收

4.6.1 exit 的清理顺序

exit() 与 _exit() 的差别是高频考点,答案必须是有序两段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  用户态(glibc,顺序不可交换):① 逆序执行 atexit()/on_exit() 注册的函数
    (LIFO,便于与初始化顺序对称释放);② 刷新并关闭所有 stdio 流(fflush)
    —— 缓冲区内容才真正 write 出去;③ 关闭所有仍打开的流(fclose)→
    相应 fd 被 close;④ 调用 _exit(status) 进入内核
  内核态(do_exit,此后不返回):⑤ 释放用户态资源:地址空间 mm、fd 表、信号
    处理、定时器、文件锁;⑥ 退出码写进 task_struct,状态置 EXIT_ZOMBIE(`Z`),
    保留 task_struct 与退出状态直到父进程 wait;⑦ 向父进程发送 SIGCHLD;
    ⑧ 把子进程过继给 init 或最近的 subreaper;⑨ schedule() 切走,永不回来
  ⟹ _exit() 跳过 ①②③ 直接进内核。fork 出的子进程必须用 _exit():否则会把
     父进程 stdio 缓冲区再 flush 一遍 —— 缓冲区在 fork 时被复制了两份,
     于是同一份日志落盘两次。

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。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <errno.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>

// 为什么必须在循环里 waitpid:多个子进程几乎同时退出时,内核可能只投递一次
// SIGCHLD(标准信号不排队,同类信号在未决位图上合并)。若 handler 只 wait 一次,
// 剩下的孩子永远没人回收,会长期显示为 <defunct>,最终耗尽 PID 表。
static void on_sigchld(int sig) {
    (void)sig;
    int saved_errno = errno;            // handler 可能打断任意代码,必须保护 errno
    for (;;) {
        pid_t pid = waitpid(-1, NULL, WNOHANG);
        if (pid > 0) continue;                        // 还有已退出的孩子,继续收
        if (pid == 0) break;                          // 有孩子活着未退出,收工
        if (pid == -1 && errno == EINTR) continue;     // 被别的信号打断,重试
        break;                                        // ECHILD:没有孩子了
    }
    errno = saved_errno;
}

int main(void) {
    struct sigaction sa;
    sa.sa_handler = on_sigchld;
    sa.sa_flags = SA_NOCLDSTOP;         // 只在真正退出时通知,停止/继续不通知
    sigemptyset(&sa.sa_mask);
    sigaction(SIGCHLD, &sa, NULL);
    for (;;) pause();
}

4.7 进程组、会话与控制终端:双 fork 的原理

setsid 成功的条件是调用者不是进程组组长(“会话 ID 尚不存在"自动满足),因此双 fork 的必要性完全来自第一个条件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
  会话(session, sid) ← 由 setsid() 创建
   ├── 进程组 A(pgid;会话首进程所在组 = 前台组)
   │    ├── 进程 A1(组长:pid == pgid)   └── 进程 A2
   └── 进程组 B:进程 B1
  控制终端:一个会话最多关联一个终端设备
    · 终端把信号发给**前台进程组**:Ctrl+C → SIGINT、Ctrl+Z → SIGTSTP
    · SIGHUP(挂断)发给会话首进程及其控制的作业

  双 fork 每一步为什么必要:
    fork#1  父 → 子 S1,父立即 _exit(0)
       ⟹ S1 被 init 收养;shell 立刻返回(./svc & 不占住终端)
       ⟹ 但 S1 是否进程组组长取决于原进程的组身份,**不能保证**
    fork#2  S1 → 孙 S2,S1 也立即 _exit(0)
       ⟹ S2 的 pid 是新号、pgid 继承自 S1,故**必然不是组长**
       ⟹ 于是 setsid() 必定成功,而不像单次 fork 那样取决于调用环境
    setsid() S2 成为新会话首进程 + 新进程组组长,脱离开原控制终端

  为什么之后不会再获得控制终端:控制终端只能由**会话首进程**通过打开一个
  尚未属于其他会话的 tty 取得。setsid 后 S2 已脱离原终端且不持有任何 tty;
  此后它不再打开 tty(或带 O_NOCTTY),终端事件再也影响不到它。
  之后的标准动作:chdir("/") 免得占住启动目录挂载点;umask(0) 权限规则自己定;
  把 0,1,2 重定向到 /dev/null,避免写日志失败。

小心

只 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_FSchdir 影响所有线程
进程级属性(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 语义,于是两者之间会出现难以理解的行为差异——这正是"多线程 + 信号"被普遍建议避开的原因:所有信号只在主线程或专门的信号线程接收,业务线程一律屏蔽。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  用户栈:pthread_create 时由 glibc 用 mmap 分配,默认上限 8 MB
    · 8 MB 是**虚拟地址保留**,物理页按需分配 ⟹ "起了 1000 个线程"不等于占用
      8 GB 物理内存;但每线程至少触碰一两页、各占一个 VMA 与自己的页表
      ⟹ 1000 线程大致对应数十 MB 物理 + 数千个 VMA
    · 栈底有 guard page(PROT_NONE 页),越界访问触发 SIGSEGV
    · 主线程的栈**不是** mmap 来的:它来自程序加载时的映像,靠 RLIMIT_STACK
      增长,超限则 SIGSEGV
  内核栈:每任务一份,x86-64 上 16 KB(2 页)或 8 KB,随 fork/clone 一并分配、
    **不可增长** ⟹ 内核代码递归深度受限;系统调用/中断与缺页处理都在这里跑。
    与用户栈独立:用户栈溢出是 SIGSEGV,内核栈溢出是内核 panic。

警告

说"线程比进程轻所以线程栈更小"是错的:线程栈大小是 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_VM4.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 返回 ECHILD4.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 节。