多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Linux僵尸进程深度解析:从wait/waitpid到SIGCHLD回收机制

Linux僵尸进程深度解析:从wait/waitpid到SIGCHLD回收机制 记得刚转做 Linux 运维那会儿半夜被监控电话叫醒一台业务服务器的进程数飙升load average 直接干到 30 多。登录上去ps aux一看满屏都是Z状态的进程僵尸进程像秋天的落叶一样铺了一层。当时我的第一反应是“有 bug把进程杀了重启就好了”结果kill -9杀了一轮又一轮僵尸还在那儿杵着纹丝不动。后来被老前辈点了一句僵尸进程杀不死能杀它的只有它的父亲。那一瞬间我才意识到Linux 里“父进程的等待”不是一个可选项而是整个进程生命周期里最基础也最容易被忽略的一环。这次就把这块掰开揉碎讲清楚父进程为什么要等、等的时候内核做了什么、怎么等才不出事故以及我在生产环境里踩过的那些坑。1. 进程不会凭空消失先看清楚生命周期1.1 用户态看不到的那条“作业线”很多人学 Linux 进程管理脑子里只有三条命令ps、kill、top。进程一挂直接杀掉仿佛任务就结束了。但站在内核的角度进程从诞生到彻底消失中间还有一段容易被忽略的阶段——终结。一个进程从我执行fork()创建出来到它调用exit()退出这只是“它自己认为自己结束了”并不意味着它在内核的进程表里被清掉了。内核为了保留“这个进程是怎么死的、资源占用了多少、退出码是什么”这些信息会把进程标记为EXIT_ZOMBIE僵尸态然后等它的父进程来收尸。这个收尸动作学名叫回收reaping对应的系统调用是wait()、waitpid()这一族。父进程通过它们拿到子进程的退出状态告诉内核“我知道我的孩子死了信息我收到了”内核这才真正把进程描述符释放进程才算彻底消失。这条“作业线”用大白话讲就是fork() 创建子进程 → 子进程运行R/S状态 → 子进程调用 exit()变成 Z 僵尸态 → 父进程调用 wait/waitpid() 回收 → 内核释放 task_struct进程消失你可能想这流程这么绕直接让内核在子进程退出时一把清掉不就完了不行。内核在子进程退出后依然保留信息是有实际需求的——父进程需要知道孩子是正常退出还是崩溃、退出码是多少、消耗了多少 CPU 时间。如果立刻清理这些信息就全丢了如果不清理进程表会被僵尸占满。所以“保留信息 等父进程来取”就成了最合理的设计。1.2 为什么父进程“必须”等待——僵尸态的由来理解了上面那条作业线你就明白一个铁律子进程不会因为自己退出而自动被系统清理它必须被父进程“回收”。如果父进程不调用wait()家族的函数退出后的子进程就停留在僵尸态一直占着内核进程表里的一个位置。僵尸进程占资源吗从内存角度看它不再执行代码不占 CPU不多占内存核心就剩一个task_struct和一些内核数据。但它占着 pid、占着进程表项、占着父子关系链如果数量累积到进程数上限pid_max新进程就 fork 不出来了。我遇到过的最极端例子某 Java 应用 fork 了大量子进程又没回收最后整台机器fork() returned -1连ps都快跑不动了。僵尸还有一个特性让新手特别抓狂kill -9对它无效。为什么因为SIGKILL是发给“活着能响应信号的进程”的而僵尸进程已经退出了用户态执行内核里它只是一个等待被回收的“尸体”没有谁能替它执行消亡动作。真正能让它消失的只有父进程要么父进程调用wait/waitpid要么父进程自己先死掉僵尸被孤儿进程收养机制转交给init或者容器里的 subreaper由新的父亲来收尸。所以在系统里看到 Z 状态进程时先别急着 kill先看它爹是谁这才是排查的正确方向。2. 等待的三种姿势wait、waitpid 与 SIGCHLD2.1 wait() 的适用边界wait()是最简单的回收接口原型只有一行#include sys/wait.h pid_t wait(int *status);它的行为是阻塞直到当前进程的任意一个子进程退出然后回收它把退出状态写到status里返回被回收的子进程 pid。注意两个关键词“任意一个”和“阻塞”。如果我有三个子进程我只调用一次wait()那它回收的是这三个里最早退出那个剩下两个如果也退出了就会继续停留在僵尸态等着我再调用。而且wait()在没有任何子进程存活时会一直睡下去永远不会返回。所以生产代码里我基本不直接用裸wait()它更适合写一些小脚本和教学 demo功能太粗。2.2 waitpid() 与精细控制真正干活的是waitpid()pid_t waitpid(pid_t pid, int *status, int options);它把wait()的“随便回收哪个”改成了“可以指定回收哪个”。pid参数支持几种模式pid 0只等指定的这个子进程pid -1等任意子进程等价于wait()pid 0等同一个进程组里的任意子进程pid -1等指定进程组里的任意子进程options参数是关键。WNOHANG表示“没有退出的子进程时别睡立刻返回 0”使用者可以循环轮询WUNTRACED表示“子进程因信号停止时也返回”WCONTINUED表示“子进程被 SIGCONT 恢复运行时也返回”。这几个参数组合起来基本能覆盖所有多进程管理的需求。我在写 supervisor 类工具时几乎清一色用waitpid(-1, status, WNOHANG)配合事件循环轮询既有精确性又不会阻塞主流程。2.3 SIGCHLD 信号驱动回收纯粹靠轮询去检查子进程是否退出有延迟且浪费。Linux 提供了一条“推送式”的路径子进程退出时内核向父进程发送SIGCHLD信号。父进程注册好信号处理函数在收到信号时主动去waitpid()收尸这才是一套成熟的多进程程序该有的写法。信号处理里最容易被忽略的一个坑waitpid要在信号处理函数里返回而且是循环等直到-1且 errno 为ECHILD才停。因为信号不会排队——如果同时有五个子进程退出内核可能只给父进程发一次SIGCHLD如果你只waitpid一次就退出处理函数剩下四个子进程就变僵尸了。正确姿势void sigchld_handler(int sig) { int saved_errno errno; pid_t pid; int status; while ((pid waitpid(-1, status, WNOHANG)) 0) { // 处理每一个退出的子进程 } errno saved_errno; }这里还有个容易翻车的点信号处理函数里最好别调用非异步信号安全的函数waitpid本身是安全的但像printf、malloc就不行。我在SIGCHLD处理器里只做回收和记录 pid具体业务处理放到主循环里做。3. 内核里发生了什么终止、回收与终结的完整链路3.1 exit() 之后的状态迁移从内核视角看一个进程调了exit()之后进入do_exit()这函数干的活非常多释放大部分用户态资源文件描述符、内存映射、页表、信号量等把当前进程状态置为TASK_ZOMBIE向父进程发送SIGCHLD把进程的退出码、运行时间等信息留在task_struct里调度器切换到下一个可运行进程注意一个细节僵尸进程还在进程链表上它并没有从进程列表中摘除只是不再参与调度、不再拥有大部分资源。它存在唯一目的就是等待某个 “reaper” 来调用wait。这个状态的完整迁移可以这么看TASK_RUNNING → TASK_ZOMBIE →父进程 wait/waitpid→ task_struct 被释放如果父进程比子进程先死这个就不成立了。子进程会变成“孤儿”内核会自动把它reparent重新挂载到init进程PID 1名下。init的默认行为是不断waitpid它的所有子进程所以孤儿进程通常很快被清掉不会变僵尸。但在容器环境里这个角色由容器的 1 号进程subreaper承担如果 1 号进程自己写得不规范、不去回收容器里的孤儿僵尸就会长期滞留。3.2 回收背后的内核数据结构Linux 里每个线程/进程就是一个task_struct它是一个接近 KB 级别的复杂结构体包含了 pid、状态、父子指针、资源统计、信号处理函数表、内存描述符等一大堆东西。fork()时创建一份exit()时释放大部分但task_struct本身不会在exit()时立刻释放——必须在waitpid()回收成功后内核才执行release_task()把这个结构体真正释放。这也是为什么 PID 不会立刻复用父子进程有血缘关系表示这个关系的指针还指向僵尸进程内核不会把 PID 复用给新进程直到wait完成。从运维角度release_task()延迟执行意味着僵尸进程占用的 PID 不会释放。生产环境里一旦发现 PID 用尽最先要查的就是有没有大量 Z 状态进程。3.3 孤儿进程与 init 收养孤儿进程跟僵尸是两个容易搞混的概念。孤儿进程是“父进程先死了自己还活着”的进程僵尸进程是“自己死了没人收尸”的进程。两者经常同时出现父进程死了子进程变成孤儿此时如果子进程状态还是僵尸内核会把它交给subreaper/init清掉。内核决定把子进程重新挂到谁名下的逻辑大概是这样如果当前进程有设置PR_SET_CHILD_SUBREAPER那它就是这个“子进程收割者”否则按进程树向上找最终落到 1 号进程容器里则是容器的 1 号进程这条规则对“守护进程双重 fork”特别重要。一个典型 daemon 会fork() # 第一次 fork → 父进程立即 exit让子进程变成孤儿 → 子进程 setsid() 创建新会话 → 再次 fork()让第二层子进程继续干活为什么要二次 fork第一个目的就是让中间层子进程立刻退出使真正的 daemon 进程变孤儿最终被 init 收养这样它不会有“父亲”需要等也不会因为父进程阻塞而连带挂掉。第二次 fork 是为了确保 daemon 进程不会获取控制终端setsid 后再 fork 的子进程不会成为会话首进程也就不会被终端信号打扰。4. 实操从一段“会出事的代码”改到能上生产4.1 基础版 wait 同步等待先看一段最普通的 demo它的问题是很多初学多进程的人都会犯的#include stdio.h #include stdlib.h #include unistd.h #include sys/wait.h int main() { pid_t pid fork(); if (pid 0) { // 子进程 printf(child running, pid%d\n, getpid()); exit(42); } // 父进程只回收一次 int status; waitpid(pid, status, 0); printf(child exited, status%d\n, WEXITSTATUS(status)); return 0; }这段代码如果只有一个子进程没啥问题。但如果我 fork 两个子进程只回收一次另一个就会变僵尸。很多隐藏在业务代码里的资源泄漏就是从这种“只 wait 一次”开始的。4.2 信号处理版解决 EINTR 与竞争先用一个相对正规的版本看框架#include stdio.h #include stdlib.h #include unistd.h #include signal.h #include sys/wait.h #include errno.h static void sigchld_handler(int sig) { int saved_errno errno; pid_t pid; while ((pid waitpid(-1, NULL, WNOHANG)) 0) { // 可以在这里记录日志 } errno saved_errno; } int main() { struct sigaction sa; sa.sa_handler sigchld_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; sigaction(SIGCHLD, sa, NULL); pid_t pid fork(); if (pid 0) { sleep(1); exit(0); } // 父进程继续做别的事情 pause(); return 0; }这段代码解决了两个隐患第一信号处理函数里循环waitpid(..., WNOHANG)保证多个子进程同时退出时不会滞留僵尸。WNOHANG这里虽然看起来是“非阻塞”实际上它是防止 handler 在最后一个子进程已被回收、没有子进程可等时卡住。while 条件 0保证有子进程可回收就回收返回 0 或 -1 立即退出循环。第二保存恢复errno。虽然这里没用到errno但在真实项目里handler 外面可能正在做文件 I/O信号一来errno就变了外层代码排查问题时会被带到沟里。4.3 循环回收与 WNOHANG 非阻塞生产环境里我更喜欢在主事件循环里轮询waitpid(-1, status, WNOHANG)而不是完全依赖信号。原因是多线程环境下SIGCHLD的归属比较麻烦后面会细说。伪代码如下while (1) { // 干别的活epoll、定时器、状态上报 do_work(); // 顺手收尸 int status; pid_t pid; while ((pid waitpid(-1, status, WNOHANG)) 0) { log_child_exit(pid, status); if (WIFEXITED(status)) { // 正常退出处理退出码 } else if (WIFSIGNALED(status)) { // 被信号杀死处理 signal } } if (pid -1 errno ! ECHILD) { // 有异常需要处理 } }这里WNOHANG是灵魂。它让waitpid()在没有僵尸时立即返回 0主循环不会被阻塞有僵尸时返回 pid逐个回收。配合信号驱动效果更好SIGCHLD处理器负责唤醒/标记主循环有子进程退出主循环负责实际回收两边分工能避免信号处理器里执行复杂逻辑的风险。4.4 子进程嵌套时的完整场景真实服务通常不是“fork 一个子进程”这么简单子进程可能再 fork 孙进程子进程可能被ptrace还可能因为不同信号停止或恢复。这就要用上waitpid的完整形态pid_t pid waitpid(-1, status, WNOHANG | WUNTRACED | WCONTINUED);WUNTRACED和WCONTINUED在处理作业控制类程序时几乎必加。如果一个子进程收到SIGSTOP停住了你不加WUNTRACEDwaitpid不会返回你就感知不到它的状态变化如果它后来被SIGCONT恢复不加WCONTINUED同样感知不到。进程管理器、shell、容器运行时这类程序这几个 flag 会决定“作业状态上报”是否准确。我在写一个实验性容器 shim 时踩过这个坑子进程被 OOM 后不是立刻退出而是先进入SIGSTOP状态我用waitpid(-1, NULL, WNOHANG)轮询永远返回 0主流程一直认为子进程还在跑结果整个容器的健康检查全乱了。加上WUNTRACED后立刻就能捕捉到子进程的 stop 事件从而触发后续处理。5. 常见问题速查与故障排查实录5.1 僵尸进程爆表先找爹再刨根现场表现top里大量Z进程ps看 PPID 都指向同一个进程。排查顺序# 找出所有僵尸进程及其父进程 ps -eo pid,ppid,stat,comm | awk $3 ~ /^Z/ # 看父进程是不是已经处于 D 状态不可中断睡眠或卡在 IO ps -o pid,stat,wchan:30 -p PPID # 如果父进程是正常 R/S 状态大概率是代码没调用 wait # 如果父进程是 D 状态说明它自己卡在内核 IO 里根本没机会执行 wait最常见的两类根因一是业务进程 fork 子进程后忘记回收代码问题二是父进程调了wait但子进程被信号停止TASK_STOPPED而不是退出父进程却没用WUNTRACED导致回收迟迟不发生。还有一类少见但恶心子进程被ptrace附着调试父进程的wait会被调试事件抢走忽略后子进程就僵了。处理临时僵尸有个土办法既然僵尸只能被父进程回收那我直接把父进程杀掉让子进程变孤儿交给 1 号进程或 subreaper去收尸。但这只能清当前僵尸治标要治本必须修代码或重启那个犯病服务。5.2 信号竞争导致回收不到信号驱动回收有一个经典竞争子进程退出的时机可能发生在父进程注册sigaction(SIGCHLD)之前。如果父进程先 fork、子进程秒退父进程还没来得及装 SIGCHLD handler信号已经丢了用户态永远不知道有子进程退出僵尸就会一直留着。解决思路有两种先sigaction安装 handler再fork()。这样信号不会丢在注册之前。安装 handler 之后立刻主动执行一次非阻塞waitpid兜底。比如在 fork 之前先设置好 handler并在 fork 后启动事件循环前调用一次“清僵尸”逻辑。生产代码里我推荐“先注册后 fork 事件循环兜底轮询”双保险。纯粹靠信号处理总觉得不太踏实。5.3 多线程与子进程的 wait 归属多线程程序里 fork 出子进程回收问题会比单线程复杂得多。因为wait()和waitpid()的效果是回收“当前调用线程所在进程”的子进程而信号是发给进程的没有一个“固定线程”能保证接住 SIGCHLD。在 Linux 里多线程进程中如果某个线程调fork()子进程只会留下当前调用线程其他线程全部消失父进程里哪个线程会收到SIGCHLD取决于信号分发规则。如果你的回收逻辑放在一个固定线程但内核把信号发给另一个线程就会造成“僵尸被漏收”。比较稳的做法是主线程或专门的信号线程阻塞SIGCHLD然后用sigwaitinfo()来接收把信号处理变成一个可预测的调用点。代码如下sigset_t set; sigemptyset(set); sigaddset(set, SIGCHLD); sigprocmask(SIG_BLOCK, set, NULL); // 在线程或主循环里统一等待 int sig; siginfo_t info; sigwaitinfo(set, info); // 收到后统一回收 int status; while (waitpid(-1, status, WNOHANG) 0) { // ... }这样信号不再“随机砸到某个线程”而是由专门的逻辑去取、去处理能规避绝大多数多线程回收问题。5.4 system()/popen() 的隐藏回收很多人没意识到C 里system(some_command)和popen()内部也经历了 fork wait 的过程。system()会在内部 fork 一个子进程执行 shell 命令然后阻塞等待子进程结束拿到退出码。这意味着如果你在业务主循环里频繁调system()线程会被卡住子进程相关资源也不是立即释放的。popen()更隐蔽它 fork 一个子进程用管道通信但popen()返回后父进程如果只pclose()而不去 wait虽然pclose()内部会等命令结束但它对退出状态的处理比较粗暴。如果程序里大量用popen且没正确处理返回值管道缓冲区一满子进程就会卡在写操作上父进程卡在读操作上最后变成一个谁也不动的死锁现场。另外shell 脚本里每个外部命令其实是 shell 进程 fork 出来子进程shell 会负责回收但如果 shell 脚本启动一个后台进程又立刻退出这个后台进程就会成为孤儿回收责任转给init。前台和后台的回收策略完全不同排查的时候一定要把“谁 fork、谁回收、谁等待”理清楚。6. 几个值得记住的现场经验这一路折腾下来我自己的体会是Linux 给进程设计“僵尸态”本质上是在信息保真和资源释放之间找平衡。子进程退出时要保留退出码、终止信号、资源统计而又不能把这些信息无限保留所以“父进程必须 wait”就成了一个必经的仪式。理解了这一点很多进程管理的问题都能一眼看穿。最后分享几个我在生产环境长期使用的自查手段ps -eo pid,ppid,stat,cmd | awk $3 ~ /^Z/这条命令我几乎刻进肌肉记忆了任何可疑的性能问题我都会先跑一遍确认有没有僵尸堆积。写服务端代码时把SIGCHLD当作“需要回收子进程”的通知而不是“子进程结束”这个事件本身主流程里仍然要周期性执行非阻塞waitpid兜底。容器环境里特别注意 1 号进程的行为。如果 1 号进程不调wait孤儿僵尸就会堆积在容器里最终拖垮 PID 空间。写容器首进程时要像写 daemon 一样把回收逻辑做好。排查 D 状态僵尸时别只看应用层。父进程如果卡在不可中断睡眠说明它可能在做磁盘/网络 IO此时即使你想“杀掉父进程来清理僵尸”也要先确认杀掉它会不会引发其他连锁故障。父进程的等待本质是 Linux 对“什么时候真正终结”的严谨界定。它可以说是整个进程管理里最不性感、最容易被忽略的部分但只要在生产环境吃一次僵尸爆表的亏你大概就能体会这个“回收仪式”到底有多重要了。
返回列表