ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux fork写时拷贝与进程终止:从COW到僵尸进程

Linux fork写时拷贝与进程终止:从COW到僵尸进程 你要是写过 Linux 下面的 C 程序大概率绕不开fork。这个系统调用很特别调用一次返回两次把当前进程几乎原封不动地拷贝出一份副本然后父子各自继续往下走。初学者容易把它理解成“复制一份进程”实际上现代 Linux 的fork根本没做那种笨重的全量内存复制而是靠一套写时拷贝Copy-on-WriteCOW机制把成本压到极低。与之对应的是进程终止有人觉得结束一个进程无非就是return或者exit但里头的细节同样不少exit、_exit、exit_group、信号终止、僵尸进程一环扣一环。这篇文章就从fork讲到进程终止把中间那层纸捅破适合刚接触 Linux 系统编程的人也适合写服务端代码时被各种诡异问题折腾过的运维和开发。1. fork 之前虚拟内存和页表是怎样给写时拷贝铺路的1.1 进程为什么能“假装自己独占内存”在聊写时拷贝之前必须先厘清一个基础模型进程到底是怎么“拥有”内存的Linux 里每个进程都有自己的虚拟地址空间从 0 到用户态上限看起来像一整块连续的内存。但虚拟地址不是物理地址进程访问任何一个地址时CPU 会通过页表把虚拟地址翻译成物理地址。页表就是一张映射表记录了虚拟页和物理页的对应关系以及这个页的权限位。你可以把页表理解成图书馆的索引系统你找一本书用的是书名虚拟地址管理员按索引号到书架上去取物理页。书没有真的跑到你面前但它看起来就在你手上。这意味着一个重要事实两个不同的进程完全可以通过各自的页表映射到同一个物理页。只要这两个页表都把某个虚拟页的权限标记为“只读”这两个进程就都能安全地读同一份物理内存谁也别想破坏它。这个思路就是后面写时拷贝的根基。1.2 传统 fork 的两种错误理解我见过不少人把fork理解成两种极端。一种认为fork会把父进程所有内存原样复制一份给子进程父子彻底分离所以很慢另一种则认为fork只是创建了一个新的 task_struct 和 pid内存完全共享所以很快。这两种说法都不全对。历史上早期 Unix 的fork确实会全量拷贝物理内存成本很高这也是后来vfork和写时拷贝技术出现的原因。而现代 Linux 的fork介于两者之间它必然要复制父进程的页表结构因为子进程需要一套自己的虚拟地址映射但它不复制物理页的内容。父子进程的页表项在一开始几乎都指向同一个物理页并且这些页被统一标记为只读。谁想改这些页谁就得触发一次缺页异常由内核在异常处理里把真正的拷贝补上。所以不要觉得fork没花钱它至少花了一份复制页表的钱。但页表本身通常很小比起把所有物理内存都过一遍这个开销可以忽略不计。理解这个模型之后再看什么fork之后exec很快、大进程fork不卡顿之类的话就都说得通了。2. 写时拷贝COW核心机制与细节拆解2.1 COW 的核心思路标记只读写时再复制写时拷贝的思想用一句话概括不急着复制先共享谁要写谁自己掏钱。在fork完成的那一刻父子进程的虚拟地址空间内容是一样的所以没必要立刻复制物理页。内核只需要把双方页表里的相关页都标记成只读然后让它们指向同一个物理页即可。之后如果父进程一直没写某个页子进程也一直没写那这个共享状态就能一直维持下去物理内存只存一份数据。一旦其中一方尝试写这个只读页CPU 就会触发写保护缺页异常page fault。内核在异常处理函数中看到这个页是一个私有的、有写时拷贝标记的页就会做这几件事分配一个新的物理页。把旧物理页的内容复制到新物理页上。更新当前进程的页表让对应虚拟页指向新物理页并把权限改成可写。旧物理页仍然保留给另一个进程继续处于只读状态。这个流程下来只有真正被写入的页会被复制其它页原来的共享关系完全不受影响。生活里也好理解图书馆的书大家都能翻如果你想在上面做笔记那就得先去复印一份自己的副本原书还留在图书馆里给别人看。这里有一个容易忽略的点发生写时拷贝时触发异常的那个进程拿到的是新页没写数据的那一方继续保持旧页。所以后续父子俩的同一个虚拟地址可能指向完全不同的物理页各自修改互不影响。这也是写时拷贝能保证fork语义完备性的关键。2.2 页表项里的权限COW 是怎么标记的如果你翻过 x86 的页表结构会发现每个页表项PTE里有若干标志位比如 Present 位、读/写位、用户/超级用户位。写时拷贝的关键操作就是把 R/W 位清掉让这个页变成只读。但仅仅只读还不够内核还需要区分“本来就是只读的页”和“本来可写、只是被临时标记成只读以便做 COW 的页”。后者在 Linux 源码里通过页表项或 VMA 的标记来识别具体到不同架构实现略有差异。比如 x86 上已经废弃的_PAGE_BIT_RW配合VM_SHARED、VM_WRITE这些 VMA 标志一起判断缺页处理函数里负责这个逻辑的是do_wp_page。这里有个实际影响不是所有页都能做写时拷贝。文件映射的页如果以共享方式映射那是另一套语义而通过mmap私有映射创建的页或者是匿名内存页才是写时拷贝的主要对象。你在写程序时基本不用关心这个区分但排查mmap导致的问题时知道这一点会有帮助。2.3 COW 的性能收益和局限写时拷贝最直接的好处是fork变快了。fork的时间不再正比于进程物理内存占用而主要正比于页表大小。对一个占用 4GB 内存的进程来说如果不做写时拷贝fork可能要花几十毫秒甚至更久有了写时拷贝多数情况下几毫秒就完成了。空间上收益更明显。最典型的场景是fork之后立即exec子进程直接丢弃了自己继承来的所有地址空间重新加载一个新程序。这个过程里几乎不发生任何物理内存拷贝父子双方共享的页面在exec之后由于映射关系重建自然而然地“解绑”了。Shell 执行一条命令本质上就是forkexec的连环操作如果没有写时拷贝每条命令都要白白付出一份复制内存的开销系统会慢得不可想象。不过写时拷贝也不是免费的。只要有写操作发生终究要把物理页复制一遍。如果一个进程在fork之后父子双方都疯狂写入大量数据那缺页异常会频繁触发每次异常都伴随内存分配和数据拷贝整体开销反而可能比一次性fork全量拷贝更高。所以fork之后尽量避免大量写操作或者干脆走vforkexec是性能敏感场景下的一个实用原则。3. 从 fork 到 exec实操代码与生命周期观测3.1 fork 的返回值与常见误用fork的返回值设计很巧妙但它也确实是很多人第一次写错的地方。调用fork之后代码会有两个执行流返回父进程中fork返回子进程的 PID。子进程中fork返回 0。如果创建失败父进程中返回 -1。为什么要这么设计因为父进程需要一个句柄来管理和等待子进程而子进程通过判断返回值是 0知道自己走的是“子进程分支”。下面这个是最基本的写法#include stdio.h #include unistd.h #include sys/wait.h int main(void) { pid_t pid fork(); if (pid 0) { perror(fork); return 1; } if (pid 0) { printf(child: getpid%d, parent%d\n, getpid(), getppid()); } else { printf(parent: child pid%d, my pid%d\n, pid, getpid()); } return 0; }新手容易犯的几个错误我几乎每次帮人看代码都能撞见。第一个是把pid 0写成pid 0这是个经典的赋值误用判断永假。第二个是不检查pid 0如果系统资源不足后续代码可能在子进程不存在的情况下继续跑。第三个更隐蔽在fork之后父子进程都在运行相同代码却没有用pid分支隔离导致一段本应在父进程执行的逻辑被重复执行两次于是输出翻倍、文件写两遍、信号处理函数注册两遍各种诡异问题都是这么来的。3.2 如何观察写时拷贝RSS 的真实变化理论讲多了容易飘最好实际动手看一次 COW 的效果。这里给出一个实用的验证方法程序里先分配一块足够大的内存并填充数据然后fork。在父进程中我们不做任何写入只观察内存占用在子进程里写一个字节再看内存占用。如果写时拷贝生效子进程写入前 RSS 基本不会增加写入后也仅仅增加一个页通常是 4096 字节左右的量而不是整块内存翻倍。可以先写个简单的 C 程序#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/wait.h #define ARRAY_SIZE (1024 * 1024 * 64) /* 64MB */ int main(void) { char *buf malloc(ARRAY_SIZE); memset(buf, 0xAA, ARRAY_SIZE); pid_t pid fork(); if (pid 0) { perror(fork); return 1; } if (pid 0) { /* 子进程只写一个字节 */ buf[0] 0xBB; printf(child: after one-byte write\n); pause(); return 0; } sleep(2); printf(parent: pid%d\n, pid); wait(NULL); free(buf); return 0; }运行之后在另一个终端查看子进程的VmRSScat /proc/$(pgrep -P $PARENT_PID)/status | grep VmRSS预期结果是fork之后子进程的 RSS 几乎不变写完一个字节之后也不过增加了一个内存页的体量。这是写时拷贝最直观的证据。顺便一提/proc/pid/smaps里有Private_Clean、Shared_Clean、Private_Dirty等字段能更细致地看到哪些页是共享的、哪些页是私有复制的进阶玩家可以研究一下。3.3 fork exec 的黄金搭档讲完fork一定要提exec。exec系列函数做的事情是用一个新的程序镜像替换当前进程的地址空间。对子进程来说fork之后立刻exec是最常见的操作exec一旦成功之前继承来的所有页映射都会作废写时拷贝的“账”也就一笔勾销。用strace可以看得很清楚。随便执行一条命令比如strace -f -e traceclone,execve -o /tmp/trace.log bash -c ls打开/tmp/trace.log会看到典型的clone新内核里fork也是通过clone实现、execve交替出现。这就是“Shell 起一个外部命令”的底牌先复制一个自己再把自己变成目标程序。这里有一个值得养成的习惯如果明确知道子进程接下来要exec在fork之后子进程里就别做太多多余操作尤其是不要malloc、不要写日志、不要做任何可能触发写时拷贝的工作。先exec再说。不然这些无用功不仅浪费 CPU还可能把继承来的全局状态搞脏引发难以排查的 bug。4. 进程终止常见退出方式与底层行为4.1 return、exit、_exit 到底有什么区别一个进程的终结方式在代码层面常有三种写法从main里return、调用exit()、调用_exit()。很多人分不清它们的区别这里系统地捋一遍。从main函数return的时候C 运行库会自动调用exit()把返回值和exit()的参数对接上。所以return 0;和exit(0);在绝大多数实现里效果一样。exit()是标准库函数它做两类事后清理工作一是调用atexit()注册的钩子函数二是刷新并关闭标准 I/O 缓冲区。这些事做完之后exit()再调用底层系统调用进入内核完成真正的终止。_exit()则完全是另一条路径。它是系统调用本身实际是exit_group直接陷进内核不做任何用户态的清理。缓冲区内容没刷新就没了atexit钩子也不会执行。那_exit()用在哪最典型的就是fork出来的子进程里。如果子进程在执行过程中出错你想让它立即终止又不希望输出缓冲区被额外处理一遍用_exit()比exit()更干净、更安全能避免一些子进程去刷新父进程留下的缓冲区造成重复输出。我印象特别深的是有一次排查一个服务进程的日志重复问题。父进程写日志用的是带缓冲的stdiofork之后子进程直接return结果子进程里的exit()把父进程缓冲区又刷了一遍同样的日志出现了两次。当时还没想到是这么回事后来在fork之前手动fflush(NULL)问题才消失。4.2 信号终止、段错误与 core dump进程不总是自己主动退出很多时候它是被系统或信号干掉的。一个进程只要还没有处理某个信号内核在收到该信号时就会按默认动作处理。比如SIGKILL不可被捕获也不可被忽略收到就死SIGSEGV默认动作是把进程终止同时可能生成一份 core dump 文件。SIGSEGV这个信号很有意思它经常和缺页异常混在一起。普通的内存申请不会立刻分配物理页而是等真正访问时才触发缺页内核在缺页处理中把页分配好并返回用户态进程毫无感知。但如果访问了非法地址比如野指针指向的位置缺页异常处理时发现这个地址根本不允许访问就会给进程发送SIGSEGV。写时拷贝也是缺页异常的一种它和非法访问共用一套内核路径区别在于内核检查 VMA 后判断这是合法的 COW 缺页还是非法访问从而决定“补上页”还是“发信号”。如果想让进程在崩溃时留下现场可以用ulimit -c unlimited打开 core dump。但实际运维中core dump 是双刃剑频繁崩溃的进程可能瞬间写满磁盘。生产服务器上建议在专门目录开启并配合回收机制。4.3 终结之路do_exit 与僵尸进程不管是主动调用exit还是被信号杀死进程最终都会进入内核的do_exit()路径。在这一步内核会释放进程占用的物理内存、关掉打开的文件描述符、处理信号关系然后把进程变成一个“僵尸”Zombie状态。你可能会奇怪都死了干吗还不回收那是因为内核必须把退出状态exit code留给父进程去读取。父进程通过wait()或waitpid()来收集子进程的退出状态内核在父进程成功wait之后才会彻底销毁对应的task_struct。所以死去但未被回收的进程在进程表里就是Z状态。僵尸进程本身不占内存但占进程表项。如果父进程从来不wait而且父进程又是个长期运行的程序那么它产生的所有子进程都会一直以僵尸形态存在。积累多了新的进程可能因为进程表满了而创建失败系统表现就是突然 fork 不动了。孤儿进程是另一条路径父进程先死子进程成了没爹的娃。这时内核会把这些孤儿进程的父进程重新设为initsystemd由它来负责wait回收。这也是为什么一个进程即使父进程挂了它最终也能被系统收走的原因。5. 常见问题与排查技巧实录5.1 僵尸进程杀不掉怎么办运维最常碰到的灵异事件之一进程已经显示Z了用kill -9都杀不掉。其实不是杀不掉是没必要杀它已经死了只是没人收尸。正确做法是找到它的父进程让父进程调用wait或者结束自己父进程一死孤儿就会被init收养并回收。排查命令不复杂ps -ef | grep defunct看到带defunct标记的进程顺着PPID找到父进程再检查父进程为什么一直没有wait。十有八九是父进程代码里没有调用waitpid或者因为某个事件循环阻塞住了。处理建议是在服务端程序里加SIGCHLD信号处理或者干脆循环里主动waitpid(-1, status, WNOHANG)。对大多数业务程序来说注册一个子进程退出回调把回收做好僵尸问题就能从根上避免。5.2 fork 后缓冲区、文件描述符、锁怎么处理写服务端代码时fork之后的资源继承问题特别容易踩坑。我把常见的几类列在一张表里遇到类似问题可以快速对照。资源类型fork 后的行为常见坑解决办法stdio 缓冲区子进程继承父进程缓冲区内容父子都刷新缓冲区导致重复输出fork 前fflush(NULL)子进程优先exec文件描述符子进程继承全部 fd端口被占用、文件句柄泄漏open时加O_CLOEXEC子进程主动关闭无关 fd锁互斥锁/文件锁子进程继承锁的当前状态锁的持有者仍是父进程子进程尝试加锁时死锁多线程下避免fork使用pthread_atfork清理信号处理器子进程继承父进程的信号处置子进程可能执行父进程的处理器逻辑exec后按需重新设置信号处理这些坑的根源都在于fork只是“复制状态”不是“重来一遍”。文件描述符表会复制所以父子都持有同一个 fd缓冲区内容会复制所以同一个输出可能被双方输出两次。理解这个原则很多问题都能自己推导出来。5.3 COW 会不会导致内存统计虚高有的人看ps内存占用会发现一个现象fork之后父子进程的 RSS 加起来比物理内存总量还大但系统内存又没爆。这正是写时拷贝的功劳——因为很多页是共享的RSS 统计只是把同一个物理页重复算进了多个进程的头上了。PSSProportional Set Size指标会把共享页按比例分摊计算所以在分析容器或服务端进程内存占用时PSS比RSS更有参考价值。/proc/pid/smaps里每个映射都有 PSS 字段可以配合grep和awk汇总一下awk /^Pss:/ {sum $2} END {print sum kB} /proc/$(pgrep -n bash)/smaps这个值反应单个进程实际分摊到的物理内存要更准确一些。5.4 多线程程序里 fork 的额外风险多线程环境下fork的坑更深因为子进程只继承了调用fork的那个线程其他线程全部消失。假设另一个线程正持有一把互斥锁而那个线程恰好在锁临界区内被“蒸发”了那么锁永远处于上锁状态。子进程如果继续去做需要该锁的操作就会直接卡死。很多人问怎么办我一般给三个办法第一多线程程序里尽量少用fork真要并发就开线程第二必须用fork的话子进程创建后立刻exec尽快脱离父进程的复杂状态第三如果确实要在子进程里做一些清理操作用pthread_atfork注册好钩子在里面把所有全局锁重新初始化。这是语言层面、操作系统层面都没有完美解法的问题最好在架构阶段就规避掉。6. 最后聊几句我踩过的坑写fork相关的代码我最大的体会是真正的难点不在于fork本身而在于“状态复制”带来的连锁反应。缓冲区、文件描述符、锁、信号处理器每一个都是隐形的雷。写服务端程序时我现在的习惯是能不用fork就不用需要派生子进程时一律posix_spawn或者fork后立刻exec把子进程需要做的事情全部放在exec的新程序里去做。测试 COW 的时候还有一个小技巧strace -f -e tracewrite,mmap,fork.加-f参数跟踪子进程能看到很多平时注意不到的系统调用顺序。比如进程退出时到底是调用了exit_group还是exit都能在 trace 里一清二楚地看到。遇到行为怪异的程序先上strace往往比猜半天代码更有效。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进