土法炼钢兴趣小组的算法知识备份

【操作系统百科】进程与 fork/exec 的历史包袱

文章导航

分类入口
os
标签入口
#fork#exec#clone#posix-spawn#vfork#copy-on-write#process-model

目录

fork 是 Unix 最古老的原语之一。它的”神奇”设计——把 “启动一个新程序” 拆成 “复制 + 换壳” 两步——让 shell 的管道、重定向、job control 变得极其简洁。它也让 Unix 程序的结构有了 “父子树” 的自然骨架。

但 fork 也是 Linux 上最容易中雷的系统调用之一。大内存进程 fork 卡住秒级;多线程 fork 让 mutex 留在诡异状态;容器里 fork 时 pid namespace 的 PID 1 是个 subreaper 陷阱。本文梳理 fork 的来龙去脉、它在现代工程的变体,以及为什么 2020 年代的新代码应该优先考虑 posix_spawn

一、fork 的经典语义

pid_t pid = fork();
if (pid == 0) {
    // 子进程
    execvp("/bin/ls", argv);
    _exit(127);
} else if (pid > 0) {
    // 父进程
    waitpid(pid, &status, 0);
} else {
    perror("fork");
}

fork 在调用时把当前进程”克隆”一份:

父子只在返回值上区分:父得 child pid,子得 0,出错父得 -1。

这个设计在 1971 年是绝妙的——那时候进程就是一段连续的内存 + 几个寄存器,“复制”是物理操作。50 年后的问题在于:进程的状态变得极其庞大,但 fork 的语义没变

二、COW 与页表放大

现代 Linux 的 fork 不会立刻复制每一页数据;它只复制页表,所有页被标记为 PTE_WRPROT(只读),哪方先写就 page fault → COW(Copy-On-Write)分配新页。

flowchart LR
    subgraph Before[fork 前]
        P[父进程 task] --> PT1[页表]
        PT1 --> PG[(物理页 RW)]
    end
    subgraph After[fork 后]
        P2[父 task] --> PT2[父页表 RO]
        C[子 task] --> PT3[子页表 RO]
        PT2 --> PG2[(物理页 RO 共享)]
        PT3 --> PG2
    end
    subgraph Write[子写入后]
        P3[父] --> PT4[父 RO]
        C3[子] --> PT5[子 RW]
        PT4 --> PG3[(原页)]
        PT5 --> PG4[(新复制页)]
    end

COW 节约了拷贝开销,但没有节约页表本身的复制。页表在 x86_64 上是 4 级,每 512 个 PTE 需要一个中间表。512GB 地址空间的页表占用大约几 GB。对大 JVM 或大 Redis 进程,fork 的”页表复制 + RO 标记”这一步本身就要几百毫秒到几秒。

而且 COW 不是”零成本”:

因此大内存进程 fork 在生产上被视作”仅限 fork 后立刻 exec” 的场景——Redis 的 BGSAVE 是少数例外,它接受这个代价换取”不停服持久化”。

三、exec:把 COW 瞬间清零

execve 让进程丢弃当前地址空间,加载新二进制。语义上:

fork + exec 的”异味”在于:fork 花费大量时间复制页表/标 RO,exec 立刻丢掉。这就是 vfork / posix_spawn 试图解决的问题。

四、vfork:为 exec 而生

vfork 的语义是 fork 的”危险简化版”:

因为这种危险,早期 POSIX 把 vfork 标记为 obsolete。但 glibc 仍有 vfork 实现;Linux 的 clone CLONE_VFORK 是现代路线。musl 的 posix_spawn 内部用 clone + CLONE_VFORK + CLONE_VM 实现。

五、clone / clone3:内核视角的”统一 fork”

Linux 内核里根本没有 fork 这个原语。forkvforkpthread_create 都翻译到同一个 syscall:clone

clone 的参数是一组 flags,控制”子进程和父进程共享什么”:

flag 含义
CLONE_VM 共享地址空间(pthread)
CLONE_FS 共享 fs_struct(cwd、root)
CLONE_FILES 共享 file descriptor table
CLONE_SIGHAND 共享 signal handlers
CLONE_THREAD 同一 thread group(tgid 相同)
CLONE_VFORK 父阻塞到子 exec/exit
CLONE_PARENT 子的父是 caller 的父
CLONE_NEWNS/PID/NET/... 新建 namespace
CLONE_PIDFD 分配 pidfd
CLONE_IO 共享 I/O context

fork() = clone(SIGCHLD)pthread_create() = clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID|...)

5.1 clone3

clone3 (2019, 5.3) 用 struct clone_args 取代长串参数,带 size 字段便于演进:

struct clone_args {
    __u64 flags;
    __u64 pidfd;
    __u64 child_tid;
    __u64 parent_tid;
    __u64 exit_signal;
    __u64 stack;
    __u64 stack_size;
    __u64 tls;
    __u64 set_tid;       // 指定子进程 pid(需要 cap)
    __u64 set_tid_size;
    __u64 cgroup;        // 直接把子放到目标 cgroup
};

set_tid 允许 CRIU(checkpoint-restore)恢复进程时重建原始 pid——这在以前几乎不可能。cgroup 字段让”fork 进新 cgroup”原子化,去除 fork→move 的竞争窗口。

六、posix_spawn:合并 fork+exec 的现代替代

posix_spawn 是 POSIX.1-2001 加入的接口,语义上等价于 fork+exec 但允许实现跳过中间态

posix_spawn(&pid, "/bin/ls", &file_actions, &attr, argv, envp);

glibc 2.24+ 在 Linux 上用 clone + CLONE_VFORK + CLONE_VM 实现;musl 一开始就这么做。避免了 fork 的页表复制 → 对大进程的启动延迟降低几个数量级。

为什么生态没有迁移过来

Python 3.12 默认用 forkserver 而不是 fork,是正确方向。Rust 的 std::process::Command 在 glibc 上走 fork + exec;在 musl 走 posix_spawn。Go 完全绕开,用自己的 syscall.forkAndExecInChild + RawSyscall + vfork-like 约束。

七、fork 与多线程:async-signal-safe 诅咒

POSIX 规定:多线程程序在 fork 后,child 只能调用 async-signal-safe 函数直到 exec。原因是:

async-signal-safe 的函数清单(POSIX 规定):_exit_Exitexecvereadwriteclosedupforksignalwaitpid 等几十个。不包含 printf、malloc、pthread_mutex_lock、stdio 任何函数

后果:复杂程序不能 fork 后做任何重要事,只能立刻 exec 或 _exit。

pthread_atfork 允许注册 prepare/parent/child 钩子,但在多库嵌套时无法保证顺序正确,几乎所有大型项目的建议都是别用 pthread_atfork

八、fork 与 JIT、GC、大堆

JIT 编译器(V8、JVM、Go runtime)生成的代码页有 PROT_EXEC,持有指针、引用表。fork 后这些内部表在两个进程间共享但可能被异步修改:

CRIU 为了能 checkpoint JIT 进程,做了大量特殊处理(freeze GC、marshal JIT page)。

九、fork 与容器:PID 1 陷阱

在 pid namespace 里,PID 1 有特殊语义:

Docker 的入口进程常常是应用本身(PID 1)。如果应用 fork 子进程且不 reap,会攒僵尸;如果被 SIGTERM 没 handler,Docker stop 超时后 SIGKILL。

解决方案

PR_SET_CHILD_SUBREAPER 让进程成为”子 reaper”,接收孤儿进程的 SIGCHLD——不是 PID 1 也能做这件事。systemd、supervisord、Docker 用它管理子树。

十、fork 在工程上的正确形态

  1. 一旦决定 exec,就用 posix_spawn(现代 libc 的实现已经够好)
  2. pre-fork 服务器(Nginx、Postgres、Gunicorn):fork 一次,子进程长期跑;共享只读的初始化数据(权重、配置)。相性好
  3. GC 语言的进程池(Python multiprocessing):用 spawnforkserver 启动方式,避免多线程 fork
  4. Redis BGSAVE 风格:fork 做快照,接受 COW 放大的代价,关键是监控 rss
  5. 容器入口:确保 PID 1 能处理 SIGTERM 和 reap;不行就用 --init
  6. exec 前用 close_range(CLOSE_RANGE_CLOEXEC)(5.11+)一次性关闭不需要的 fd,代替遍历 /proc/self/fd

十一、fork 不会消失

即使 posix_spawn 更好,fork 不会被移除——它是稳定 ABI 的一部分,数十亿行代码依赖它。Linus 多次拒绝”废弃 fork”的提议。新代码可以选择更好的接口,但 fork 永远会在那里。

“好的抽象不是最先被发明的,而是在漫长时间后仍然能被安全使用的那一种。” fork 早已不属于前者;我们能做的是理解它、绕过它、或在必须时谨慎使用它。

下一篇 B-10 进入线程模型:1:1、N:1、M:N、虚拟线程——相同的”并发”问题在不同调度哲学下的不同答案。


参考文献

工具


上一篇关于 OS 的工程常识错觉 下一篇线程模型:1:1 / N:1 / M:N 与虚拟线程

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-27 · os

【操作系统百科】内存回收

Linux 内存回收是 VM 最复杂的子系统之一。本文讲 active/inactive LRU、kswapd 与 direct reclaim、watermark 三线、swappiness 的真实含义、MGLRU 改造、memcg 回收与 PSI。

2026-04-28 · os

【操作系统百科】交换

swap 还值得开吗?本文讲 swap area 基础、swap cache、zram 压缩内存、zswap 前端压缩池、swappiness 的真实含义、容器里的 swap 策略,以及为什么现代 Android 全靠 zram 不靠磁盘。

2026-05-03 · os

【操作系统百科】Slab/SLUB 分配器

buddy 只管页粒度(4K+),内核大多数对象只有几十到几百字节。slab/SLUB 在 buddy 之上做对象级缓存。本文讲 slab 历史、SLUB 接手、SLOB 退场、kmem_cache、per-CPU cache、KASAN 集成。

2026-05-07 · os

【操作系统百科】用户态分配器

glibc malloc、tcmalloc、jemalloc、mimalloc 各有哲学。本文讲 arena、thread cache、size class、madvise 返还策略、碎片与 RSS 膨胀、如何根据负载选分配器。


By .