fork 到底复制了什么

fork 到底复制了什么
隔壁老康难度 ★★★☆☆ · 前置:inode 与硬链接:删了文件,空间为什么没降 | 一句话结论:
fork复制的是task_struct和页表,不是内存里的字节;两者共享物理页并全部标只读,谁写谁触发缺页、复制一页。exec换掉地址空间但 PID 不变。
排查一个内存相关的问题时,我顺手跑了个 cat 管道给 wc,两句话就把一个 2GB 的文件数完了,前后不到一秒,然后我盯着 top 看了好一会儿,因为两个进程的 RSS 都只有几 MB,谁的账上都没多出那 2GB,按管道要把数据从一端搬到另一端的理解,这 2GB 怎么也该在内存里经过一次。
1 | df -h /tmp | tail -1 # 先确认 /tmp 有 2GB 空 |
可内存根本没涨。
我当时的猜测是管道有某种魔法,能绕开内存直接把字节递过去,这个猜测后来被证明只对了一半,管道那层确实不搬数据(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),顺序大致是下面这七步。
- 复制
task_struct。dup_task_struct先从 slab 缓存分配新结构,再把父进程的字段逐个拷过来; - 换新身份。分配新 PID,改掉
pid和tgid;cred是实打实复制一份(copy_creds走prepare_creds),只有clone(CLONE_THREAD)造线程时才靠引用计数共享; - 处理内存,调
copy_mm; - 处理文件,
copy_files走dup_fd把 fd 表复制一份,copy_fs走copy_fs_struct复制工作目录/root/umask,都不是共享。文件偏移量之所以父子共享,是因为 fd 表里指向的是同一个struct file; - 处理信号,
copy_sighand新分配一份处理函数表再memcpy过来(pending自然是空的);只有clone(CLONE_SIGHAND)才是引用计数 +1; - 建立亲缘,把自己挂到父进程的
children链表上; - 唤醒,把新任务塞进调度队列,等被选中就能跑。
第三步单独占一行不是排版事故。
(这七步我第一次读源码时是跳着看的,因为它太像例行公事,后来才发现第三步就是全部的答案,前面六步都在搬几 KB 的记账信息。)
写时复制:复制页表,不复制字节
copy_mm 里有个开关,clone_flags & CLONE_VM。带这个标志的时候(clone 用来造线程的场景),新任务直接用父进程的 mm_struct,引用计数 +1,这就是线程,共享地址空间。不带的时候(普通 fork),分配一个新的 mm_struct,然后 dup_mmap() 遍历父进程的每一段 VMA(vm_area_struct,内核用来描述一段连续地址区间,代码段、数据段、堆、栈、映射区都各是一段),给每一段建好新的页表项,但页表项指向的还是父进程原来那批物理页,同时把这些页在两个进程里都标成只读。
所以 fork 返回的那一瞬间,两个进程的虚拟地址完全一样,指向的物理页也是同一批,真实内存占用几乎不动,只多了页表。
复制被推迟了,靠 CPU 的页错误机制补上。
graph TB A["fork 返回<br/>父子页表指向同一批物理页<br/>全部标只读"] --> B["子进程 write(栈上变量)"] B --> C["CPU 发现该页只读<br/>触发 page fault"] C --> D["内核 do_wp_page()<br/>确认是 COW 页"] D --> E["分配一个新物理页<br/>复制原页内容"] E --> F["把子进程页表改为指向新页<br/>恢复可写"] F --> G["write 指令重新执行,成功"] D -->|"父进程那边没被改"| H["父进程页表继续指向老页"]
这个优化叫 COW,copy-on-write,写时复制,它带来两个能观测到的后果,fork 很快,跟进程占多少内存基本无关;fork 之后的第一次写入会变慢,因为那时候才真拷。所以 fork 完立刻 exec 的写法特别划算,execve 会把整个地址空间丢掉,那些还没来得及复制的页直接作废,一次复制都不用做;反过来,如果 fork 出来的子进程要长期活着并且会写大量内存,比如某个服务端进程里 fork 出一堆 worker,COW 的代价就摊到运行期了,表现是莫名其妙的 page fault 尖峰。
返回值为什么有三个
1 | pid_t pid = fork(); |
子进程不知道自己是谁,但它不需要知道,所以返回 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 | Name: cat |
/proc/self 是个软链,指向打开它的那个进程自己的 /proc/<PID>,所以这里 Name 是 cat,Pid 就是 cat 的 PID。
VmSize 和 VmRSS 差这么多不是 bug,前者是虚拟地址空间总量(含没用到的栈、映射了但没碰的堆),后者是真正驻留的物理页,fork 出来的进程 VmSize 跟父进程几乎一样,VmRSS 才慢慢涨,这就是 COW 在账面上长什么样。
再量一次 COW,需要一按自己 pid 拼路径的辅助函数。
1 |
|
1 | gcc -O0 cow.c -o cow && ./cow # 把上面这段存成 cow.c;-O0 免得编译器把 big 优化掉 |
实测输出(数字随内核版本和内存情况浮动)。
1 | parent before fork: VmRSS: 65564 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 | PID PPID STAT COMMAND |
--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 都杀不掉。











