ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入理解Linux进程创建与回收:从fork到SIGCHLD与进程池

深入理解Linux进程创建与回收:从fork到SIGCHLD与进程池 在 Linux 下搞过服务端开发的人基本上都会被“进程的创建与回收”这件事教育过几回。创建听着简单不就是 fork 一下回收听着也不难不就是 wait可真到了线上进程像是野草一样疯长、ps 里冒出一堆 defunct、父进程忘了收尸结果句柄泄漏、服务跑着跑着就再也 fork 不出新进程……这些场景我一个个都踩过。这篇文章就沿着“进程创建”和“进程回收”这条主线往下走先讲清楚 fork 底层的写时复制逻辑再把 wait 和 waitpid 的细节磨明白最后聊一聊工程里真正在用的 SIGCHLD 回收姿势和进程池的设计思路。不管你是刚学 Linux 编程的新手还是已经在写服务端程序想补一补底层功课的老兵这应该都能帮上忙。1. 先想清楚进程到底是什么为什么创建和回收是配套动作1.1 程序是一份菜谱进程是端上桌的那道菜很多刚接触 Linux 的朋友会把“程序”和“进程”混为一谈但这俩关系其实很像“菜谱”和“端上桌的菜”。菜谱静静地躺在盘子里或者手机里什么都不会发生只有你照着菜谱动手做了才有一道热菜端在你面前。程序就是磁盘上那个可执行文件它是静态的进程是程序被加载到内存里跑起来之后的动态实体它有自己独立的 PID、独立的虚拟地址空间、独立打开的文件表还有自己的工作目录、环境变量和信号处理函数。开发中还有一个更准确的类比进程其实是一套“执行现场”。CPU 执行到哪里、栈上有什么临时变量、打开了哪些文件、标准输出指向哪个终端这些信息组成了一套上下文。Linux 内核调度一个进程的时候就是在这些现场之间反复切换。你理解了“现场”这个概念就会明白进程创建的本质不是“安装了一个新程序”而是“搭了一套新的执行现场”。1.2 创建进程等于“现场复制”回收进程等于“事后结账”在 Linux 上创建一个新进程最常见的底层动作是 fork。fork 的直译是“分支”它的行为也确实像岔路口从一个正在运行的父进程身上复制出一份几乎一模一样的子进程。子进程一开始的代码、数据、文件描述符都和父进程相同但它从此有了自己独立的 PID也和父进程走上了不同的路径。复制只是一半另一半收尾工作就是回收。很多新手以为进程退出之后内核就会自动把它的所有痕迹抹掉事实并非如此。进程退出的一瞬间它的大部分资源内存、文件、信号处理器确实会被内核释放掉但内核会刻意保留一个“残骸”里面记录着这个进程的退出状态、消耗的 CPU 时间等信息等着父进程来认领。这个残骸状态在 ps 里显示为 Z也就是僵尸进程zombie。父进程调用 wait / waitpid本质上就是去“收尸”取走退出状态然后告诉内核“行了这孩子我看完了你可以把最后那点记录也删了”。所以创建和回收永远是配套动作创建了多少个进程最后就应该回收多少个一个都不能少。2. 进程创建fork、vfork 与 exec 三兄弟2.1 fork整个 Unix 世界最经典的创建接口在 Linux 里创建一个进程主流办法就一个调用 fork。它的原型特别简单#include sys/types.h #include unistd.h pid_t fork(void);调用一次却会返回两次这也是很多初学者最懵的地方。fork 成功后父进程收到的是子进程的 PID子进程收到的是 0如果失败父进程收到的是 -1。通过这个返回值父子进程立刻就走进了不同的分支pid_t pid fork(); if (pid 0) { perror(fork failed); exit(1); } else if (pid 0) { /* 子进程执行到这里 */ printf(I am child, pid%d\n, getpid()); } else { /* 父进程执行到这里 */ printf(I am parent, child pid%d\n, pid); }这个“返回两次”的设计其实非常巧妙。子进程没有办法在 fork 之后立刻知道自己的 PID 是多少除非去调 getpid但那样还要再区分一次父子上下文内核干脆把结果直接塞进返回值里父进程得到比自己小的那个 PID 线索子进程得到一个 0两边都不需要额外系统调用就能立刻对号入座。2.2 fork 底层的写时复制不影响“上手即用”早期的 fork 实现是实打实地把父进程整个地址空间复制一份成本高得吓人。后来 Unix 设计者引入了写时复制Copy-On-WriteCOW机制fork 的时候并不急忙复制物理内存只是把父进程的页表复制给子进程并且把这些页标记成“只读”。父子任意一方真的去写某个页时才触发出错内核再针对那一个页做真正的复制然后把所有权分配给触发了写入的那一方。这个机制带来的直接体验就是你写一个 fork 程序感觉它好快因为大部分情况下它根本就没怎么复制内存。但它也埋了一个坑如果你 fork 之后马上在子进程里大量写内存尤其是一边 fork 一边初始化大数组那 COW 页错误会此起彼伏性能并不比原地复制好多少。所以工程上有一个老传统——fork 之后子进程应该立刻 exec 或者退出尽量不要在子进程里做太多“带写操作”的初始化。2.3 vfork一个共享地址空间的“远古特例”除了 forkLinux 还保留了一个 vfork 接口它和 fork 最大的区别是vfork 创建出的子进程完全共享父进程的地址空间并且父进程会一直阻塞直到子进程调用 exec 或 _exit。vfork 的设计初衷是为了配合 exec 用的因为子进程马上要替换成全新程序那 fork 阶段复制地址空间就纯属浪费干脆先共享着用。但 vfork 在工程里是个危险品。你想父子共享同一个地址空间而且父进程被冻结住子进程一旦改了某个变量等父进程恢复执行时看到的也是被改过的值。这就导致很多 vfork 程序出现“子进程悄悄改了父进程数据”的诡异 bug。我记得有一种老掉牙的 fork bomb 变种也会用 vfork 来提高炸弹效率。所以现在的实际建议是现代 Linux 上 fork 已经支持 COWvfork 几乎没有性能优势还带来一堆共享地址的隐患除非你清楚知道自己到底在干什么否则不要用 vfork 写业务代码。2.4 exec 族让子进程“换一个程序跑”fork 出来的子进程继续跑的依然是父进程那套二进制代码大多数时候我们想要的其实是“启动一个别的程序”。于是 fork 通常和 exec 配合使用先 fork 一个子进程再在子进程里调用 exec 族接口把当前进程的代码段、数据段、堆、栈整体替换成一个新的程序。Linux 的 exec 族有六个脸execl、execv、execle、execve、execlp、execvp区别主要体现在两个维度第一个是命令行参数是逐个列举还是传一个字符指针数组第二个是可执行文件路径是直接给绝对路径/相对路径还是依赖 PATH 环境变量去搜索。其中 execve 是真正的系统调用其他几个都是 libc 封装。一个典型的用法char *args[] {/bin/ls, -l, NULL}; execv(args[0], args); /* 如果 exec 成功这里永远不会执行到 */ perror(execv failed); exit(1);需要注意的是 exec 成功后不会返回只有 exec 失败才会带着 -1 回到原来的调用点。这个特性经常被粗心的同学忽略导致 exec 后面写了大量“原本不该执行”的代码。另一个容易被忽略的点exec 会保留进程的 PID、文件描述符表的大部分内容以及进程的工作目录所以子进程里打开的文件、继承的 socket 在 exec 之后依然存在。3. 创建进程的核心细节与实操要点3.1 父进程的命令行参数与子进程的“复制品”fork 会复制的东西比你想象的多得多不仅仅是代码段、数据段还包括父进程的命令行参数、环境变量、文件描述符表、信号处理器、当前工作目录、umask 等。这意味着你在父进程里已经建好的数据库连接池、日志 fd、socket 监听口在子进程里全是“复制的”状态。这里有个特别容易踩的坑如果你 fork 之前已经建立了 TCP 连接子进程和父进程会共享同一个 socket 的内核对象。如果父子都往这个 socket 上写数据可能出现两个进程交错写、数据混乱的问题。同理日志 fd 也一样——如果你 fork 之后不关 fd 就直接各写各的日志内容会乱。所以在真实的网络服务框架里常见套路是先创建好监听的 socket然后 fork子进程只负责 accept父进程什么也不做或者反过来。再或者fork 之后谁不用的 fd 就要立刻 close 掉。3.2 标准 I/O 缓冲区会“复制出双份”这是 fork 初学者最容易撞上的经典事故。比如printf(before fork); pid_t pid fork();结果你发现这一行 printf 在屏幕上出现了两次或者出现在日志文件里两次。为什么因为 printf 这类标准库函数默认是全缓冲或行缓冲要满足缓冲区满或者遇到换行符/程序正常退出才会刷新。fork 复制地址空间的时候把这个还没刷新的用户态缓冲区也复制了一份于是父子进程各自携带一份“待输出内容”程序退出时各自刷新自然输出两遍。规范的做法是 fork 前记得 fflush(stdout)或者干脆在 fork 之后的子进程里立刻重新 exec让 exec 进程自己干净地开始。如果是写日志这类场景更好的方案是直接使用 write 这类系统调用它对内核来说没有用户态缓冲不会在 fork 时被复制出两段残留内容。3.3 fork 失败与 EAGAIN资源往往不是你想的那么无限很多人以为 fork 只要调用就能成功实际生产环境中 fork 失败太常见了。最常见的错误返回值是 EAGAIN它的字面意思是“资源暂时不可用”。触发场景一般有三类进程数量达到系统限制比如整个系统的线程/进程数达到 pid_max或者当前用户的进程数超过 ulimit -u又或者当前 cgroup 里配置了 pids.max容器环境里尤其容易触发。遇到 fork 失败又打印不出错误码的时候别慌按下面顺序查# 查看系统最大 PID 号 cat /proc/sys/kernel/pid_max # 查看当前用户可创建的进程/线程数 ulimit -u # 查看容器或 cgroup 限制 cat /sys/fs/cgroup/pids/pids.max cat /sys/fs/cgroup/pids/pids.current如果你的服务莫名其妙地“启动不了新线程/新进程”而系统负载又不高优先怀疑是不是这些限制到了天花板。长期运行的服务还要注意“进程数泄漏”——父进程不停 fork 子进程但不回收僵尸进程也是一个一个进程同样会占 PID 和进程表条目。4. 进程回收wait 与 waitpid 的学问4.1 僵尸进程的真相内核为什么不直接清理干净说回收之前必须先搞清楚僵尸进程是什么。当一个子进程结束运行无论是正常 exit 还是被信号杀死内核都会保留它最基本的进程描述符task_struct里面存放着进程的 PID、退出码、消耗的 CPU 时间、资源使用统计等。这个状态的进程就叫做僵尸进程zombie在 ps 里会显示状态 Z有时也会标 [defunct]。内核这样做的原因很实际父进程可能需要知道“孩子是怎么死的”——是正常退出退出码是多少还是被发信号杀死是哪个信号。如果内核在子进程结束的瞬间就把所有信息抹掉父进程就永远拿不到这些答案了。因此僵尸进程不是 bug而是内核故意的设计问题的关键只在于父进程什么时候来取这些信息。父进程调用 wait / waitpid 收走信息之后僵尸进程才会真正从进程表里消失。僵尸进程本身不再占用内存但会占用一个 PID如果父进程长时间不回收大量僵尸堆积会把 PID 空间耗尽新进程创建不出来服务就彻底瘫痪了。而如果父进程自己先退出孤儿进程会被系统收养由 initPID 1进程负责回收这也是为什么普通终端里写的简单程序不会残留一大堆僵尸——你退出 shell 以后init 帮你收了尸。4.2 wait最基础的回收接口但粒度太粗最简单粗暴的回收方式就是 wait#include sys/wait.h pid_t wait(int *status);这个调用会阻塞父进程直到任意一个子进程退出如果没有子进程wait 直接返回 -1 并设置 errno 为 ECHILD。status 是一个传出参数里面打包了子进程的退出方式等信息。wait 的缺点很明显第一它只能等“任意一个”子进程不能指定等哪个第二它是阻塞式的如果子进程很长时间不退出父进程会被一直挂住第三如果父进程有多个子进程你得循环调用 wait而且不知道下一次回收到的到底是谁。所以工程上真正常用的是 waitpid。4.3 waitpid工程中的标准答案waitpid 比 wait 灵活得多也复杂得多#include sys/wait.h pid_t waitpid(pid_t pid, int *status, int options);理解 pid 参数是第一步pid 取值含义-1等待任意一个子进程和 wait 等价0等待指定 PID 的子进程0等待与当前进程同一个进程组的所有子进程 -1等待指定进程组绝对值下的所有子进程options 参数控制非阻塞等行为最常用的是 WNOHANG意思是“如果没有已退出的子进程不要阻塞立即返回 0”。这个值在配合信号处理或者事件循环时几乎是标配。还有一个很少用但面试偶尔会考的选项 WUNTRACED表示也回收被暂停stopped的子进程WCONTINUED 表示回收从暂停恢复继续运行的子进程。拿到 status 之后用一组宏来解析它if (WIFEXITED(status)) { int code WEXITSTATUS(status); // 正常退出时拿到退出码 } if (WIFSIGNALED(status)) { int sig WTERMSIG(status); // 被信号杀死时的信号编号 } if (WIFSTOPPED(status)) { int sig WSTOPSIG(status); // 被暂停时的信号编号 }我在工程里最常写的循环长这样作用是把所有已退出子进程全部收干净int status; pid_t child_pid; while ((child_pid waitpid(-1, status, WNOHANG)) 0) { if (WIFEXITED(status)) { LOG(child %d exited with code %d, child_pid, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { LOG(child %d killed by signal %d, child_pid, WTERMSIG(status)); } }4.4 回收之后还要注意wait 失败但 errno 不一定等于 ECHILD用 waitpid 循环回收时终止条件绝不能用“返回 0”因为返回 0 只是“这次没有已退出的子进程”不代表下次没有。如果你正在做信号驱动的回收一定要用 WNOHANG 配合数量判断如果你在普通流程里等所有子进程退出那就应该用阻塞式 waitpid循环到返回 -1 并且 errno ECHILD说明再没有子进程了这时候才能安全退出。很多人图省事只 wait 一次结果最后剩下几个子进程没人管等父进程退出后它们才被 init 捡走这在小脚本里看着没啥问题但在长期运行的守护进程里就是资源泄漏。5. 工程实践SIGCHLD 与进程池的正确姿势5.1 用 SIGCHLD 信号让内核来通知你收尸当子进程退出时内核会向父进程发送 SIGCHLD 信号。父进程可以注册一个信号处理函数在这个处理函数里统一回收所有已退出的子进程。这是最常见的“异步回收”模式比父进程轮询所有子进程状态要优雅得多。先设置信号处理器#include signal.h #include sys/wait.h #include unistd.h static void sigchld_handler(int sig) { int status; pid_t pid; while ((pid waitpid(-1, status, WNOHANG)) 0) { // 记录日志、更新统计信息等 } } // 在某处注册 struct sigaction sa; sa.sa_handler sigchld_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; // 尽量让被信号打断的系统调用自动重启 sigaction(SIGCHLD, sa, NULL);这里有一个非常重要的易错点信号处理函数内部绝对不能调用 printf、malloc、fopen 这类函数。原因很简单信号随时可能打断主程序的任意一行代码如果主程序正执行到 malloc 的半路而信号处理函数里又调了一次 malloc就可能在堆管理上重复进入非线程安全状态导致崩溃或数据损坏。信号处理函数里能安全调用的函数必须来自 async-signal-safe 列表直接 write 到 fd 是可行的复杂逻辑应该抛给主循环去处理。5.2 一批子进程同时崩溃时SIGCHLD 可能会丢失一次这是另一个非常隐蔽的问题。如果一批子进程几乎同时退出Linux 会把多个 SIGCHLD 信号合并处理同一类型的未处理信号只会排队一个后面来的在 pending 队列里被合并。也就是说如果你在信号处理函数里只 waitpid 一次可能只收了一个子进程剩下的就漏掉了。所以标准的做法是在信号处理函数里使用 while 循环不停地 waitpid(..., WNOHANG) 直到返回 -1 或 0while ((pid waitpid(-1, status, WNOHANG)) 0) { // 处理 pid 的退出 }while 循环的目的就是“即便信号合并了我也要把所有已经退出的子进程全部清空”。这个细节在面试里属于进阶题在实际线上故障里则最容易被忽略——服务跑上一天后ps 里慢慢又冒出一堆僵尸。5.3 简单进程池提前创建好按需取用“进程池”这个词在热词里出现频率很高主要是因为它和“反复创建进程的性能开销”直接相关。频繁创建进程这件事开销远不止一次 fork 系统调用要复制页表、维护内核进程链表、分配 PID、做调度器初始化哪怕有 COW 加持也抵不住高频来往。更别提进程创建后还要 exec、初始化一大堆东西。进程池的核心思路就是提前一次性创建 N 个固定的 worker 进程任务来了直接分给空闲 worker而不是每个任务都临时 fork。它的好处有两个一是省掉了反复 fork exec 的启动开销二是数量可控不会因为突发事件导致进程数瞬间爆炸。一个极简进程池框架可以这样设计父进程启动时先 fork 出固定数量比如 CPU 核数的 workerworker 进程进入一个循环等待任务队列里有任务父进程通过管道或者队列把任务发给某个空闲 workerworker 完成一个任务后继续等下一个父进程监控每个 worker 的健康状态发现 worker 异常退出就立即重新 fork 一个替补。伪代码大概长这样// 伪代码创建 worker for (int i 0; i pool_size; i) { pid_t pid fork(); if (pid 0) { worker_loop(i); // 不会返回 } workers[i] pid; } // 伪代码worker 主循环 void worker_loop(int idx) { while (1) { Task *task receive_task(); // 从任务队列里取 run_task(task); } }父进程负责补位时最好和前面说的 SIGCHLD 回收机制配合收到 SIGCHLD 时除了回收退出的 worker还要记录是第几个 worker 挂了然后重新 fork 一个填充进去。如果手写维护 worker 的 PID 表需要注意不能直接用旧的 PID 去持有因为 PID 可能被系统复用补出来的新 worker 要重新登记。6. 常见问题与排查技巧实录6.1 一张表看懂常见坑现象可能原因排查 / 解决方法ps 里大量僵尸进程状态 Z父进程没有调用 wait/waitpid或者信号处理中只回收了部分检查父进程回收逻辑在 SIGCHLD 里用 while 循环回收或设置 SA_NOCLDWAITfork 返回 -1errnoEAGAINPID/进程数达到系统或用户限制ulimit -u、cat /proc/sys/kernel/pid_max、检查 cgroup pids.max排查僵尸进程数量fork 后 printf 内容输出两遍fork 复制了未刷新的用户态缓冲区fork 前 fflush(stdout)或子进程里改用 write 系统调用父子进程同时写同一个 socket / 日志 fd数据混乱fork 复制了 fd共享同一内核文件对象按职责关闭不需要的 fd或让一方持有、另一方关闭子进程里改了变量父进程的值也变了用了 vfork地址空间共享改成 fork exec或确认自己的场景能否接受共享wait 返回 -1errnoECHILD父进程已经没有可回收的子进程检查是否所有子进程都已被回收是否设置过 SA_NOCLDWAIT进程池 worker 挂了服务能力下降父进程没有检测 worker 存活或没补位结合 SIGCHLD 做 worker 补位周期性健康检查6.2 现场排查到底该看什么遇到进程异常我先教新人的一套固定排查路径ps -ef --forest看进程树一眼看出父子关系尤其找状态列是 Z 的进程top -d 1看整体负载和状态Z 进程数量太多说明回收链路出问题了对具体进程用cat /proc/pid/status查看 State 字段和 PPid确认父进程是谁。State 里的 Z 表示僵尸S 表示可中断睡眠R 表示在运行如果是自己写的服务直接strace -f -e tracewait4,clone,fork,execve -p pid跟着系统调用看它到底有没有调用 waitclone和fork在 strace 里对应的都是 clone 系统调用父进程在这里创建子进程wait4 就是 wait/waitpid 的底层实现实在看不出来用pstree -p pid打印进程树能快速定位子进程的归属。排查工具不在多核心逻辑是先从进程状态和父子关系判断回收是否出了问题再用 strace 确认系统调用行为。6.3 我踩过的以及你们可能也会踩的坑第一个坑多线程程序里 fork。如果主进程是多线程的fork 出来的子进程只会包含调用 fork 的那个线程其他线程全部消失。如果那个线程当时正握着某个锁而锁在 fork 时不一定会被正确“传输”到子进程的锁状态里子进程再去申请同一把锁可能直接死锁。所以规范里都强调多线程要 fork 之后马上 exec或者不要在多线程环境里裸 fork。这个在面试里也经常被问到。第二个坑SIGCHLD 的设置时机。如果你在父进程已经 fork 出几个子进程之后才设置 signal handler那之前退出的子进程产生的 SIGCHLD 可能已经被默认处理掉了你永远收不到。所以要在 fork 之前就把 SIGCHLD handler 设置好。我有一次调了半天查不出僵尸进程为什么出现最后发现是“先 fork 后 signal”顺序一换问题就消失了。第三个坑SA_NOCLDWAIT 和显式 waitpid 不能混着来。如果信号处理里设置了 SA_NOCLDWAIT那子进程退出后内核直接不保留僵尸状态你的 waitpid 永远收不到子进程因为 waitpid 会返回 -1 且 ECHILD。这在某些框架里是个“隐式禁则”一不小心就会踩。如果你需要子进程退出状态信息就不要用 SA_NOCLDWAIT如果你根本不在乎退出状态用 SA_NOCLDWAIT 反而是最省事的办法。7. 一点个人经验进程的创建与回收说到底是两件事怎么开个头怎么收个尾。Linux 把“开头”设计得极其简单一个 fork 就能复制出几乎一切又把“收尾”设计得极其讲究宁可保留一个僵尸进程也要让父进程有机会知道孩子怎么离开的。我在实际项目中见过太多只关注“怎么 fork”却不管“怎么 wait”的代码最后线上僵尸堆积、服务失联的例子。如果你能把 SIGCHLD 回收写成 while 循环把 fork 之前应该 flush 的缓冲区 flush 掉把进程池的 worker 补位考虑进去那么这块基本功就算真正落地了。最后再分享一个小技巧遇到进程相关故障先跑 ps -o pid,ppid,state,cmd再辅以 strace -f 看 wait4 和 clone八成问题都能在这两个命令里找到答案。
返回列表