Linux fork函数详解:返回值、COW写时复制与僵尸进程排查

发布时间:2026/10/27 4:56:17
Linux fork函数详解:返回值、COW写时复制与僵尸进程排查

第一次看到fork这个函数,我盯着那句“调用一次,返回两次”琢磨了挺久——一个函数怎么可能有两次返回?后来在内核源码里把kernel_clone到copy_process这条路走了一遍,才算真正把这件事想通。它难懂不是因为概念有多玄乎,而是因为它一脚踩在用户态 API 和内核调度器的交界处:你在 C 代码里看到的只是一行函数调用,背后却是整个进程地址空间的复制、页表的重新登记、调度实体的插入。Linux 的 fork 函数几乎是所有多进程程序的起点,从 shell 执行命令、Nginx 拉起 worker、到你自己写的批量任务分发脚本,底层都是同一套东西。这篇文章我想写给两类人:一类是刚学完操作系统课本、知道“fork 会复制进程”但一写代码就蒙的同学;另一类是在项目里被僵尸进程、缓冲区重复输出、子进程不受控这些问题咬过、想把这套机制彻底捋顺的开发者。我会从内核实现路径、返回值语义、代码实操、参数取舍一路讲到排查实录,代码都能直接跑,坑也都标出来。

1. fork 的本质:一次调用为何有两次返回

1.1 从用户态看,它只是三个整数之一

从调用者角度看,fork的接口简单到几乎没有任何可配置项——没有参数,没有任何 flag,只返回一个pid_t。这个返回值分三种情况,必须先记牢,因为后面 90% 的 bug 都源于这里判断错了:

返回值出现在哪个进程含义
> 0父进程这个值是刚创建出来的子进程 PID
== 0子进程说明“我就是那个新进程”,没有别的信息
== -1父进程创建失败,原因看errno

有一点很容易被忽略:子进程拿到的 0 不是“子进程的 PID 是 0”,而是一个标记位。子进程想知道自己的 PID 得调getpid(),想知道爹是谁得调getppid()。我见过不少人写成if (pid > 0) { /* 子进程 */ },逻辑反了还不报错,程序在多核机器上跑出来的结果飘忽不定,排查半天才发现是分支写反。

#include <stdio.h> #include <unistd.h> #include <sys/types.h> int main(void) { pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } else if (pid == 0) { printf("child : pid=%d ppid=%d\n", getpid(), getppid()); } else { printf("parent: pid=%d child=%d\n", getpid(), pid); } return 0; }

编译就用gcc -Wall -O0 fork_demo.c -o fork_demo,加-Wall是习惯,编译器对fork相关的返回值未检查会给出提示,能挡掉一批低级错误。

1.2 内核里那条路:从系统调用到copy_process

用户态的一次fork()最终会落到内核的sys_fork(x86-64 上走__x64_sys_fork),再进到kernel_clone。这个名字在不同内核版本里变过:老版本叫_do_fork,5.x 之后统一成kernel_clone。真正干重活的是copy_process,它按顺序做了这么几件事:

  • dup_task_struct:复制一份task_struct和内核栈,此时父子进程的描述符已经存在,但内容还很“空”。
  • 一系列权限与配额检查:RLIMIT_NPROC限制、系统级线程数上限、用户进程数上限,任何一项超了就直接返回错误。
  • sched_fork:给新任务初始化调度实体,把它挂到调度器上,但此时还没真正运行。
  • copy_files、copy_fs、copy_sighand:这几项大多是增加引用计数而不是深拷贝。打开的文件表、信号处理函数表、工作目录这些,父子共享同一份内核对象。
  • copy_mm:这是最重的一步,复制内存描述符、遍历所有 VMA、复制页表,并把父子双方的页表项统统改成只读。
  • alloc_pid:分配一个新的 PID。
  • wake_up_new_task:把子进程放进运行队列,等调度器挑中它。

父进程在kernel_clone返回处拿到子进程 PID;子进程不是“返回”到这个位置,而是被调度到时从ret_from_fork这条栈帧直接跳回用户态,返回值寄存器被填成 0。所以“一次调用两次返回”这个说法其实有点误导——它不是同一个函数返了两遍,而是两个独立的执行流各自从内核态穿越回用户态,只是它们穿越前在同一段内核代码里待过。

1.3 为什么不做全量拷贝:COW 的取舍逻辑

如果fork真的把父进程几百 MB 内存整块复制一遍,那创建一个进程的开销会大到没法用。早期的确是这个思路,fork后紧跟execve的场景浪费极其严重——复制了一大堆马上要被覆盖的内存。写时复制(Copy-On-Write,简称 COW)就是为了解决这个浪费:copy_mm复制页表时,父子进程的页表项指向同一批物理页,只是把这些页的权限位从可写改成只读,同时在task_struct里把页标记成 COW 页。

真正的复制推迟到有人写的那一刻。任意一方写入触发缺页异常,内核发现这是个 COW 页,才分配新物理页、把原内容拷过去、改回可写,然后重新执行那条指令。这个设计的精妙之处在于:只读的父子进程几乎零成本,只有真正被修改过的页才付出复制代价。

但 COW 不是没有成本。fork的耗时和进程当前占用的虚拟内存成正比,因为页表本身要复制。粗算一下:x86-64 上普通页是 4KB,一个占 1GB 内存的进程大约有 26 万个页表项,每个 PTE 8 字节,光页表数据就是 2MB 左右,再加上遍历 VMA 的时间。所以你会发现一个现象——空进程fork微秒级就完了,几百 MB 的服务进程fork一次可能要好几个毫秒。这个结论直接影响了后面的选型判断。

注意:COW 让父子共享物理页,但逻辑上是两个独立的地址空间。子进程里的malloc返回值在父进程里没有任何意义,别指望用这个地址跨进程传数据,那需要共享内存或者管道。

2. 执行流、竞争与继承:那些课本上不讲清楚的事

2.1 父子谁先跑,答案是“不知道”

fork返回之后,父进程和子进程谁先被 CPU 执行,完全由调度器决定,没有任何顺序保证。在多核机器上,两个进程大概率是真并行;在单核上,时间片怎么分也看调度策略。这意味着任何依赖“父进程先执行完再轮到子进程”的代码都是错的。

我踩过的第一个坑就出在这里:一个父进程里 fork 之后立刻往共享的文件描述符写数据,子进程也写,结果两次实验跑出来的内容顺序完全不一样。当时以为是文件系统的问题,其实是执行顺序不确定。正确的处理方式是显式同步——用waitpid等子进程结束,用管道通信,用信号量互斥,而不是靠“大概会先跑我”。

#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(void) { pid_t pid = fork(); if (pid == 0) { printf("child working\n"); _exit(0); } int status = 0; waitpid(pid, &status, 0); printf("child exited, status=%d\n", WEXITSTATUS(status)); return 0; }

子进程退出用_exit而不是exit,这是个细节但很重要。exit会执行atexit注册的函数、刷新 stdio 缓冲区,而这些缓冲区是从父进程继承来的,如果父进程 fork 前有没刷新的内容,子进程exit时又刷一遍,输出就重复了。_exit直接走系统调用,跳过所有这些用户态清理,干净利落。

2.2 文件描述符继承:共享的不只是“编号”

fork之后子进程会得到父进程所有打开文件描述符的副本,但这里有个特别容易误解的地方:复制的是描述符,不是文件对象。父子双方各自的 fd 编号可能不同,但它们指向同一个struct file,而struct file里存着文件偏移量。

后果很直观:父进程读了 100 字节,子进程接着读,会从第 101 字节开始,而不是从头读。写文件同理,两边交替写会互相覆盖偏移。我在做一个日志切分的小工具时就被这个咬过,父子都往同一个日志文件追加,结果日志里出现了大片空洞——因为两边都在用lseek挪同一个偏移量。

想避免这个问题,常规做法是:

  • 父子需要独立读写位置时,子进程 fork 之后自己重新open一次文件;
  • 或者应用层用O_APPEND打开,让每次写都由内核原子地定位到文件末尾;
  • 或者干脆别让多个进程写同一个文件,改成各自写各自的,后续再合并。

除了文件描述符,子进程还会继承:当前工作目录、信号处理方式(SIG_IGN和自定义处理函数都继承,但未处理的信号会重置为默认)、umask、环境变量、资源限制。这些在写守护进程的时候会集中爆发问题。

2.3 多线程进程里 fork 的隐藏雷区

这是我认为最需要单独拎出来说的一条。fork只会复制调用它的那个线程,其他线程在子进程里全都不存在。但问题来了:那些线程持有的互斥锁、条件变量、读写锁,状态是被原样复制过去的。如果 fork 的瞬间某个锁正被另一个线程持有,那么在子进程里,这把锁永远处于“被锁住”的状态,而持有者已经不存在了,任何试图加锁的代码都会永久阻塞。

规避方式有三条路,按推荐程度排:

  1. fork 完立刻 execve。这是最安全的姿势,execve会把整个用户态地址空间换掉,锁的状态也一并消失。绝大多数“fork 后要干事”的场景,其实都该走这条路。
  2. fork 之前保证没有其他线程在跑,比如在程序启动早期就把需要 fork 的逻辑做完。
  3. 用pthread_atfork注册 prepare/parent/child 三个钩子,在 fork 前后手动加解锁。这套机制能解决问题,但很容易写成死锁,尤其是涉及多把锁的时候,加锁顺序稍不注意就翻车。

提示:如果你的程序用了线程池,同时又有 fork 子进程的需求,请优先考虑把子进程部分改成独立进程或者独立的服务,不要在同一进程里混用。混用带来的调试成本远高于拆开的改造成本。

3. 动手实操:从最小 Demo 到可用的进程管理

3.1 循环 fork 的 2 的 n 次方陷阱

先看一段看起来人畜无害的代码:

for (int i = 0; i < 3; i++) { fork(); printf("pid=%d, i=%d\n", getpid(), i); }

很多人以为这会产生 3 个子进程加 1 个父进程。实际结果是 8 个进程。原因在于:fork之后父进程继续循环,子进程也从 fork 的下一行继续执行,于是它也进入下一轮循环并且再次 fork。每一轮都把当前进程数翻倍,3 轮就是 2³ = 8。

这个行为本身没错,是很多人对“子进程从哪开始执行”的理解有偏差。子进程不是从main重头开始,而是从fork返回点继续。

如果确实需要创建 N 个并列的工作进程(比如预派生模型),正确写法是在子进程里立刻跳出循环:

#define WORKERS 4 for (int i = 0; i < WORKERS; i++) { pid_t pid = fork(); if (pid < 0) { perror("fork"); break; } if (pid == 0) { worker_loop(i); /* 子进程干活,不再进入下一轮 */ _exit(0); } } /* 父进程在这里统一 wait */

关键就是if (pid == 0)分支里干完活直接_exit,绝不落回循环体。这个模式叫预派生(pre-fork),Nginx 的 worker 模型、老式 Apache 的 prefork 都是这个思路。好处是每个 worker 独立,坏处是 worker 数在启动时就固定了,得配合平滑重启来调整。

3.2 用 fork + execve 写一个迷你 shell

想真正理解 fork,最好的练习是写一个能跑命令的 shell。它的核心逻辑只有四步:读一行、切参数、fork、子进程 exec。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/wait.h> #define MAXLINE 1024 int main(void) { char line[MAXLINE]; while (fgets(line, sizeof(line), stdin) != NULL) { line[strcspn(line, "\n")] = '\0'; if (line[0] == '\0') continue; if (strcmp(line, "exit") == 0) break; char *argv[64]; int argc = 0; char *tok = strtok(line, " "); while (tok != NULL && argc < 63) { argv[argc++] = tok; tok = strtok(NULL, " "); } argv[argc] = NULL; pid_t pid = fork(); if (pid < 0) { perror("fork"); continue; } if (pid == 0) { execvp(argv[0], argv); perror("execvp"); /* 只有 exec 失败才会走到这里 */ _exit(127); } int status = 0; waitpid(pid, &status, 0); if (WIFEXITED(status)) { printf("[exit %d]\n", WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { printf("[killed by signal %d]\n", WTERMSIG(status)); } } return 0; }

几个值得说的点。execvp会自己去PATH里找可执行文件,所以直接敲ls就能跑。execvp成功的话永远不会返回,返回值只在失败时有用,失败后必须_exit(127),用 127 是 shell 界的惯例,表示“命令没找到”。waitpid拿到的status是个打包值,不能直接当退出码用,得用WIFEXITED、WEXITSTATUS、WIFSIGNALED这些宏去拆。我第一次写的时候直接printf("%d", status),输出 256 让我困惑了很久——其实真实退出码是 1,因为退出码存在高 8 位。

这个 shell 还很粗糙,不支持管道、重定向、后台任务,但骨架已经对了。想支持管道,就需要在 fork 之前先pipe(),然后两个子进程分别把dup2把读端写端接到标准输入输出上,这是个很好的练手项目。

3.3 并行任务分发:怎么定并发数

再给一个更贴近实际工作的例子——把一批耗时任务拆给多个子进程并行跑。这个模式在数据预处理、批量转码、压测脚本里特别常见。

#include <stdio.h> #include <unistd.h> #include <sys/wait.h> #define JOBS 4 static void do_work(int id) { sleep(1); /* 模拟耗时计算 */ printf("job %d done by pid %d\n", id, getpid()); } int main(void) { pid_t pids[JOBS]; for (int i = 0; i < JOBS; i++) { pid_t pid = fork(); if (pid < 0) { perror("fork"); pids[i] = -1; continue; } if (pid == 0) { do_work(i); _exit(0); } pids[i] = pid; } for (int i = 0; i < JOBS; i++) { if (pids[i] > 0) { int status = 0; waitpid(pids[i], &status, 0); printf("child %d reaped, status=%d\n", pids[i], WEXITSTATUS(status)); } } return 0; }

并发数怎么定?我的经验是别超过 CPU 逻辑核数。纯计算型任务,进程数等于核数时吞吐最高,再多只会增加上下文切换和内存压力。可以用sysconf(_SC_NPROCESSORS_ONLN)拿到当前可用核数,再乘一个系数:

  • I/O 密集型任务(等待磁盘、网络),系数取 2 到 4;
  • CPU 密集型任务,系数取 1;
  • 每个子进程内存占用大(比如几百 MB),还得反过来压低数量,防止触发 OOM。

另外要注意waitpid的回收顺序。上面这段代码是按创建顺序回收的,如果第 0 个任务特别慢,后面三个早就跑完了也得等着。想做到“谁先完谁先回收”,用waitpid(-1, &status, 0)循环回收任意子进程,或者干脆用SIGCHLD信号处理。

4. 资源、参数与选型:什么时候该用 fork,什么时候不该

4.1 fork 失败的两种典型 errno

fork返回 -1 时,errno最常见的两个值是EAGAIN和ENOMEM,含义完全不同,排查方向也不一样。

EAGAIN表示“资源暂时不够”,通常是进程数配额打满了。要检查几个地方:RLIMIT_NPROC(用ulimit -u看)、系统级kernel.pid_max、kernel.threads-max,还有 cgroup 里的pids.max。在容器环境里跑,最容易撞的是 cgroup 的 pids 限制,从宿主机上看一切正常,容器里怎么都 fork 不出来。我遇到过的一次就是容器 pids 上限设得太小,业务里但凡有个 fork 都失败,日志里只留一行“Resource temporarily unavailable”,查了半天才定位到配额。

ENOMEM则是内存不够。注意这不一定是物理内存耗尽,也可能是因为 COW 机制下内核需要为新进程分配task_struct和内核栈,同时在复制页表时需要申请内存。内存紧张时,一个本身不大的进程 fork 也可能失败。这时候该看的是dmesg里有没有 OOM 相关记录,以及/proc/meminfo的CommitLimit和Committed_AS。

堆栈上还有一个坑:内核栈默认不大(历史上是 8KB,某些配置下更小),fork本身不占用户栈,但如果你的代码在fork前递归很深,子进程复制的是整个地址空间的映射,虽然 COW 不会立刻复制物理页,但虚拟地址空间的布局是完全一致的,栈溢出风险同样被继承过去。

4.2 fork、vfork、clone、posix_spawn 怎么选

这四个东西经常被放在一起比较,但它们的定位差别很大:

接口地址空间典型用途注意事项
fork完全复制,COW通用进程创建大内存进程下耗时随内存增长
vfork与父进程共享,父进程阻塞fork 后立刻 exec 的老场景子进程不能 return,只能_exit或 exec
clone可精细控制共享哪些资源实现线程、命名空间隔离参数复杂,直接用的场景不多
posix_spawn内部实现可能用 vfork/clone单纯想“起个程序”接口受限,不适合 fork 后跑自定义逻辑

选型上的判断其实很简单:如果你 fork 之后马上就要 execve,那就用posix_spawn,它在 glibc 里会走clone(CLONE_VM | CLONE_VFORK),省掉整个页表复制的开销,在大内存进程里差距非常明显。我在一个占内存接近 1GB 的服务里把fork + exec换成posix_spawn,创建子进程的耗时从毫秒级降到了几十微秒级,效果立竿见影。

如果 fork 之后子进程要跑一段自己的 C 逻辑(改环境、设权限、接着算),那还是老老实实fork。vfork不要碰,它的约束太苛刻,稍微写错一点就是未定义行为,而且现代 glibc 的fork已经在很多场景下优化得够好了。

如果你需要的是“共享地址空间的并发”,那你其实想要的是线程,应该用pthread_create,而不是clone。clone是给需要做命名空间隔离、实现容器运行时这类底层工具准备的。

4.3 内存开销的实测感受

我自己做过一个粗略的对比测试,在同一台机器上让一个进程分别占用 10MB、100MB、500MB 匿名内存,然后各 fork 1000 次,记录平均耗时。数量级上的结论是:10MB 时 fork 大约在几十微秒,100MB 时涨到一两百微秒,500MB 时到了毫秒级。这个增长主要来自页表复制和 VMA 遍历,跟写入多少数据无关。

这个结论有很实际的指导意义:如果你的服务常驻内存很大,就不要在热路径上频繁 fork。要么改成常驻的 worker 池,启动时一次性 fork 好,之后靠任务队列分发;要么改用线程池;要么在 fork 前尽量减少不必要的内存映射。反之,如果进程很小(比如一个命令行小工具),fork 的开销基本可以忽略,不用过度设计。

5. 常见问题与排查实录

5.1 僵尸进程:不是“没死透”,是“没人收尸”

子进程结束后,内核不会立刻把它的task_struct全部释放,因为父进程可能还需要读取退出状态。这个状态叫僵尸态(Z)。只要父进程调用了wait或waitpid,状态被读取,资源才会真正回收。

如果父进程一直不回收,僵尸会越积越多。单个僵尸占的内存不多(主要是一个task_struct加内核栈),但 PID 是有限的,数量堆上去之后新的 fork 就会返回EAGAIN。用ps aux | grep ' Z'能一眼看到,或者ps -eo stat,pid,ppid,comm | awk '$1 ~ /^Z/'。

处理方式有几种,按场景选:

  • 同步回收:waitpid(pid, &status, 0),适合明确知道要等哪个子进程的场合。
  • 异步回收:注册SIGCHLD处理函数,在处理函数里循环waitpid(-1, NULL, WNOHANG)直到返回 0 或 -1。注意SIGCHLD可能合并,所以必须用循环 +WNOHANG,不能只 wait 一次。
  • 主动放弃:如果确实不关心子进程结果,可以显式忽略:signal(SIGCHLD, SIG_IGN),在某些系统上这会让子进程退出时自动被回收,不用父进程操心。但要注意这会影响到所有子进程,其他需要waitpid的地方就拿不到状态了。
#include <signal.h> #include <sys/wait.h> #include <unistd.h> static void on_sigchld(int sig) { (void)sig; int saved_errno = errno; while (waitpid(-1, NULL, WNOHANG) > 0) { /* 循环回收所有已退出的子进程 */ } errno = saved_errno; } int main(void) { struct sigaction sa; sa.sa_handler = on_sigchld; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; sigaction(SIGCHLD, &sa, NULL); /* ... 业务逻辑 ... */ return 0; }

信号处理函数里保存和恢复errno是个细节,因为信号随时可能打断正在进行的系统调用,处理函数里如果改了errno,回到被打断的地方逻辑就乱了。SA_RESTART的作用是让被信号打断的系统调用自动重启,减少EINTR的处理负担。

另一个容易被忽略的角色是孤儿进程。父进程先于子进程退出,子进程就被“过继”给init(PID 1,或者某些系统里的 subreaper),由它负责回收。这本身不是问题,但如果你的程序里子进程依赖父进程做清理工作,父进程一死子进程就成了没人管的野进程。做守护进程或者热重启的时候要特别小心这一点。

5.2 缓冲区重复输出:一个经典到不能再经典的坑

这段代码,把输出重定向到文件,你会看到两行hello:

#include <stdio.h> #include <unistd.h> int main(void) { printf("hello"); /* 没有换行 */ fork(); return 0; }

原因在于 stdio 的缓冲策略。输出到终端时是行缓冲,遇到\n才刷;输出到文件时是全缓冲,缓冲区满了才刷。printf("hello")没有换行,内容就躺在用户态缓冲区里没发出去。fork复制地址空间时,这份缓冲区内容原样复制到了子进程。父子进程各自正常退出,exit触发刷新,于是同一个 "hello" 被写了两次。

这个坑的隐蔽之处在于:同样的代码在终端上跑是正常的,一重定向到日志文件就出问题。所以排查时如果发现“日志里怎么有重复行”,先看看是不是在fork前有没刷新的输出。

规避手段很成熟:fork之前调用fflush(NULL)把所有的输出缓冲刷干净;或者子进程一律用_exit而不是exit;或者在每个printf后面老实加\n。这三条里我推荐后两条组合使用,_exit应该成为肌肉记忆。

5.3 常见问题速查表

把上面这些以及一些零散的问题汇总一下,方便对着查:

现象最可能的原因排查/解决
fork返回 -1,EAGAIN进程数达到RLIMIT_NPROC或 cgroup pids 上限ulimit -u,查容器 pids.max
fork返回 -1,ENOMEM内核内存不足,无法分配task_struct或页表看dmesg、/proc/meminfo
子进程跑完不退出,ps显示Z父进程未回收加waitpid或SIGCHLD处理
日志出现重复行fork 前 stdio 缓冲区未刷新fork 前fflush(NULL),子进程用_exit
循环里 fork 出 2^n 个进程子进程落回循环子进程分支结束后立刻_exit
父子写同一文件内容错乱共享文件偏移量用O_APPEND,或各自 open,或分文件
子进程里加锁永远阻塞多线程下 fork,锁被其他线程持有改成 fork 后立即 exec,或用pthread_atfork
子进程变成孤儿无人清理父进程先退出用 subreaper 或专门的 supervisor
子进程继承了不该有的 fdfd 默认被继承fork 后关闭无关 fd,或用O_CLOEXEC

最后一条值得展开说一句。O_CLOEXEC这个标志非常有用:打开文件或套接字时带上它,这个 fd 会在 exec 时自动关闭,避免子进程意外持有不该持有的连接。在长连接多的服务里,如果 fork 出来的子进程继承了所有 socket,一方面浪费 fd,另一方面连接关闭的时机变得难以判断。从代码层面统一给open、socket、pipe2加CLOEXEC是个好习惯,成本几乎为零。

还有一个排查技巧:不确定子进程到底继承了哪些 fd,可以在子进程里ls -l /proc/自进程PID/fd,一眼看清楚。同理,/proc/PID/status里的Threads、VmRSS,/proc/PID/task/下的线程列表,都是排查这类问题的一手信息。养成看/proc的习惯,比装一堆工具快得多。

6. 我在实际项目里攒下的几条经验

写到这里,分享几个文档里不太会提、但确实节省过时间的做法。

第一,给每个 fork 出来的子进程起个可识别的名字。Linux 上可以调prctl(PR_SET_NAME, "worker-xxx"),这样ps和top里就能看明白哪个进程在干什么,而不是一堆同名进程排在那。出问题时省下来的定位时间远超当初写这几行代码的成本。

第二,在子进程的入口处打一行带 PID 的日志,并且立刻setpgid或调整会话。这能让日志里的 PID 和进程树对应起来,尤其是在子进程会再 fork 的场景下,没有这层标记,日志会乱成一锅粥。做后台任务的时候顺手把子进程放到独立的进程组,后面想整组发信号终止也方便。

第三,不要吝啬waitpid的循环写法。我见过太多“只 wait 一次”的代码,在子进程密集退出的场景下漏回收,慢慢积累成僵尸。统一写成while (waitpid(-1, &st, WNOHANG) > 0),配合SA_RESTART,这套模板可以直接抄。

第四,大内存进程优先考虑换掉模型。如果发现每次 fork 都有明显卡顿,先别急着调内核参数,问问自己“这个 fork 真的必要吗”。预派生进程池、线程池、独立的常驻服务,这三种方案在绝大多数场景下都比“按需 fork”更稳。

fork这个接口看着古老,但它承载的语义——地址空间复制、文件描述符继承、执行流分叉——至今没有被替代。把它的返回值、COW、回收机制这三件事吃透,后面再看clone、看容器运行时怎么隔离命名空间、看 shell 怎么执行管道,都会顺很多。我现在写涉及多进程的代码,开工前会先在纸上画一遍父子关系,标清楚哪个 fd 谁负责关、哪条路谁负责 wait,画完之后 bug 就少了一大半。