fork 到底复制了什么

难度 ★★★☆☆ · 前置:inode 与硬链接:删了文件,空间为什么没降 | 一句话结论:fork 复制的是 task_struct 和页表,不是内存里的字节;两者共享物理页并全部标只读,谁写谁触发缺页、复制一页。exec 换掉地址空间但 PID 不变。

排查一个内存相关的问题时,我顺手跑了个 cat 管道给 wc,两句话就把一个 2GB 的文件数完了,前后不到一秒,然后我盯着 top 看了好一会儿,因为两个进程的 RSS 都只有几 MB,谁的账上都没多出那 2GB,按管道要把数据从一端搬到另一端的理解,这 2GB 怎么也该在内存里经过一次。

1
2
3
4
df -h /tmp | tail -1                              # 先确认 /tmp 有 2GB 空
dd if=/dev/zero of=/tmp/bigfile.bin bs=1M count=2048 # 造个 2GB 的文件
cat /tmp/bigfile.bin | wc -c
rm -f /tmp/bigfile.bin # 测完删掉,别留着占盘

可内存根本没涨。

我当时的猜测是管道有某种魔法,能绕开内存直接把字节递过去,这个猜测后来被证明只对了一半,管道那层确实不搬数据(splice 的事在管道那篇讲),但更前一步的问题我压根没想过,cat 和 wc 这两个进程是怎么被造出来的。它们是 shell fork 出来的,而所有教材都说,fork 会复制整个进程,那 2GB 的进程 fork 两次,不得复制 4GB,这个数字跟 top 里那几 MB 放在一起,总得有一个是假的。

一字节都没复制。

这篇讲的就是这句话为什么成立,以及内核在 fork 里到底动了哪些东西,动完为什么还能那么快,快到一个已经跑了几天的进程 fork 出新进程,你在 top 里根本看不出发生了什么。

task_struct:一个进程的全部身份证明

先把一个说法纠清楚,日常里我们说的进程混了两个东西,程序是磁盘上那个 ELF 文件,只读,可以随便共享,进程是这份程序跑起来之后的状态,内存长什么样、寄存器里是什么、开着哪些 fd、信号怎么处理、PID 和 uid 是多少,这些加起来才叫一个进程。内核里表示它的结构叫 task_struct,定义在 include/linux/sched.h,字段好几百个,占几 KB,不是一个精简记录,而是进程的全部身份证明,每一样东西都得有个地方存,加起来就是那一大坨。

它长这样。

装什么 大致对应字段 为什么需要
身份 pid、tgid、cred(uid/gid) 谁能给谁发信号、谁能读谁的文件
内存 mm(指向 mm_struct) 地址空间长什么样、页表在哪
文件 files(指向 files_struct) 那张 fd 表
信号 sighand、pending、blocked 收到信号之后怎么反应
调度 state、prio、se(CFS 实体) 什么时候能上 CPU、能跑多久
亲缘 real_parent、children、sibling 谁给谁收尸、谁给谁传环境变量
记账 utime、stime、start_time ps 里那些数字从哪来

ls /proc/<PID>/ 看到的那一堆文件,基本就是这个结构的一层投影,字段在哪、文件就在哪,你把这张表记熟了,/proc 那篇也就读懂一半了。反过来说也成立,/proc/<PID>/status 里那些看起来没头没脑的行,比如 NStgid、NSpid、Seccomp,全都能在这张表里找到出处,找不到的往往是跟命名空间或安全模块相关的那几项,它们各自指向别的结构,不在这个结构体里,所以第一次翻 /proc 会觉得字段多得没道理,等你把 task_struct 认全了,这些文件反而变成一个索引,你知道该去哪一栏找东西,而不是靠 man proc 一行行读下来。

fork 在内核里做了七件事

fork 走的是 kernel/fork.c:kernel_clone()(老版本叫 do_fork),顺序大致是下面这七步。

  1. 复制 task_struct。dup_task_struct 先从 slab 缓存分配新结构,再把父进程的字段逐个拷过来;
  2. 换新身份。分配新 PID,改掉 pid 和 tgid;cred 是实打实复制一份(copy_creds 走 prepare_creds),只有 clone(CLONE_THREAD) 造线程时才靠引用计数共享;
  3. 处理内存,调 copy_mm;
  4. 处理文件,copy_files 走 dup_fd 把 fd 表复制一份,copy_fs 走 copy_fs_struct 复制工作目录/root/umask,都不是共享。文件偏移量之所以父子共享,是因为 fd 表里指向的是同一个 struct file;
  5. 处理信号,copy_sighand 新分配一份处理函数表再 memcpy 过来(pending 自然是空的);只有 clone(CLONE_SIGHAND) 才是引用计数 +1;
  6. 建立亲缘,把自己挂到父进程的 children 链表上;
  7. 唤醒,把新任务塞进调度队列,等被选中就能跑。

第三步单独占一行不是排版事故。

(这七步我第一次读源码时是跳着看的,因为它太像例行公事,后来才发现第三步就是全部的答案,前面六步都在搬几 KB 的记账信息。)

写时复制:复制页表,不复制字节

copy_mm 里有个开关,clone_flags & CLONE_VM。带这个标志的时候(clone 用来造线程的场景),新任务直接用父进程的 mm_struct,引用计数 +1,这就是线程,共享地址空间。不带的时候(普通 fork),分配一个新的 mm_struct,然后 dup_mmap() 遍历父进程的每一段 VMA(vm_area_struct,内核用来描述一段连续地址区间,代码段、数据段、堆、栈、映射区都各是一段),给每一段建好新的页表项,但页表项指向的还是父进程原来那批物理页,同时把这些页在两个进程里都标成只读。

所以 fork 返回的那一瞬间,两个进程的虚拟地址完全一样,指向的物理页也是同一批,真实内存占用几乎不动,只多了页表。

复制被推迟了,靠 CPU 的页错误机制补上。

这个优化叫 COW,copy-on-write,写时复制,它带来两个能观测到的后果,fork 很快,跟进程占多少内存基本无关;fork 之后的第一次写入会变慢,因为那时候才真拷。所以 fork 完立刻 exec 的写法特别划算,execve 会把整个地址空间丢掉,那些还没来得及复制的页直接作废,一次复制都不用做;反过来,如果 fork 出来的子进程要长期活着并且会写大量内存,比如某个服务端进程里 fork 出一堆 worker,COW 的代价就摊到运行期了,表现是莫名其妙的 page fault 尖峰。

返回值为什么有三个

1
2
3
4
5
6
7
8
pid_t pid = fork();
if (pid < 0) {
/* 失败:EAGAIN(进程数或内存不够)、ENOMEM */
} else if (pid == 0) {
/* 子进程:这里返回 0 */
} else {
/* 父进程:这里返回子进程的 PID */
}

子进程不知道自己是谁,但它不需要知道,所以返回 0,父进程需要子进程的 PID,否则没法 wait、没法发信号,失败则返回 -1 并置 errno。这套设计最早是为了让 fork 之后的代码不用判断我是谁,你在 if (pid == 0) 里写子进程的部分,在 else 里写父进程的部分,两段代码在同一个函数里紧挨着,读起来像剧本,演的时候各演一半,很多老式服务端的初始化流程就是这么组织的,一次 fork 分出若干个完全独立的副本,每个副本拿 getpid() 做自己的日志前缀,然后各自进 accept 循环,这种写法现在不多见了,但在读老代码的时候天天遇到,不认出来会以为那是两套逻辑。

一个代码块靠返回值分叉,这是 fork 最别扭也最经典的地方,现代代码里更常见的是 posix_spawn,它把 fork 加 exec 包起来,还能在 vfork、clone(CLONE_VFORK) 之间切换,对内存紧张的进程更友好。

继承什么,不继承什么

继承 说明
地址空间 逻辑上继承(COW),内容一致
fd 表 继承,与父共享 struct file,偏移量共享
文件系统上下文 当前工作目录、umask、根目录
信号处理函数 继承,除非显式改成 SIG_DFL
环境变量 继承(environ 就是内存的一部分)
uid/gid 继承
进程组 继承,setpgid 之后会分裂
不继承 说明
PID / PPID 全新
待处理信号 pending 清空
alarm 定时器 清零
fcntl 文件锁 不继承;flock 的会随共享 fd 继承
记录锁、itimers 不继承
CPU 时间记账 从零开始

这两张表看着琐碎,实际很会咬人,父进程 alarm(10) 之后 fork,子进程确实收不到这个定时器,但父进程自己的还在走,过了几秒父进程突然被 SIGALRM 打断,很容易以为是子进程出了问题。fcntl 的锁在 fork 后丢失也一样,父进程加锁、子进程干活这种写法看着严谨,其实锁早就没了,两个进程会同时写同一个文件。

exec 之后,PID 没变

execve 做的是替换,读 ELF 头、建新的 mm_struct、把入口交给新程序,task_struct 保留,PID 不变,fd 表保留(O_CLOEXEC 的除外),信号处理函数重置成默认,这也是为什么信号处理要在 exec 之后重新装。

一条命令就能看见。

1
bash -c 'exec sleep 5'

bash 不 fork,直接在自己进程里 exec 成 sleep,所以在另一个终端 ps -o pid,comm -p $(pgrep -n sleep) 看到的 PID,跟启动它的那个 shell 是同一个。

这条特性在写 systemd unit 的 ExecStart 时经常被利用,加不加 exec 决定了 systemd 追踪的是 shell 还是真正的服务进程,不加的话,systemd 杀掉 shell 之后服务进程可能还活着,端口还占着,重启一次报一次 Address already in use。

认一遍进程的身份证

从认一张身份证开始。

1
cat /proc/self/status | head -20
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Name:	cat
Umask: 0022
State: R (running)
Tgid: 5231
Pid: 5231
PPid: 4120
Uid: 1000 1000 1000 1000
Gid: 1000 1000 1000 1000
NStgid: 5231
NSpid: 5231
...
VmPeak: 10240 kB
VmSize: 10120 kB
VmRSS: 1024 kB

/proc/self 是个软链,指向打开它的那个进程自己的 /proc/<PID>,所以这里 Name 是 cat,Pid 就是 cat 的 PID。

VmSize 和 VmRSS 差这么多不是 bug,前者是虚拟地址空间总量(含没用到的栈、映射了但没碰的堆),后者是真正驻留的物理页,fork 出来的进程 VmSize 跟父进程几乎一样,VmRSS 才慢慢涨,这就是 COW 在账面上长什么样。

再量一次 COW,需要一按自己 pid 拼路径的辅助函数。

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
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

char big[64 * 1024 * 1024]; /* 64MB 全局数组,BSS 段,未初始化 */

/* 读自己的 RSS:必须按自己的 pid 拼路径,
不能用 /proc/self —— 那会读到读它的那个进程 */
static void show_rss(const char *tag) {
char path[64];
char line[256];
snprintf(path, sizeof(path), "/proc/%d/status", (int)getpid());

FILE *fp = fopen(path, "r");
if (!fp) return;
while (fgets(line, sizeof(line), fp)) {
if (strncmp(line, "VmRSS:", 6) == 0) {
printf("%s%s", tag, line);
break;
}
}
fclose(fp);
}

int main(void) {
/* 先写一遍,让物理页真的分配出来 */
memset(big, 'A', sizeof(big));
show_rss("parent before fork: ");

pid_t pid = fork();
if (pid == 0) {
show_rss("child after fork: ");

big[0] = 'B'; /* 触发一次 COW */
show_rss("child after write: ");
_exit(0);
}
waitpid(pid, NULL, 0);
return 0;
}
1
gcc -O0 cow.c -o cow && ./cow     # 把上面这段存成 cow.c;-O0 免得编译器把 big 优化掉

实测输出(数字随内核版本和内存情况浮动)。

1
2
3
parent   before fork: VmRSS:	  65564 kB
child after fork: VmRSS: 65564 kB
child after write: VmRSS: 65568 kB

三个数怎么读,fork 之后子进程 RSS 和父进程一模一样,一页都没复制,改 1 个字节只让 RSS 涨了 4KB,就是一页。如果发现 RSS 一下涨了 64MB,那说明 COW 没生效,查两件事,是不是用了 vfork,或者对这段内存做过 mlock。

这个程序我第一版写错了。我一开始用 system("grep VmRSS /proc/self/status"),跑出来永远是同一个数,因为 /proc/self 在子 shell 里指向的是那个 sh,不是我的程序,加了 show_rss() 按 pid 拼路径才对。这个坑很值,它说明 /proc/self 里的 self 指的是谁在打开这个文件,不是谁在关心这个数字,父进程那行必须在 fork 之前打,否则父子交替输出,谁是谁完全看不出来。

最后看进程树。

1
ps -eo pid,ppid,stat,comm --forest | head -20
1
2
3
4
5
 PID    PPID STAT COMMAND
1 0 Ss systemd
412 1 Ss sshd
4120 412 Ss bash
5231 4120 R+ ps

--forest 用缩进画亲缘,PPID 列给准确的父进程,STAT 第一位是状态,S 可中断睡眠、R 运行、D 不可中断、Z 僵尸、T 停止,这个字段值得单独找一篇讲。

fork 之后共享什么,问得最多

有人说 fork 很慢所以要用线程,这话在 COW 之下基本站不住,几 GB 的进程 fork 也是微秒级,慢的是 fork 之后的写入,vfork 存在的年代(3.x 之前)确实 fork 很贵,现在它主要是为了避免 fork 之后 exec 之前那段复制,而 COW 已经让这段几乎免费。

(这个问题我当时没敢问,因为看起来太蠢,蠢到像是应该自己知道的。)

还有人说 fork 之后父子进程的变量各自独立,逻辑上独立,但不是立刻独立,谁先写谁触发复制,两边都不写,物理上始终是同一页,说子进程继承了父进程的内存也不准确,它只继承当时那一瞬间的样子,父进程 fork 之后再改自己的变量,子进程看不到。

ps 里的 PID 是不是 task_struct 里那个 pid,单线程进程没错,多线程进程里每个线程有自己的 pid(内核视角的线程 ID,gettid() 返回的那个),但共享同一个 tgid(getpid() 返回的),ps -eLf 能看到所有线程,但列的含义和直觉相反,PID 列(ps 的 pid 别名 tgid)对同一进程的每个线程都一样,等于 tgid,LWP 列(spid)才是各线程自己的 TID,所以内核里线程和进程都是 task_struct,差别只在 clone 传了哪些 CLONE_*,线程模型那篇专门讲这一层。

最后一条,kill -9 不一定能杀掉进程,D 状态(不可中断睡眠,通常在等 IO)不响应任何信号,包括 SIGKILL,只能等 IO 返回或者重启机器。

复制了什么,推迟了什么

task_struct 是进程的全部身份,ls /proc/<PID>/ 看到的就是它的投影。

fork 复制的是记账信息和一个新页表,内存靠 COW 推迟,所以它快,代价推到了写入那一刻。

线程和进程在 Linux 里是同一种结构,区别只在 clone 传了哪些 CLONE_*。

还有一个词没接上,ps 里那一列 R、S、D、Z 到底在说什么,为什么有些进程 kill -9 都杀不掉。