十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Linux进程状态全解析:僵尸进程与孤儿进程的实战排查与处理

Linux进程状态全解析:僵尸进程与孤儿进程的实战排查与处理 Linux的进程状态这块说难不难但确实容易绕晕尤其是僵尸进程和孤儿进程这两个名字听着就很“吓人”的概念。当初我在学习Linux进程管理时在这上面踩了不少坑面试被问“孤儿进程和僵尸进程有什么区别”时支支吾吾在服务器上看到一堆僵尸进程不知道怎么办甚至一度以为kill -9能把僵尸进程干掉。这篇博文就围绕Linux进程状态、僵尸进程、孤儿进程这三个主题把我自己的学习总结、实测过程、排查方法和踩坑教训全部梳理一遍。内容适用面很广——无论是刚入门Linux想系统学一遍进程模型的新手还是工作中需要排查服务器进程异常的同学都可以参考。我会尽量用实际的命令、代码和现场操作来还原整个学习过程而不是只给你背概念。1. 先把进程状态这棵树的根摸清楚进程状态不是孤立的一个知识点它是理解僵尸进程和孤儿进程的基础。很多人一上来就背“僵尸进程就是子进程退出但父进程没回收”但是为什么要回收回收的是什么为什么进程还能有“已退出却还在”的状态这些问题不搞清楚遇到具体问题还是不会处理。1.1 教科书之外的进程状态全景我们在Linux内核源码里看task_struct中的state字段其实进程状态远不止教科书上说的“五状态模型”。日常使用和面试中你至少需要掌握这些状态RTASK_RUNNING进程正在运行或者在运行队列里等待调度。注意ps显示R不代表它正霸占CPU可能只是“就绪”状态。这是最常见的误解。STASK_INTERRUPTIBLE可中断睡眠。进程在等待某个条件比如等待用户输入、等待网络数据能被信号唤醒。我们写程序时最常见的sleep()就处于这个状态。DTASK_UNINTERRUPTIBLE不可中断睡眠。一般是进程在内核态等待IO完成比如等待磁盘写回。这种状态是真正的“喊破喉咙也叫不醒”连kill -9都没用。TTASK_STOPPED被停止。收到SIGSTOP或SIGTSTP信号后进入可用SIGCONT恢复。tTASK_TRACED被跟踪。一般是被调试器比如gdb附加后进程暂停并受调试器控制。ZEXIT_ZOMBIE退出但未被父进程回收。这就是僵尸进程下文重点讲。XEXIT_DEAD真正的死透状态只是个瞬时状态几乎不会在ps里看到。还有一个很容易被忽略的点内核里还有Ssleeping和Ddisk sleep之外Linux2.6之后引入了TASK_KILLABLE表现为S状态但要杀死或可以被致命信号唤醒。不过这属于进阶话题先不展开。1.2 ps里的STAT字段逐个拆给你看运行ps -ef或者ps aux时STAT这一列就是进程状态的字母缩写。我建议大家养成一个习惯——不要只看进程名先看状态列。一个简单的排查技巧如果服务器上出现大量状态不是S或R的进程大概率有问题。ps aux的STAT字段实际是复合的除了主状态字母还可能有附加标识和类含义高优先级进程N低优先级nice值大于0L进程有页面锁定在内存中实时进程常见s会话领导者一般是某个终端或守护进程的组长l多线程进程用clone创建线程组的进程常见位于前台进程组比如你正在终端里跑的前台命令举个例子Ss代表什么S是可中断睡眠s是会话领导者。你SSH登录后跑的bash通常就是Ss状态。R意味着该进程正在前台运行比如你执行find / -name *.conf时它通常显示为R。1.3 为什么进程退出后内核还不放手理解僵尸进程的关键是搞清楚进程退出时究竟发生了什么。当进程调用exit()或_exit()结束时内核并不会立刻把该进程的所有资源全部销毁。它会先把进程的文件描述符、内存页面、信号处理器等资源释放掉但保留task_struct结构本身——因为里面记录了进程的退出状态码、消耗的CPU时间、内存使用统计等信息。这些信息是留给谁看的留给父进程。父进程需要调用wait()或waitpid()来“收尸”读取子进程的退出状态内核才会把这个残留的task_struct彻底释放。如果父进程一直不调用wait()子进程就会停留在Z状态。这就是僵尸进程的来历。你可以把父进程和子进程想象成甲方和乙方乙方干完活子进程退出需要甲方验收签字wait甲方如果一直不签字乙方的工位就不能拆一直占着。内核里那个“工位”就是task_struct。2. 僵尸进程的来龙去脉僵尸进程一旦出现很多人第一反应就是慌。其实几个僵尸进程并不可怕真正可怕的是你不知道它为什么出现也不知道怎么让它消失。这一章我会用实际操作来演示僵尸进程是怎么产生的并解释为什么kill -9对它无效。2.1 写一个demo亲手制造僵尸进程为了直观感受我写一个极简的C程序故意让父进程在子进程退出后睡60秒不调用wait()#include stdio.h #include unistd.h #include sys/wait.h int main() { pid_t pid fork(); if (pid 0) { printf(父进程 pid%d, 子进程 pid%d, 开始睡觉...\n, getpid(), pid); sleep(60); } else if (pid 0) { printf(子进程 pid%d, 马上退出\n, getpid()); _exit(0); } else { perror(fork error); return 1; } return 0; }编译运行gcc zombie.c -o zombie ./zombie 然后在另一个终端查看进程状态ps -o pid,ppid,stat,cmd | grep zombie你会看到类似这样的输出PID PPID STAT CMD 123 1000 S ./zombie 124 123 Z [zombie] defunct子进程的STAT一列是Z命令行后面还带了一个defunct意思是“已经不存在的进程但还占着进程表项”。看到这个状态基本就能确定这是个僵尸进程。注意PPID一列父进程的PID是123也就是我们的./zombie主进程僵尸进程的父进程就是它自己。2.2 为什么kill -9杀不死僵尸进程很多人拿到僵尸进程第一反应就是kill -9 PID结果发现进程依然顽强地躺在进程表里。这是因为僵尸进程已经死了——它的所有执行上下文早就没了根本不处理任何信号。信号是用来给“活着的进程”发指令的你对一具“尸体”发信号怎么可能有反应所以我们常说僵尸进程只能靠父进程调用wait()来回收或者等父进程自己也退出后由它新的父进程一般是PID 1即systemd或init来清理。那如果父进程一直不退怎么办实操中有两种常规思路一是找到父进程确认业务可以重启时直接杀掉父进程让僵尸进程被收养并清理二是如果父进程是重要的服务进程不能随意杀掉那就只能等业务自己修复或者临时通过监控手段观察数量变化。所以处理僵尸进程的关键不是“把它杀掉”而是“让它被回收”。2.3 僵尸进程的危害不是吓唬你单个僵尸进程可能只是看着碍眼但一旦数量多了问题就严重了。最直接的影响是进程表资源被占满。每个僵尸进程都保留着一个PID和task_struct结构而Linux系统的PID数量是有限的。你可以临时查看一下系统的pid_maxcat /proc/sys/kernel/pid_max一般机器默认是32768或者更大但再怎么大也经不起无限消耗。当一个僵尸进程占据PID却不释放新进程fork时如果分配不到PID就会报“Resource temporarily unavailable”——这是生产环境里很典型的事故。而且僵尸进程没法被调度CPU利用率是0内存也释放了大部分所以有时候几万个僵尸进程看起来系统负载并不高但它默默在阻塞新进程的创建。我不止一次在生产环境看到类似场景某个Java服务或者Python服务崩溃后没有正确处理SIGCHLD信号导致它频繁fork的子进程全部变成僵尸最终引发雪崩。所以别信“僵尸进程无害”的说法——少量确实无害大量就是事故现场。3. 孤儿进程被遗弃之后去哪了僵尸进程是“子进程死了父进程不埋”那孤儿进程恰好反过来是“父进程死了留下子进程一个人”。理解孤儿进程的关键是搞清楚父进程退出后子进程被谁接管。3.1 孤儿进程是怎么产生的还是用一个demo来演示。父进程fork出子进程后立刻自己退出#include stdio.h #include unistd.h int main() { pid_t pid fork(); if (pid 0) { printf(父进程 pid%d 即将退出不会等子进程\n, getpid()); _exit(0); } else if (pid 0) { sleep(3); printf(子进程 pid%d我的父进程现在是 %d\n, getpid(), getppid()); } return 0; }父进程退出后子进程继续运行但你猜它的ppid会变成什么子进程自己打印的结果一般是这样子进程 pid456我的父进程现在是 1在传统的System V Unix环境下父进程PPID会变成1即init进程在现在的Linux发行版上这个“1号进程”可能就是systemd。这就是“被收养”的过程。孤儿进程一旦产生就会自动被PID为1的进程收养随后由这个收养者负责回收它的退出状态。这里有个细节值得注意孤儿进程不会变成僵尸进程。因为它父进程一死PID 1就会接手成为它的新父进程。后面无论这个孤儿进程怎么退出PID 1都会调用wait来回收它。所以孤儿进程本身并不可怕它只是“没爹了但马上有人接收”。3.2 收养者到底是谁init还是systemd在很多老资料里你看到的是“孤儿进程由init进程收养”因为早期的Unix/Linux里PID 1就是init。但现在主流发行版大多用systemd作为PID 1它同样担任“收养孤儿”的职责。区别在于init通过wait回收子进程systemd也有对应的机制。但在容器环境里情况会变得微妙。如果你启动一个Docker容器PID 1可能是你自己的程序也可能是bash。这时候如果容器里产生孤儿进程它会被PID 1进程收养但前提是PID 1的进程得主动调用wait来回收子进程。如果PID 1是bash或一个不处理SIGCHLD的简单程序孤儿进程退出后就很容易变成僵尸累积在容器里。这就是很多容器环境“僵尸进程越来越多”的根本原因。后续如果大家有兴趣这是值得单独写一篇的排查笔记。3.3 区分孤儿、僵尸和守护进程很多人把“守护进程”和“孤儿进程”混在一起其实这是两码事。守护进程是有意为之的、脱离终端的后台常驻进程通常通过setsid创建新的会话脱离控制终端并且把工作目录切换到根目录。而孤儿进程是“你爸没等你先走了”属于一种被动产生的结果。一个孤儿进程可以变成守护进程吗理论上如果它愿意它可以调setsid重新建立会话但它刚成为孤儿时并不等于守护进程。有一个很容易考的面试点如何把一个进程变成无害的守护进程经典做法就是“fork一次之后父进程退出让子进程成为孤儿子进程再调用setsid”。这里就用到了孤儿进程的机制所以“孤儿进程”和“守护进程”并非完全没有关联但概念上要分清。4. 实战定位僵尸进程并安全处理理论知识讲完进入实战环节。这一节我会按“发现问题 - 定位关系 - 处理 - 从代码层面预防”的顺序把完整的排查流程走一遍。这里的命令、参数和判断思路都是我在实际服务器上反复验证过的。4.1 快速定位ps、top、pstree一起用从整体上先看系统中有没有僵尸进程最直观的是top命令。尤其要养成看zombie这一项的习惯top在top输出的上半部分Tasks一行会显示zombie数量。如果这个数长期不为0甚至持续增长基本就能确认系统里有僵尸进程在累积。接下来要定位具体是哪些进程以及它们的父进程是谁ps -e -o pid,ppid,stat,cmd | grep -w Z这个命令很实用我建议直接收藏。它只过滤状态为Z的进程并且把PID和PPID同时列出来。找到僵尸进程的PPID之后再确认父进程是谁ps -p PPID -o pid,ppid,stat,cmd如果需要看进程树用pstree -p更直观。有一次我排查一个Apache环境就是通过pstree -p看到某个worker进程是僵尸节点的父进程最后定位到是FastCGI进程管理器的回收逻辑出了问题。进程树是排查父子关系最好的工具比单独看ps列表高效得多。4.2 处理思路杀父进程是最快的解决办法假设你已经定位到一个僵尸进程PID为10086PPID为10010。处理逻辑很简单先看父进程10010是什么业务。如果这个父进程本身就是常驻的服务进程且可以直接重启那就重启这个服务。父进程重启后僵尸子进程会被PID 1收养并回收。如果父进程是某个不重要的临时进程直接kill -9 PPID让僵尸进程变成孤儿并被收养。如果父进程是容器里的1号进程情况会复杂一些可能需要重建容器或者在容器内安装并启动一个真正的init进程比如tini或s6来回收子进程。有一条必须反复强调的禁忌不要直接kill -9僵尸进程本身。你可能会看到某些文章说“可以kill -9僵尸进程”那是误传因为僵尸进程根本不响应信号。真正要kill的是它的父进程。4.3 代码层面避免僵尸进程signal和wait实际问题中解决僵尸进程的最好办法不是出了问题再到处找父进程而是在代码里就避免它产生。最常见的方案有三个。方案一父进程调用wait()或waitpid()阻塞等待子进程退出#include stdio.h #include sys/wait.h #include unistd.h int main() { pid_t pid fork(); if (pid 0) { int status; waitpid(pid, status, 0); printf(子进程已回收退出码%d\n, WEXITSTATUS(status)); } else if (pid 0) { sleep(2); return 42; } return 0; }需要注意wait()是阻塞的如果父进程还要做别的事情用waitpid(pid, status, WNOHANG)非阻塞轮询会更合适。原理很简单父进程处理完子进程的退出信息内核就能彻底释放子进程的task_struct。方案二用signal(SIGCHLD, SIG_IGN)告诉内核“我不关心子进程退出状态你自动帮我收尸”。这是Linux上比较高效的做法但要注意它是System V风格的行为BSD风格更推荐显式wait。在写守护进程或网络服务时这个方案特别方便#include stdio.h #include signal.h #include unistd.h int main() { signal(SIGCHLD, SIG_IGN); pid_t pid fork(); if (pid 0) { printf(子进程 pid%d 即将退出\n, getpid()); _exit(0); } else if (pid 0) { sleep(10); } return 0; }这样子进程退出时内核直接回收不会出现Z状态。方案三如果要既处理SIGCHLD又获取退出码可以在信号处理器里调用waitpid#include stdio.h #include signal.h #include sys/wait.h #include unistd.h void sigchld_handler(int sig) { int status; pid_t pid; while ((pid waitpid(-1, status, WNOHANG)) 0) { printf(回收子进程 %d\n, pid); } } int main() { signal(SIGCHLD, sigchld_handler); pid_t pid fork(); if (pid 0) { sleep(1); _exit(0); } else if (pid 0) { sleep(5); } return 0; }这里用waitpid(-1, ...)是循环回收所有已退出的子进程用WNOHANG避免阻塞。很多网络服务框架处理子进程退出时就是这么写的你可以直接套用。4.4 双fork让中间层消失还有一个经典的“双fork”技巧。父进程fork出一个子进程然后这个子进程再fork出孙子进程接着子进程立刻退出孙子进程由PID 1收养。这样父进程只需要回收第一个子进程孙子进程就完全交给系统管了#include stdio.h #include unistd.h #include sys/wait.h int main() { pid_t pid fork(); if (pid 0) { // 父进程只等第一个子进程 wait(NULL); printf(父进程已经回收第一个子进程\n); } else if (pid 0) { // 第一个子进程再 fork 孙子然后自己退出 pid_t pid2 fork(); if (pid2 0) { printf(子进程 pid%d 退出留下孙子进程继续干活\n, getpid()); _exit(0); } else if (pid2 0) { sleep(2); printf(孙子进程 pid%d, 父进程是 %d\n, getpid(), getppid()); } } return 0; }双fork的核心思想就是利用“孤儿进程被PID 1收养”的特性把“要长期运行但不需要父进程管理等”的后台任务直接甩给系统。很多守护进程的启动流程本质上就是这种模式的变体。5. 面试高频题与避坑经验最后一部分我结合这些年在面试中被问到的题和实际踩过的坑做一个速查型的总结。这部分专门为面试前突击和遇到线上事故时快速对照准备。5.1 面试最常问的几个问题整理几个高频问题我尽量给出可以直接背的答案框架问题一什么是僵尸进程答案框架子进程先于父进程退出父进程没有调用wait/waitpid回收子进程的退出状态导致内核保留子进程的task_struct进程表现为Z状态无法响应信号无法被kill。问题二如何清理僵尸进程答案框架僵尸进程不能直接kill。先找到它的父进程如果父进程可以重启则重启父进程或者kill父进程让僵尸进程被PID 1收养和回收。代码层面要通过wait/waitpid或设置SIGCHLD的处理器来避免。问题三孤儿进程是怎么产生的有什么影响答案框架父进程先退出子进程还在运行子进程被PID 1收养变成孤儿进程。本身没有负面影响反而是一种保护机制。守护进程的创建经常利用这个特性。问题四wait和waitpid有什么区别答案框架wait等待任意一个子进程退出如果没子进程退出就阻塞waitpid可以指定等待哪个子进程也可以配合WNOHANG实现非阻塞。问题五SIGCHLD信号的作用是什么答案框架子进程状态变化退出、暂停、恢复时内核会向父进程发送SIGCHLD信号。父进程可以通过捕获该信号在handler里调用waitpid回收子进程也可以set SIG_IGN让内核自动回收。5.2 实际环境里容易踩的几个坑首先是D状态进程无法杀掉。很多人以为kill -9是万能药但遇到STAT为D的进程你会发现信号发了毫无反应。这种情况通常是进程在做不可中断的IO操作比如网络文件系统出问题。我的经验是先检查系统日志确认是不是磁盘或NFS异常再想办法恢复底层存储千万别随意重启节点否则数据一致性风险很大。如果D状态长时间不消失确实没有更好的办法只能等内核IO超时或重启机器。其次是容器环境里的僵尸危机。容器内的PID 1如果是个不处理SIGCHLD的bash或简单应用子进程一多就会累积僵尸。一个偏方是在容器启动命令里用tini或s6作为1号进程它会自动回收所有子进程。这也是为什么很多官方容器镜像特别是跑多进程服务的镜像都会内置一个init进程的原因。第三个坑是脚本排查ps输出时容易误判。比如grep defunct只能看到僵尸进程的命令行标识但如果你用grep -v grep排除自身很容易漏掉那些名字里没有defunct但状态是Z的进程。最稳妥的过滤方法还是按状态列过滤ps -e -o pid,ppid,stat,cmd | awk $3 ~ /^Z/ {print}这样哪怕进程命令行不带defunct字样也能准确抓到Z状态。最后分享一个小技巧当你在生产环境看到僵尸进程数量不涨不降、偶尔冒出来几个的时候先别急着kill父进程。用ps -o lstart看看父进程的启动时间如果父进程是最近才重启过的僵尸进程大概率会被系统逐步清理如果父进程已经跑了几个月僵尸数量还在增长那就要重点检查业务代码里有没有正确维护子进程了。这个判断顺序能帮你避免很多误操作。
返回列表