# 第 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）的每一位都是权限位**，以及**写一个只读页会触发缺页异常**。

> [!IMPORTANT]
> COW 的准确表述是三层：**① `fork` 只复制页表、不复制数据页；② 父子两套页表指向同一批物理页，并把这批映射标成只读；③ 任一方写这些页时触发缺页，内核才为写方复制一个私有副本。** 只答"用到才分配"不算过关，因为漏掉了"靠只读权限位拦截写"这个机制核心。

## 4.2 COW 的完整机制

### 4.2.1 父子页表共享物理页

`fork` 完成后父子各有一套完整页表，但两套页表里指向**同一个物理页框（page frame）**的 PTE 是成对存在的：

```text
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` 而本次是写 → 保护违例 → 缺页处理程序接管：

```text
一次 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 且仍只读 ——
     "谁写谁付钱"：只有触发写的一方分摊复制成本。
```

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

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

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

`fork` 的耗时由四项组成，**没有一项正比于"数据字节数"**：

```text
  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 ~ 数百 μ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。

> [!CAUTION]
> 把 `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 节 |

```c
#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` 实现，避免复制父进程页表。

> [!TIP]
> 记忆锚点：把 `clone` 的 flags 看成"合并开关清单"——`CLONE_VM` 合地址空间、`CLONE_FILES` 合 fd 表、`CLONE_SIGHAND` 合信号表、`CLONE_THREAD` 合线程组。**五个全开就是 `pthread_create`，全关（再加 `SIGCHLD`）就是 `fork`。**

> [!NOTE]
> 面试官会追问的下一层：**`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 的内存账

```text
  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`、旧映像完好"的实现基础。

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

## 4.6 进程退出与回收

### 4.6.1 exit 的清理顺序

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

```text
  用户态（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。

```c
#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` 的必要性完全来自第一个条件：

```text
  会话（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，避免写日志失败。
```

> [!CAUTION]
> 只 `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 节） |

> [!NOTE]
> 面试官会追问的下一层：**Linux 的信号屏蔽字到底共不共享？** POSIX 要求 `pthread_sigmask` 只影响本线程，但 Linux 内核因历史原因让 `CLONE_THREAD` 的任务共享信号屏蔽字；glibc 通过在用户态为每个线程维护私有 `sigmask` 并适时同步来模拟 POSIX 语义，于是两者之间会出现难以理解的行为差异——这正是"多线程 + 信号"被普遍建议避开的原因：所有信号只在主线程或专门的信号线程接收，业务线程一律屏蔽。

```text
  用户栈：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。
```

> [!WARNING]
> 说"线程比进程轻所以线程栈更小"是错的：**线程栈大小是 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 节。

> [!TIP]
> 面试话术：被问"为什么用线程池"，不要只答"复用线程"。完整答案是三句：**① 摊薄创建成本（用户栈 `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 带来的额外物理内存。

<details><summary>答案与解析</summary>
**页表**：页数 `= 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 节。</details>

**Q2**（推导）写出从"子进程对某个共享只读页执行一次写"到"写成功返回用户态"的完整步骤。父进程的页表在这一过程中被修改了吗？若父进程此刻已退出，这次写还会复制吗？

<details><summary>答案与解析</summary>
**步骤**：① 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，写落在页缓存共享页上。</details>

**Q3**（辨析）"`vfork` 比 `fork` 快，所以拉起子进程应该用 `vfork`。"这句话对吗？给出 `vfork` 踩坏父进程的具体场景，并说明现代代码更该用什么。

<details><summary>答案与解析</summary>
**不严谨**。`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` 的挂起有什么副作用？"——改变父子相对执行顺序，掩盖或引入竞态。</details>

**Q4**（分析）某服务每收到一个请求就 `fork` 一个子进程处理。运行数日后 `ps` 显示数千个 `<defunct>`，并开始出现 `fork` 失败。父进程装了 `SIGCHLD` handler，内容是一句 `wait(NULL);`。给出根因、修法，并说明把 `SIGCHLD` 设成 `SIG_IGN` 为什么能消除僵尸以及代价是什么。

<details><summary>答案与解析</summary>
**根因**：僵尸源于"子进程已退出但父进程没有 `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 节。</details>

**Q5**（设计）把前台服务改造成守护进程：脱离终端（终端关闭不被 `SIGHUP` 杀掉）、不占用启动目录、日志可控。写出完整启动步骤并说明每一步的"为什么"，重点解释为什么必须 fork 两次、以及第二次 fork 之后为什么再也不会获得控制终端。

<details><summary>答案与解析</summary>
**步骤与理由**：① **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"的原子组合，否则并发启动会互相覆盖。</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应小节 |
|---|---|---|---|
| `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 节。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch04/  

