学完fork以后很容易产生一种错觉父进程创建子进程两边各跑各的子进程执行完就自然消失了。真正写代码时才会发现事情没有这么简单。子进程结束了父进程还需要调用wait或waitpid否则一个已经“死了”的进程可能继续占着系统里的位置甚至连kill -9都处理不了它。这些现象背后连着几个问题子进程退出以后为什么还要父进程“收尾”wait和waitpid到底在等什么等待时父进程真的还占着 CPU 吗子进程明明exit(257)为什么拿到的退出码是1父进程被阻塞和父进程一边轮询、一边做事有什么区别这篇文章沿着课堂上的理解顺序从僵尸进程的问题讲起再看wait、waitpid、退出状态status最后落到阻塞和非阻塞等待。目录子进程已经结束父进程为什么还要等僵尸进程已经死了为什么还占着位置wait先把最基本的等待接口用起来waitpid把“等谁”和“怎么等”拆开status退出码为什么不能直接当成整数读阻塞等待把 CPU 交出去等子进程结束非阻塞等待父进程一边检查一边做自己的事把整条知识链串起来1. 子进程已经结束父进程为什么还要等父进程创建子进程通常不是为了“生出来就不管”。它可能要依靠子进程完成一段任务再根据执行结果决定下一步。比如父进程启动一个子进程去处理数据子进程完成后需要告诉父进程任务是正常做完的还是被信号打断的如果是正常结束退出码是多少父进程什么时候可以确定子进程的任务已经彻底结束。光看子进程“不运行了”并不够。子进程可能正常退出也可能被SIGKILL或其他信号终止还可能发生段错误。父进程需要一种确定的方式拿到这些退出信息并且在拿到信息之后把子进程留下的内核记录清理掉。这就是进程等待要解决的两个核心问题回收子进程仍然保留的资源获取子进程的退出信息。注意“子进程退出”和“父进程回收子进程”不是同一件事。退出发生在子进程一侧表示它的任务已经结束回收发生在父进程一侧表示父进程承认这个结果并让内核把剩余记录清掉。2. 僵尸进程已经死了为什么还占着位置2.1 为什么不能一退出就彻底删除子进程调用exit后用户态代码已经停止执行。它的用户地址空间、打开的文件等大部分资源会被释放但它不能立刻从系统里彻底消失因为内核还要给父进程留下一份“死亡通知”。这份通知至少要回答几个问题子进程的 PID 是什么它是正常退出还是被信号杀死正常退出时的退出码是多少被信号终止时是哪个信号如果子进程一退出就把所有记录删掉父进程以后再调用wait时就不知道刚才结束的是谁也不知道它为什么结束。所以内核会先保留一小部分进程记录把子进程标记成僵尸状态等待父进程来读取结果。父进程调用wait或waitpid以后内核把退出信息交出去再完成最后的资源回收。大致过程可以理解为子进程 exit() | v 内核释放它的大部分资源保留退出信息 | v 子进程进入 EXIT_ZOMBIE 状态 | v 父进程调用 wait() / waitpid() | v 内核返回子进程 PID 和退出状态 | v 释放僵尸进程剩余的内核记录在 Linux 中这段过程的典型内核路径包括do_exit()、退出通知以及后续的release_task()。不同内核版本的函数细节可能变化但职责可以概括为子进程负责结束自己父进程负责收尾。2.2 “僵尸进程造成内存泄漏”要说得更准确用“内存泄漏”来形容僵尸进程作为第一层理解没有问题但它不是特别精确。僵尸进程的用户态内存通常已经释放真正留下的是内核里的进程描述信息、PID 等有限资源。一个僵尸进程不会继续执行代码也不会占着大量用户态内存但它会占着进程表中的一个位置和对应的 PID。如果父进程不断创建子进程又一直不回收僵尸就可能越积越多。数量足够大以后PID 和内核进程资源会被耗尽系统无法再创建新进程。这种后果和资源泄漏很像所以可以把它理解成“内核资源被持续占住”但不能简单说成一整块用户内存没有释放。2.3 为什么kill -9对僵尸无效SIGKILL能强制结束一个还在运行、尚未退出的普通进程但它不能让一个僵尸“再死一次”。僵尸已经完成了退出过程。它没有用户态执行流可以终止剩下的只是等待父进程读取的退出记录。此时再向它发送信号不会让它重新执行退出逻辑也无法代替父进程完成回收。这也是“谁也杀不死一个已经死去的进程”这句话真正的含义。容易混淆的一点如果子进程还在运行kill -9可以杀死它但它随后仍可能变成僵尸直到父进程回收。kill -9解决的是“让运行中的进程结束”不是“替父进程善后”。如果父进程先退出僵尸通常会由新的父进程接管。在常见系统中这个角色由init或类似的收养者承担后面仍会有人负责回收。因此父进程退出确实能打破“僵尸一直留着”的局面但这只是兜底机制不应该拿来代替正常的进程等待。3.wait先把最基本的等待接口用起来3.1 函数接口#includesys/types.h#includesys/wait.hpid_twait(int*status);返回值有三种常见情况成功返回被回收子进程的 PID失败返回-1同时设置errno已经没有可等待的子进程通常会因为ECHILD失败。status是输出型参数。父进程把局部变量的地址传给内核等待成功以后内核往这个地址写入子进程的退出状态。如果父进程暂时不关心退出信息可以传入NULLpid_tpidwait(NULL);传NULL只表示“不需要把状态复制给我”并不表示“不要回收子进程”。成功返回时子进程仍然会被回收只是父进程不读取具体退出码。3.2 调用wait时子进程可能处于什么状态wait的关键行为取决于子进程当前的状态子进程状态wait的行为子进程仍在运行父进程可能阻塞直到子进程发生变化子进程已经退出但还没被回收立即返回并完成回收没有符合条件的子进程立即出错返回-1所以“wait一定阻塞”也不准确。它只有在需要等待、并且当前没有已经退出的子进程时才会阻塞。如果父进程调用得比较晚子进程早就变成僵尸wait会马上收走退出信息。当有多个子进程时wait会等待其中任意一个。它一次只处理一个已经结束的子进程不能指望一次调用把所有子进程全部收完。3.3 用代码观察退出码和终止信号测试代码通过一个sleep(20)拉开时间窗口让我们可以选择让子进程正常退出或者在另一个终端把它杀掉。#includesys/types.h#includesys/wait.h#includestdio.h#includestdlib.h#includestring.h#includeerrno.h#includeunistd.hintmain(void){pid_tpid;if((pidfork())-1){perror(fork);exit(1);}if(pid0){sleep(20);exit(10);}else{intstatus;pid_tretwait(status);if(ret0(status0x7F)0){printf(child exit code:%d\n,(status8)0xFF);}elseif(ret0){printf(sig code : %d\n,status0x7F);}}return0;}正常等待 20 秒时子进程执行exit(10)程序输出child exit code:10如果父进程还在等待时在另一个终端向子进程发送SIGKILL程序输出sig code : 9这里的9是SIGKILL的信号编号不是子进程主动执行了exit(9)。两件事在结果上都表现为“进程结束了”但内核记录的原因完全不同。代码里的两个位运算值得先记住(status 0x7F) 0低 7 位为 0表示子进程正常退出(status 8) 0xFF从高 8 位中取出正常退出码status 0x7F取出终止子进程的信号编号。后面会专门拆开status的位布局。实际项目里更推荐使用WIFEXITED、WEXITSTATUS等宏不必每次都手写掩码。4.waitpid把“等谁”和“怎么等”拆开wait的规则比较固定等待任意一个子进程默认阻塞。父进程如果只想等某个特定子进程或者暂时不想被阻塞wait就不够灵活了。waitpid在此基础上增加了两个控制点#includesys/types.h#includesys/wait.hpid_twaitpid(pid_tpid,int*status,intoptions);它的三个参数分别控制三件事pid等哪个或哪一类子进程status退出状态放在哪里options使用阻塞等待还是采用WNOHANG非阻塞检查。课堂里最常用的是下面两种pidpid取值等待对象-1任意一个子进程效果与wait接近pid 0PID 正好等于该值的指定子进程所以wait(status);和waitpid(-1,status,0);在等待任意一个子进程这件事上基本等效。如果指定 PID父进程就不会随便拿其他子进程的结果waitpid(child_pid,status,0);这里只等child_pid。即使别的子进程先结束这次调用也不会因为那个子进程而返回。这个能力在多子进程程序中很重要因为父进程往往需要知道“究竟是哪一个任务完成了”。waitpid的返回值不能统一理解成“成功或失败”要结合options判断返回值含义 0成功回收了一个子进程返回值就是那个子进程的 PID0使用了WNOHANG当前没有已经退出、可以立即回收的子进程-1调用出错具体原因看errno常见错误原因包括ECHILD没有符合条件的子进程EINTR等待过程被信号打断。严格来说返回值0只应出现在WNOHANG这类非阻塞选项中。它表示“现在还没有结果”不是“子进程永远没有结束”所以调用方可以稍后再检查。5.status退出码为什么不能直接当成整数读5.1 一个整数里装了两类信息wait和waitpid都会交回一个int status但它不是普通的“退出码变量”。我们真正要表达的情况至少有两类子进程正常结束并给出一个退出码子进程被某个信号终止。如果只传一个退出码就没法可靠地区分“子进程主动exit(9)”和“子进程被 9 号信号杀死”。为了用同一个整数保留完整信息内核把不同含义编码到不同位段里。只看 Linux 等待状态的低 16 位可以大致画成位段含义bit 06终止子进程的信号编号正常退出时为0bit 7是否产生了核心转储bit 815正常退出时的退出码bit 1631当前这种等待状态不使用例如子进程正常执行exit(10)退出状态的高 8 位会体现为10终止信号字段为 0子进程被SIGKILL杀死时低 7 位是 9而正常退出码字段没有意义。5.2 推荐使用标准宏直接做位运算适合用来理解结构但业务代码更推荐使用标准宏if(WIFEXITED(status)){intcodeWEXITSTATUS(status);// 子进程正常退出code 是退出码}elseif(WIFSIGNALED(status)){intsignal_numberWTERMSIG(status);// 子进程被 signal_number 号信号终止}它们对应的职责是WIFEXITED(status)判断子进程是否正常退出WEXITSTATUS(status)在正常退出的前提下提取退出码WIFSIGNALED(status)判断子进程是否被信号终止WTERMSIG(status)在信号终止的前提下提取信号编号。WEXITSTATUS和WTERMSIG都有前置条件。比如子进程是被信号杀死的再调用WEXITSTATUS就没有意义。更稳妥的判断顺序是先确认waitpid返回了目标子进程的 PID再判断状态类型pid_tretwaitpid(child_pid,status,0);if(retchild_pidWIFEXITED(status)){printf(exit code: %d\n,WEXITSTATUS(status));}elseif(retchild_pidWIFSIGNALED(status)){printf(terminated by signal: %d\n,WTERMSIG(status));}5.3 为什么exit(257)最后变成了1Unix/Linux 进程退出码的有效范围是 8 位也就是0255。子进程传给exit的整数只保留低 8 位超出部分会被截断。257的二进制低 8 位正好是1257 0x101 257 0xFF 0x01 1所以阻塞等待示例中的子进程执行exit(257);父进程通过WEXITSTATUS(status)得到的却是1这不是waitpid把结果抄错了而是进程退出码本身只有 8 位。想让子进程告诉父进程一个更大的数不能依赖退出码应该使用管道、文件、共享内存或专门的 IPC 机制。6. 阻塞等待把 CPU 交出去等子进程结束6.1 完整示例#includestdio.h#includestdlib.h#includesys/types.h#includesys/wait.h#includeunistd.hintmain(void){pid_tpidfork();if(pid0){printf(%s fork error\n,__FUNCTION__);return1;}elseif(pid0){printf(child is run, pid is : %d\n,getpid());sleep(5);exit(257);}else{intstatus0;pid_tretwaitpid(-1,status,0);printf(this is test for wait\n);if(WIFEXITED(status)retpid){printf(wait child 5s success, child return code is :%d.\n,WEXITSTATUS(status));}else{printf(wait child failed, return.\n);return1;}}return0;}运行结果是child is run, pid is : 45110 this is test for wait wait child 5s success, child return code is :1.这里的 PID 每次运行都可能不同最关键的是输出顺序。子进程先打印自己的 PID然后睡眠 5 秒。父进程进入waitpid(-1, status, 0)后没有立刻打印this is test for wait而是一直等到子进程退出才继续执行。这说明options为0时等待是阻塞式的。原始代码里判断顺序可以再优化。虽然status已经被初始化为 0但从语义上看先确认ret pid再读取状态更严谨if(retpidWIFEXITED(status)){printf(child return code: %d\n,WEXITSTATUS(status));}6.2 阻塞期间内核在做什么这里的“阻塞”不是父进程在 CPU 上一直循环检查也不是整个系统停下来了。父进程调用waitpid后内核会做大致几件事在父进程的子进程集合里寻找符合条件的子进程如果找到已经终止的子进程读取退出状态回收对应记录如果子进程还在运行就把父进程放到等待队列并让出 CPU子进程退出时内核发出子进程状态变化通知父进程被唤醒重新检查子进程状态把status复制到父进程的地址空间并返回被回收子进程的 PID。父进程阻塞时通常处于可被唤醒的睡眠状态不再参与正常运行调度。其他进程仍然可以使用 CPU所以“父进程阻塞”和“系统卡死”完全是两回事。这也是阻塞等待适合的使用场景父进程没有别的事情可做只需要等子进程完成任务把 CPU 交出去反而更高效。7. 非阻塞等待父进程一边检查一边做自己的事7.1WNOHANG改变了什么把options改成WNOHANG后waitpid的行为变成如果目标子进程已经退出立即回收并返回它的 PID如果目标子进程还在运行立即返回0父进程继续做其他事如果调用出错返回-1。WNOHANG的字面意思是“不挂起”。它不是让父进程换一种方式死等而是让这次检查变成一次非阻塞尝试。7.2 完整示例课件里的非阻塞示例还放了一组临时任务用函数指针数组模拟父进程在子进程运行期间可以处理的工作。#includeiostream#includevector#includecstdlib#includesys/types.h#includesys/wait.h#includeunistd.htypedefvoid(*handler_t)();std::vectorhandler_thandlers;voidfun_one(){std::cout这是一个临时任务1std::endl;}voidfun_two(){std::cout这是一个临时任务2std::endl;}voidLoad(){handlers.push_back(fun_one);handlers.push_back(fun_two);}voidhandler(){if(handlers.empty()){Load();}for(handler_t task:handlers){task();}}intmain(){pid_t pidfork();if(pid0){std::coutfork errorstd::endl;return1;}elseif(pid0){std::coutchild is run, pid is : getpid()std::endl;sleep(5);exit(1);}else{intstatus0;pid_t ret;do{retwaitpid(-1,status,WNOHANG);if(ret0){std::coutchild is runningstd::endl;}handler();}while(ret0);if(retpidWIFEXITED(status)){std::coutwait child 5s success, child return code is :WEXITSTATUS(status).std::endl;}else{std::coutwait child failed, return.std::endl;return1;}}return0;}这段程序没有固定的打印次数因为父进程每轮循环能执行多少次取决于调度器和机器负载。运行形态大致是下面这样但重复次数并不固定child is run, pid is : ... child is running 这是一个临时任务1 这是一个临时任务2 child is running 这是一个临时任务1 这是一个临时任务2 ... wait child 5s success, child return code is :1.重点不是输出多少次而是父进程在 5 秒等待窗口里没有停在waitpid里。它每检查一次waitpid(..., WNOHANG)发现子进程还没退出返回0父进程打印child is running父进程调用handler()做临时任务再进入下一轮检查。子进程退出后某一次waitpid返回子进程的 PID循环条件不再成立父进程读取退出状态并结束等待。7.3 非阻塞不等于“没有成本”这个示例使用了非常紧凑的轮询只要子进程没结束父进程就一轮接一轮检查。对于 5 秒的演示程序很容易看明白知识点的作用但放到真实程序里如果什么都不做地高速轮询会白白消耗 CPU。实际工程中非阻塞等待通常和事件循环、定时检查或其他任务配合使用。父进程确实有事情可做时就在两次检查之间处理那些工作没有事情可做时也可以设置合适的等待节奏而不是空转。还有一个容易被忽略的点waitpid也可能被信号打断并返回-1此时errno EINTR。如果程序要求非常稳健可以把这种错误与真正的失败分开处理决定是否重试。8. 把整条知识链串起来回头看这几节其实是在逐步回答同一个问题父进程怎样既知道子进程的结果又把子进程完整地送走fork之后父子进程各自执行。子进程结束任务通过退出码或终止信号留下结果。它不能立刻把自己所有痕迹抹掉因为父进程可能还没来取结果。于是它先进入僵尸状态等待父进程处理。父进程使用wait或waitpid读取结果并让内核完成最后回收。wait适合“等任意一个子进程”waitpid可以指定具体子进程还可以通过WNOHANG改成非阻塞检查。status不是简单的退出码。内核把“正常退出码”和“终止信号”放在同一个整数的不同位段里因此要先通过WIFEXITED、WIFSIGNALED判断过程是怎么结束的再读取对应信息。退出码只有 8 位所以exit(257)最终拿到的是1。阻塞和非阻塞的区别则可以落到父进程是否愿意把 CPU 交出去对比项阻塞等待非阻塞等待典型调用waitpid(pid, status, 0)waitpid(pid, status, WNOHANG)子进程未退出时当前执行流挂起等待唤醒立即返回0子进程退出后立即回收并返回 PID下一次检查时回收并返回 PID适合场景后续工作必须依赖子进程结果父进程还有别的任务要处理最后需要分清四个容易混在一起的说法子进程退出子进程自己的执行结束了子进程变成僵尸内核保留了退出信息等待父进程读取父进程等待成功父进程拿到了结果僵尸被回收进程被信号终止这是子进程结束的一种方式不等于正常退出码。把这几层分清以后wait、waitpid和status就不再是三组孤立的 API而是一条完整的生命周期子进程留下结果父进程读取结果内核完成清理。fork负责让进程开始分叉进程等待负责让这段父子关系有始有终。