TaterLi 个人博客

[内核学习]进程创建

Linux 造进程只有一个系统调用家族:clone.fork 是 clone 的特例,pthread 是 clone 的另一种特例,vfork 是历史特例.这篇把"一个 clone,几种人生"讲清楚,附两组硬数据:fork 和线程的创建成本差多少,COW 的缺页数能不能和内存大小精确对账.能,而且对得很漂亮.

这篇回答几件事:

先抓现场.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 前碰任何变量都会直接改到父进程内存里,而且栈也共享,一个函数调用就互相踩.

最后留几个容易忽略的细节:

退出移动版