Linux 造进程只有一个系统调用家族:clone.fork 是 clone 的特例,pthread 是 clone 的另一种特例,vfork 是历史特例.这篇把"一个 clone,几种人生"讲清楚,附两组硬数据:fork 和线程的创建成本差多少,COW 的缺页数能不能和内存大小精确对账.能,而且对得很漂亮.
这篇回答几件事:
- fork,pthread,vfork 在系统调用层长得什么样
- copy_process 复制了什么,哪些东西其实没复制
- COW 写时复制,用缺页计数验证到页
- fork 和 pthread 的成本差在哪
先抓现场.tracepoint 的 raw_syscalls/sys_enter 把系统调用的参数原样吐出来,给 forkbench 程序设个过滤器,glibc 的 fork 长这样:
NR 220 (clone) flags=0x1200011
解码 0x1200011:CLONE_CHILD_SETTID(0x01000000)加 CLONE_CHILD_CLEARTID(0x00200000)加 SIGCHLD(0x11).没有 CLONE_VM,没有 CLONE_FILES — 什么都不共享,子进程是父进程的完整拷贝,这就是 fork 的语义,顺手让内核把子进程 TID 存好并在子进程退出时清零(glibc 管理 PID 缓存用).再看 pthread_create,同一个程序里它发的是 NR 435 — clone3,flags 移进了参数结构体,老 clone 的六个参数槽装不下现代线程那一串标志.所以"fork 和线程都是 clone"不是比喻,是字面事实:同一个入口,flags 不同,拷贝量不同,命运不同.
flags 怎么决定命运,全在 kernel/fork.c 的 copy_process:每个资源组对照一个位,CLONE_VM 没设就 copy_mm(复制地址空间,走 COW),设了就共享同一个 mm_struct;CLONE_FILES 管文件表,CLONE_SIGHAND 管信号处理器,CLONE_THREAD 管线程组归属.线程之所以"轻",是因为这些位几乎全设了,真正新建的只有 task_struct 本体,内核栈和调度实体.fork 反过来,全不设,每样都拷一份 — 但拷的大头内存恰恰不是真的拷.
COW,copy on write,fork 最著名的优化.fork 时页表整体复制,但所有可写页都标成只读,父子先共用同一批物理页;谁写谁缺页,缺页处理函数发现"这页标只读但本该可写,而且是 COW",当场拷一页,改自己的映射,放行.验证方法比听故事过瘾:让父进程先把 8MB 数组 memset 一遍(页全部建立),fork,子进程再 memset 一遍(全量写,理论上每页一次 COW),用 getrusage(RUSAGE_CHILDREN) 的子进程 minor fault 差值计数:
child wrote 8MB: minflt=2085 (期望 8MB/4KB = 2048)
child wrote 0B: minflt=36 (不写就几乎不缺页)
2085 对 2048,多出来的三十几个正是对照组那行 fork 自身开销级的量.8MB 内存,写时复制精确到页,一页不多一页不少,这是内核文档里那句话的实测版:"fork 之后子进程的内存成本近乎为零,直到它写".
成本差多少,板上计时:
fork+exit+wait x500: 814.7 us/op
pthread_create+join x500: 95.5 us/op
八倍半.fork 贵在复制页表(几万页的映射要拷),复制文件表,信号表,起一遍新调度实体的账;pthread 只造 task_struct 和内核栈.这个数量差是"能用线程别用进程"的性能依据,但别读成教条:fork 之后子进程 exec 的话,那套拷贝立刻全扔,COW 没亏几分钱,所以 shell 一秒钟 fork 几十次毫无压力;而线程省了创建的钱,换来共享地址空间里一个野指针能写脏邻居的账,工程上怎么选是另一道题.
vfork 简单交代:clone(CLONE_VM|CLONE_VFORK),连页表都不拷,地址空间直接共用,父进程挂起,直到子进程 exec 或退出.这是 COW 普及前的遗产,当年省的就是那套页表拷贝;现在 posix_spawn 内部还用它的变体(CLONE_VM|CLONE_VFORK)拼"fork+exec 最快路径",裸 vfork 在应用程序里已经属于"知道就好,别手写"的级别 — 子进程在 exec 前碰任何变量都会直接改到父进程内存里,而且栈也共享,一个函数调用就互相踩.
最后留几个容易忽略的细节:
- clone 的低 8 位是退出信号,fork 传 SIGCHLD,线程传 0(线程退出不通知父进程,组内由 futex 线程计数管),这个设计新老 clone 一脉相承.
- copy_process 里有个 pids subsystem 的配额检查,cgroup 限制进程数在这里拦,fork 炸弹被掐住的地方也是这.
- COW 的缺页处理在多线程写同一页时有个"锁页再拷"的小窗口,极端并发 fork+写微基准能测出抖动,日常完全无感.
- clone3 相对 clone 的真正好处是参数结构体能扩展(CLONE_INTO_CGROUP 就是新加的),新标志位不会再撞参数槽上限.
- 子进程的 vfork/CLONE_VM 场景下 getrusage 的账本会延迟结算,统计类程序拿 RUSAGE_CHILDREN 前要确保 wait 收尸完毕.
- kthread_create 走的也是同一条 kernel_clone 路径,不过是内核发起,带 CLONE_KERNEL 组合标志 — 2.2 篇的 kthreadd 和本篇是同一套机器.
- fork 返回两次这个行为,一半功劳在 arm64 的 pt_regs:子进程的 x0 在 ret_from_fork 前被填 0,父进程的 x0 是新 PID,同一份出口代码两种结果.