
写后台服务、跑批量任务的时候肯定遇到过这种情况程序fork出来的子进程退出了但ps一看还挂着几十个状态为Z的条目父进程不管它系统里就飘着一堆僵尸进程。要弄懂这类问题绕不开Linux进程控制里最基础的三板斧进程创建、进程终止、进程等待。这篇就围绕这三个点展开。fork负责创建子进程exit和_exit负责结束进程wait和waitpid负责回收子进程资源。这三件事看着简单真正写起来全是坑fork之后缓冲区为什么会重复输出子进程退出了父进程怎么判断它是正常退出还是被信号杀掉的为什么waitpid在信号处理函数里要加循环文章里我会把这些机制的原理、代码样例和实际操作中的排查经验一起讲清楚适合刚接触Linux系统编程的读者也适合工作中经常跟多进程程序打交道、想补一补底层细节的人。1. 先把三件事的关系理清楚1.1 进程控制到底在控制什么每个进程在内核里都对应一个task_struct结构体也就是PCB进程控制块里面存着pid、父进程pid、进程状态、打开的文件描述符表、信号处理设置、内存映射信息、当前工作目录等等。进程控制做的事情简单说就是围绕这个PCB做生命周期管理创建时分配PCB并初始化运行中维护PCB里的状态终止时回收PCB占用的所有资源。创建进程用的是fork它做的事情是复制当前进程的PCB生成一份几乎一模一样的副本再给这个副本分配新的pid、建立新的内存映射。进程终止的入口是exit或者_exit前者会做一些C库层的收尾工作后者直接陷入内核态清理资源。进程等待用的是wait或者waitpid让父进程阻塞起来等某个子进程终止之后把它的退出状态取出来同时回收子进程残留的PCB这一步是防止僵尸进程的关键。这里要注意创建和等待是成对出现的。只创建不等待子进程终止后它的task_struct不会被释放慢慢堆积成僵尸进程。只等待不创建那这个等待大概率就是个bug。所以学习进程控制最好把创建、终止、等待当成一条流水线来理解而不是三个孤立的知识点。1.2 为什么把三件事放在一个体系里学单独看fork很容易停留在创建一个子进程这个表层理解上面试问一句fork之后父子进程共享什么、拷贝什么就露馅。单独看exit又会忽略退出码的传递链子进程exit(3)父进程怎么拿到这个3答案在wait返回的状态里。这三个系统调用是一条完整的链路子进程怎么来的、怎么走的、父进程怎么确认它走了构成了进程生命周期的主干。理解了这条主干后面再学exec系列、信号、进程间通信会顺畅很多。比如exec是创建完进程后加载新程序信号里SIGCHLD会打断阻塞的wait管道通信的另一端其实就是wait回收的子进程。这篇按创建、终止、等待的顺序走每一步都配代码和踩坑记录最后一节用一个小型多进程任务管理器把整条链路串起来。2. 进程创建fork背后的机制2.1 fork的返回值为什么那么反直觉fork最反直觉的地方在于调用一次返回两次。父进程里fork返回子进程的pid子进程里fork返回0失败时返回-1。不是复制了一个进程然后各自继续跑这么简单而是fork返回后父进程和子进程都从fork调用之后的下一行代码开始执行只是各自拿到的返回值不同。为什么设计成返回两次因为父进程和子进程要做的事情通常不一样。父进程拿到子进程pid可能要继续创建更多子进程或者记下这个pid用于后续管理子进程拿到0说明自己是子进程可以去执行另一段逻辑。用返回值作为分支依据是区分我是父进程还是我是子进程的唯一内置手段。fork的底层实现不是把父进程整个内存复制一遍。早期确实做过完全复制后来改成了写时拷贝Copy-on-WriteCOWfork时父子进程共享同一份物理内存页面页表项都标记为只读。任何一方试图写入某个页面时才触发缺页异常内核把这一页单独复制一份再允许写入。这样设计的好处是fork之后子进程大概率马上调用exec加载新程序前面复制的数据页根本用不上COW能省掉大量内存拷贝的开销。2.2 最小可运行的fork示例看一段最小代码#include stdio.h #include stdlib.h #include unistd.h #include sys/wait.h int main(void) { pid_t pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { printf(子进程: pid%d, 父进程%d\n, getpid(), getppid()); _exit(0); } printf(父进程: pid%d, 子进程%d\n, getpid(), pid); wait(NULL); return 0; }编译运行后父进程和子进程都会打印一行。子进程用getppid()拿父进程pid这里拿到的是父进程调用fork时的pid。有个细节如果父进程先退出了子进程的getppid()会变成1因为孤儿进程被pid为1的init进程收养。在bash里直接跑这个程序父进程一般不会先于子进程退出但放到更复杂的服务框架里这个孤儿进程的ppid漂移现象很常见。fork之后父子进程的执行顺序是不确定的。父进程先打印还是子进程先打印完全看调度器不能假设谁先谁后。如果对顺序有要求需要自己通过管道、信号或者共享内存去同步光靠fork的语义是保证不了的。2.3 fork前printf输出会多出一份一个很经典的fork面试题下面这段代码最后会输出几行#include stdio.h #include unistd.h #include sys/wait.h int main(void) { printf(hello ); fflush(stdout); fork(); wait(NULL); return 0; }如果把fflush(stdout)去掉输出会变成两行hello而不是一行。原因是printf写的是标准C库的缓冲区数据还在用户态缓冲区里没刷到内核fork复制进程时把这个缓冲区也复制了一份于是父子进程各持有一份hello 等程序正常退出时各自刷新缓冲区就输出了两遍。实际开发中这事经常引发诡异现象日志打到一半fork之后日志就重复了。规避方法很直接fork之前确实有未刷新的输出就显式调用fflush或者保证fork之后立即exec、不依赖之前的缓冲状态。这个问题放在博客里反复讲因为它是把fork的复制性质暴露得最彻底的一个场景。2.4 fork失败的常见原因与fork炸弹的教训fork返回-1通常有两种原因一是系统进程数达到上限可以用ulimit -u查看当前用户的进程数限制二是内存不足COW虽然省内存但创建task_struct、复制页表这些操作仍然需要分配内核内存。fork还有个极端的反面用例fork炸弹。不断fork自己短时间内把系统进程表撑爆会让整个机器卡死。我自己只在受控的虚拟机里见过一次演示生产环境千万别尝试。限制用户最大进程数、用systemd或容器限制进程数都是防止这类情况的常用手段。这个例子放在这里也算提醒fork是个强大的系统调用但用之前得想清楚它的代价。3. 进程终止exit与_exit的差别3.1 exit是收尾_exit是直接走进程终止的入口有两个exit(int status)和_exit(int status)。两者最终都会进入内核的do_exit但exit在前面多做了三件事执行通过atexit注册的钩子函数刷新并关闭所有标准C库的stdio缓冲区调用底层清理最终进入内核态_exit则跳过所有C库层的收尾直接陷入内核做资源回收。区别集中体现在缓冲区上。看这段代码#include stdio.h #include stdlib.h #include unistd.h void bye(void) { printf(bye called\n); } int main(void) { atexit(bye); printf(data without newline, pid%d , getpid()); // exit(0); // 情况1 _exit(0); // 情况2 }情况1用exit(0)atexit注册的bye会被调用printf的缓冲区也会被刷新所以日志完整输出。情况2用_exit(0)atexit不执行缓冲区不刷新data without newline直接丢失。如果你在子进程里用_exit父进程通过wait拿到的退出状态是一致的但日志和清理钩子的行为完全不同。多进程程序里有个常见搭配fork出来的子进程建议用_exit而不是exit。原因是子进程通常复制了父进程的缓冲区内容如果用exit刷新缓冲区可能导致重复输出而且exit还会执行父进程注册的atexit钩子这些钩子可能是为父进程准备的子进程执行一遍纯属多余。3.2 退出码到底怎么传递进程的退出码是一个8位的整数范围0到255。main函数里return 3和调用exit(3)最终效果一样都走C运行时的退出流程。退出码的传递依赖wait这类系统调用子进程退出时内核把退出状态记录在task_struct里父进程通过wait回收时这个状态被提取出来。重点来了退出状态不是单纯的一个退出码而是一个打包的整数。取这个整数时要用一组宏拆解常用的有WIFEXITED(status)子进程是否正常退出通过exit或returnWEXITSTATUS(status)正常退出时取出退出码低8位WIFSIGNALED(status)子进程是否被信号终止WTERMSIG(status)被哪个信号终止WIFSTOPPED(status)子进程是否处于暂停状态WSTOPSIG(status)暂停是由哪个信号触发这么设计的原因是父进程需要区分子进程正常结束退出码是几和子进程被信号杀掉是哪个信号。如果不拆开这两类信息混在一个int里根本没法用。后面讲waitpid时会看到这些宏的实际用法。3.3 孤儿进程与僵尸进程进程终止后父进程如果还没调用wait那子进程的task_struct会带着退出状态一直留在内核里进程状态标记为Zzombie僵尸。僵尸进程不占CPU、不占内存数据页但占着一个pid和一份PCB一直不回收进程表就会被慢慢塞满。和僵尸相对的是孤儿进程父进程先退出子进程还没退出。此时子进程会被pid为1的init进程收养init会在合适的时候主动wait这些孤儿进程替它们收尸。所以对孤儿进程本身内核有一整套自动接管机制不需要开发者操心。真正需要开发者操心的恰恰是父进程还活着却不调用wait任由子进程变成僵尸。理解这两类进程的关键在于僵尸是子进程死了但没人收尸孤儿是父亲走了但孩子还活着。处理僵尸的唯一正解就是父进程调用wait或waitpid配合好终止和等待这两步。4. 进程等待wait与waitpid4.1 wait是最省事的等待方式wait(int *status)的功能是阻塞当前进程直到有一个子进程终止。返回终止子进程的pid同时把退出状态写到status指向的位置。如果调用wait时没有任何子进程直接返回-1errno设为ECHILD。注意任意一个子进程。如果父进程创建了4个子进程调用一次wait只能回收其中最先终止的那个剩下的3个还需要继续调用wait。这是很多初学的人写多进程程序时容易漏的地方。status可以传NULL意思是父进程不关心子进程退出状态只想收尸。但如果想判断子进程怎么退的就需要传入一个int变量后面用宏拆解。简单示例#include stdio.h #include stdlib.h #include unistd.h #include sys/wait.h int main(void) { pid_t pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { printf(子进程退出前的最后日志\n); exit(42); } int status; pid_t ret wait(status); if (ret 0) { perror(wait); exit(1); } if (WIFEXITED(status)) { printf(子进程%d正常退出退出码%d\n, ret, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { printf(子进程%d被信号%d终止\n, ret, WTERMSIG(status)); } return 0; }4.2 waitpid解决两个问题wait有两个明显的痛点只能阻塞等待、只能等任意一个子进程。waitpid就是为了解决这两个问题而存在的。pid_t waitpid(pid_t pid, int *status, int options);第三个参数options是重点它可以取0、WNOHANG、WUNTRACED等值。0表示阻塞等待WNOHANG表示非阻塞如果没有子进程退出立即返回0父进程可以继续干自己的事。第一个参数pid的取值规则pid 0等指定pid的那个子进程pid -1等待任意一个子进程等同于waitpid 0等所有和调用者同进程组的子进程pid -1等指定进程组里的子进程实际开发中最常用的组合是waitpid(-1, status, WNOHANG)配合循环非阻塞地轮询直到所有子进程都退完。这个组合在信号处理函数里尤其常见后面会单独说明。4.3 循环回收多个子进程假设父进程创建了3个子进程正确回收方式是循环多次调用waitpid#define CHILD_NUM 3 for (int i 0; i CHILD_NUM; i) { int status; pid_t ret waitpid(-1, status, 0); if (ret 0) { perror(waitpid); break; } printf(回收了子进程 %d\n, ret); }这里用waitpid(-1, status, 0)语义和wait(status)一样都是阻塞等任意一个子进程。循环次数定为3是因为我们明确知道创建了3个子进程每回收一个循环变量加1直到全部收完。如果改用waitpid(pid, status, 0)绑定具体子进程pid就得考虑多个子进程退出的先后顺序。比如子进程B先退父进程却等子进程A那父进程会阻塞到A退出为止B的退出状态虽然在内核里但没人取B就暂时成了僵尸。所以除非有明确的业务需要否则回收多个子进程时用-1等任意一个是更省事的选择。4.4 非阻塞轮询的场景非阻塞模式适合父进程要边干活边管孩子的情况。比如父进程自己有一个持续运行的事件循环同时还需要监控几个子进程的状态这时候就不能阻塞在wait上。可以用int status; pid_t ret; while ((ret waitpid(-1, status, WNOHANG)) 0) { printf(回收子进程 %d\n, ret); } // ret 0 说明当前没有子进程退出 // ret -1 说明所有子进程都已回收完毕代码里while退出后ret为0表示没有子进程恰好在这轮退出不是出错ret为-1时需要看errno最常见的错误码是ECHILD表示没有子进程了。这个区分是很多人容易搞混的点。4.5 用SIGCHLD配合waitpid实现自动回收子进程终止时内核会自动向父进程发送SIGCHLD信号。如果父进程不处理这个信号默认行为是忽略它子进程仍然可能变成僵尸。常见做法是注册一个信号处理函数在里面用非阻塞waitpid循环回收#include stdio.h #include stdlib.h #include unistd.h #include signal.h #include sys/wait.h static void sigchld_handler(int sig) { int status; pid_t pid; while ((pid waitpid(-1, status, WNOHANG)) 0) { printf(信号处理中回收子进程 %d\n, pid); } } int main(void) { signal(SIGCHLD, sigchld_handler); for (int i 0; i 3; i) { pid_t pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { printf(子进程 %d 开始\n, getpid()); _exit(0); } } // 父进程假装继续干活 pause(); return 0; }关键点在于信号处理函数里必须用循环不能只调用一次waitpid。因为SIGCHLD信号可能会丢失多个子进程几乎同时退出内核可能只给父进程发一次信号如果处理函数只回收一个子进程剩下的就没人管了。用while WNOHANG waitpid(-1)循环直到返回0或-1才能把这一批退出的子进程全部回收干净。5. 实战把创建、终止、等待串成一个任务管理器5.1 场景设定假设要写一个小型任务调度器父进程创建4个子进程每个子进程模拟一个不同耗时的任务比如压缩、上传、校验、清理。父进程负责任务下发和结果汇总等所有子进程结束后统计正常完成数和异常退出数。这个场景覆盖了进程创建、进程终止、进程等待三块内容fork创建子进程、子进程exit返回退出码或被信号终止、父进程用waitpid逐个回收并解析状态。5.2 代码实现#include stdio.h #include stdlib.h #include unistd.h #include sys/wait.h #define TASK_COUNT 4 int main(void) { pid_t children[TASK_COUNT]; for (int i 0; i TASK_COUNT; i) { pid_t pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { // 子进程按编号模拟不同任务 printf(任务%d号子进程开始pid%d\n, i, getpid()); switch (i) { case 0: sleep(3); break; case 1: sleep(1); break; case 2: sleep(2); break; case 3: sleep(1); break; } if (i 2) { // 模拟2号任务异常被信号终止 abort(); } _exit(0); } children[i] pid; } // 父进程逐个回收子进程 int ok_count 0; int fail_count 0; for (int i 0; i TASK_COUNT; i) { int status; pid_t ret waitpid(-1, status, 0); if (ret 0) { perror(waitpid); continue; } if (WIFEXITED(status)) { printf(任务子进程pid%d 正常完成退出码%d\n, ret, WEXITSTATUS(status)); ok_count; } else if (WIFSIGNALED(status)) { printf(任务子进程pid%d 被信号%d终止\n, ret, WTERMSIG(status)); fail_count; } } printf(汇总正常完成 %d 个异常终止 %d 个\n, ok_count, fail_count); return 0; }运行后能看到不同任务以不同顺序退出2号子进程因为abort触发SIGABRT被终止父进程通过WIFSIGNALED和WTERMSIG识别出来。这个汇总逻辑放到真实系统里就相当于任务调度系统的结果回收模块。5.3 扩展成非阻塞巡检版本上面的版本父进程全程阻塞等待如果父进程还想同时处理自己的业务可以改成轮询模式#include stdio.h #include stdlib.h #include unistd.h #include sys/wait.h #define TASK_COUNT 4 int main(void) { int remaining TASK_COUNT; for (int i 0; i TASK_COUNT; i) { pid_t pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { sleep(1 i); _exit(i); } } while (remaining 0) { int status; pid_t ret waitpid(-1, status, WNOHANG); if (ret 0) { if (WIFEXITED(status)) { printf(子进程 %d 完成了退出码%d\n, ret, WEXITSTATUS(status)); } remaining--; } else if (ret 0) { perror(waitpid); break; } else { // ret 0: 本轮没有子进程退出父进程继续忙自己的事 printf(父进程继续处理其他业务...\n); usleep(500000); } } printf(所有子进程已回收完毕\n); return 0; }这种方式适合父进程本身是一个事件循环的场景。注意轮询间隔不能太短否则白白烧CPU按业务容忍度几百毫秒到一秒钟比较常见。5.4 实战中容易踩的两个问题第一个问题是回收次数和子进程数不匹配。比如父进程在循环里fork子进程return出去后又接着进下一轮循环继续fork导致子进程数量比预期多很多。解决办法是先想清楚fork之后子进程的流向子进程分支里一定要_exit或者exec不能让子进程掉回父进程的执行路径。第二个问题是子进程退出状态没有及时取。哪怕调用了waitpid但只调了一次在多个子进程同时退出的情况下也会剩下一部分僵尸。用信号处理时更要坚持循环回收原则。排查方法很简单ps -o pid,ppid,stat,cmd看到一堆Z状态进程基本就是wait相关代码写漏了。6. 常见问题与排查技巧实录6.1 僵尸进程的现场排查遇到僵尸进程第一步是确认它的父进程是谁。命令ps -o pid,ppid,stat,cmdSTAT列出现Z就说明该进程是僵尸。再往前找父进程pid确认父进程是否还活着。如果父进程活着说明父进程没有正确调用wait回收如果父进程已经退出了僵尸进程应该已经被init接管并回收短时间内不可能一直存在。实战里还有一种常见情况父进程是一个长期运行的守护进程它一边fork子进程处理任务一边自己跑主循环。如果回收代码写的是一次性waitpid而不是循环只要某批子进程同时退出就会漏掉几个僵尸慢慢积累。排查时先看进程数目有没有持续上升再用ps确定僵尸的父进程pid去查对应代码逻辑。6.2 常见问题速查表问题现象可能原因解决方法大量僵尸进程堆积父进程没有调用wait/waitpid或信号处理里只回收了一个子进程用while循环WNOHANGwaitpid(-1)回收或检查父进程逻辑waitpid返回-1且errnoECHILD没有可以等待的子进程确认fork是否成功确认子进程是否已经被回收过fork之前printf的输出重复父进程stdio缓冲区被复制给了子进程fork前调用fflush(stdout)或在子进程里改用_exit子进程退出码总是-1或255可能没有拆解status直接把status当退出码用了用WIFEXITED/WEXITSTATUS/WIFSIGNALED拆解status父进程阻塞在wait上不返回等待的子进程还没退出或等待的pid写错了确认子进程是否卡死用WNOHANG轮询代替阻塞等待信号处理函数只回收了部分子进程一个SIGCHLD信号对应多个子进程退出处理函数调用了一次waitpid用while循环回收直到waitpid返回0或-16.3 避坑心得调试多进程程序时强烈建议先画一张谁创建谁、谁等谁的关系图。进程控制的问题大部分不是系统调用本身写错而是对父子关系、回收职责划分不清。实际项目中我习惯把创建子进程和回收子进程放在同一层代码里不让它们分散到不同模块。一个函数负责fork并记录子进程pid另一个函数负责批量回收并上报状态中间用pid数组关联。这样即使以后改成信号驱动回收改动面也只在回收模块内部。再补充一个小细节如果父进程注册了SIGCHLD信号处理函数同时又在主逻辑里用阻塞waitpid两者会相互干扰。信号到达时waitpid被打断返回-1并设置errno为EINTR。处理办法是检查errno如果是EINTR就继续调用waitpid或者干脆只在信号处理函数里回收主逻辑不再调用waitpid两种方案选一种不要在一条链路里混用。