尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

《从零入门Linux系统篇(十九):进程篇·三——僵尸进程与孤儿进程:深入理解进程退出与回收机制》

《从零入门Linux系统篇(十九):进程篇·三——僵尸进程与孤儿进程:深入理解进程退出与回收机制》 在上一篇文章中我们已经认识了Linux中的进程状态知道一个进程并不是从创建到结束始终保持同一种状态。它会运行、睡眠、阻塞也可能被暂停。更重要的是进程的生命周期并不会随着“代码执行结束”就简单画上句号。当一个子进程退出而父进程迟迟没有处理它的退出信息时会发生什么它可能变成一个看起来已经“死掉”却又没有彻底消失的进程——僵尸进程。反过来如果父进程先一步退出而子进程还在继续运行又会发生什么这时子进程就会成为孤儿进程并被系统重新接管。乍一看僵尸和孤儿听起来像两个有点吓人的名字但它们其实描述的是 Linux 进程生命周期中非常典型的两种状态。一个涉及子进程退出后的资源回收另一个涉及父子关系变化后的进程接管。本文就从这两个特殊进程出发带你继续往进程生命周期的深处走一层为什么进程退出后还会留下痕迹这些信息到底存在哪里为什么1号进程会主动“收养”孤儿这些看似奇怪的现象背后其实都有一套严谨的内核机制。目录一、僵尸进程Zombie Process1.1 什么是僵尸进程1.1.1 僵尸进程的定义与产生原因1.1.2 子进程退出后退出信息保存在哪里1.2 僵尸进程的危害——为什么会占用系统资源1.2.1 僵尸进程为什么会持续存在1.2.2 进程退出后所谓的“内存泄漏”还存在吗1.3 僵尸进程实战——观察与模拟Z状态1.3.1 编写僵尸进程模拟程序1.3.2 使用命令行监控并识别Z状态1.4 深入拓展——内核对象分配与SLAB技术1.4.1 什么是SLAB1.4.2 内核数据结构的对象缓存机制二、孤儿进程Orphan Process2.1 什么是孤儿进程2.1.1 孤儿进程的定义与产生原因2.1.2 谁来“托底”——一号进程的领养机制2.1.3 认识 1 号进程——systemd2.2 孤儿进程实战——观察与模拟验证2.2.1 编写孤儿进程模拟程序2.2.2 命令行监控与进程关系分析2.3 两个容易混淆的进程细节2.3.1 父进程退出后为什么自己不会变成孤儿进程2.3.2 前台进程与后台进程是如何发生变化的一、僵尸进程Zombie Process在Linux系统里一个进程的代码跑完了绝不意味着它的生命就此画上句号。恰恰相反子进程退出后会先踏入一个特殊的过渡地带——僵尸状态Z状态。此时的它代码已经释放数据已经消失唯独那张PCB户口本还孤零零地悬在系统的进程表里等着有人来料理后事。这个“死而不僵”的存在就是我们今天要拆开的第一个话题。1.1 什么是僵尸进程想象这样一个场景你走在路上一个人突然从你身边冲过去紧接着“咚”的一声摔在地上猝死了。此刻他虽然已经没有了生命体征但因为死因不明他的遗体和随身物品对应子进程的task_struct、退出码和退出信号必须原封不动地留在原地谁也不能擅自收尸。这个“躺着等人来查”的阶段就是僵尸状态。直到你报警警察赶到现场拍照、翻看口袋、登记身份信息、查明死因一条条记录完毕对应父进程调用waitpid获取子进程的退出状态这才挥手让救护车把遗体拉走火化入殓。直到这一刻死者在这个世界上留下的最后痕迹才被彻底抹去真正进入“死亡状态X状态”。在Linux里子进程退出的这套流程跟这个场景如出一辙代码跑完了进程死了但PCB没被回收它就成了僵尸。这些僵尸飘在进程表里不干活、不占内存只是占着一行记录等着父进程来“认尸”。如果父进程一直不来它们就会一直挂在那儿越长越多。这就是僵尸进程的全部秘密。1.1.1 僵尸进程的定义与产生原因我们创建子进程的初衷说白了就是让它去替我们完成某个任务。任务干完了结果怎么样成功、失败、还是半路崩了父进程必须有个交代。于是当子进程退出时内核做了一件很干脆的事把它占用的代码段、数据段全部回收内存一个字节都不留。但唯独那张PCBtask_struct子进程的户口本被故意扣了下来暂时留在内核里。这里面藏着一个非常核心的结论你的代码和数据可以释放但你的PCB不能走为什么因为PCB里记着子进程的退出码、退出信号、运行统计这些“临终遗言”。如果内核把这些信息一并抹了父进程就再也查不到子进程到底怎么死的。所以保持Z状态本质上就是给父进程留一个窗口让它有机会来读取子进程退出时的那些关键信息。读完内核才会把PCB也收走僵尸才真正灰飞烟灭。1.1.2 子进程退出后退出信息保存在哪里僵尸进程的“临终遗言”就刻在它那张PCB户口本的几个专属字段里。Linux内核的task_struct结构体里专门划出了几个成员来记录进程的退出状态struct task_struct { // ... long exit_state; // 进程的退出状态如 EXIT_ZOMBIE、EXIT_DEAD int exit_code; // 退出数字比如 exit(0) 里的那个 0 int exit_signal; // 退出信号如果它是被信号干掉的这里记下信号编号 // ... };当子进程一脚踏进僵尸状态时内核会把它的exit_state标成EXIT_ZOMBIE然后把退出码和退出信号规规矩矩地填进对应字段。从此这个子进程就只剩这张户口本还活着数据全是“临终口供”。父进程要做的就是调用wait()或waitpid()系统调用这两个接口我们后面讲进程等待时会专门细说从子进程的task_struct里把exit_code和exit_signal读出来。读完内核才会把这张PCB收走僵尸才算真正入土为安。换句话说僵尸进程的PCB就是一个临时档案袋父进程不拆开看它就永远在系统里悬着。1.2 僵尸进程的危害——为什么会占用系统资源1.2.1 僵尸进程为什么会持续存在如果父进程自己忙得焦头烂额或者干脆当甩手掌柜不关心、不回收、不读取子进程的退出信息那么子进程的Z状态就会一直悬在那儿没人收尸。问题就出在这里。子进程的用户空间内存代码、数据、堆栈确实被释放得干干净净但它的task_struct还牢牢占着内核空间里的一块物理内存。千万别小看这个结构体它里面塞了上百个字段是一个实打实的内存大户。如果一个父进程不停地fork创建子进程子进程跑完就退父进程却从不调用wait去回收那么这些僵尸的task_struct就会在内核空间里越堆越多。一个子进程留一张户口本十个留十张一万个就留一万张每一张都是货真价实的内核内存开销。这就是典型的内核内存泄漏。你的程序会变得越用越卡系统资源被这些“活死人”一口一口啃掉。更可怕的是这种泄漏不是发生在用户态你没法靠重启自己的程序彻底解决得把那些僵尸全部清理掉内存才能吐出来。所以僵尸进程不是闹着玩的它是会真实拖垮系统的隐患。1.2.2 进程退出后所谓的“内存泄漏”还存在吗分两种情况看答案截然不同。子进程自己在用户空间申请的内存比如malloc出来的那些不用担心。子进程一退出操作系统会把它整个用户空间的内存全部强制回收一个字节都不会剩。这部分内存泄漏的风险为零。Z状态残留的内核task_struct这才是真正的麻烦。只要父进程不退出、不调用wait回收这张户口本就永远占用着内核内存。它不随子进程的退出而消失反而会一直悬在那里直到父进程亲自来收尸。更要命的是常驻内存的进程。像Nginx、数据库这类服务器后台服务一跑就是几个月甚至几年。如果它们在工作过程中时不时fork出子进程子进程退出后又忘了回收每漏一个内核里就多一具僵尸。日积月累这些僵尸会一点点蚕食内核内存最终可能把整个系统的内存啃到枯竭直接导致服务器崩溃。所以结论很清晰用户空间的内存泄漏会随进程退出而终结但僵尸进程造成的内核内存泄漏只要父进程还活着就永远不会自动消失。处理僵尸进程是每个长期运行的服务程序都绕不开的必修课。1.3 僵尸进程实战——观察与模拟Z状态1.3.1 编写僵尸进程模拟程序光靠脑补不够我们直接写一段C程序把“子进程先退、父进程甩手不管”的场面完整复现出来。#include iostream #include unistd.h #include sys/types.h #include cstdlib int main() { pid_t id fork(); if (id 0) { std::cerr fork error std::endl; return 1; } else if (id 0) { // 子进程只活 5 秒然后就地去世 int count 5; while (count--) { std::cout 我是子进程PID: getpid() , 剩余寿命: count s std::endl; sleep(1); } std::cout 子进程已退出进入僵尸状态... std::endl; exit(0); } else { // 父进程一直忙自己的完全不管子进程死活 while (true) { std::cout 我是父进程PID: getpid() , 正在忙碌中没空收尸... std::endl; sleep(1); } } return 0; }1.3.2 使用命令行监控并识别Z状态另开一个终端把下面这条监控脚本贴进去跑起来。它会每隔一秒刷新一次进程列表子进程咽气的那一瞬间你绝不会错过while true; do ps -axj | head -n 1 ps -axj | grep myprocess | grep -v grep; sleep 1; echo ---------------------------------------; done五秒倒计时一结束你会看到两个非常扎眼的变化子进程的STAT栏从S睡眠变成了Z僵尸。加号还在说明它还占着前台身份只是人已经没了。子进程的COMMAND栏末尾冒出来一个幽灵般的标记[myprocess] defunct。defunct 翻译过来就是“已故的、不再存在的”这是僵尸进程的官方认证标签。从此这个Z状态就焊在那儿了因为父进程还在自己的循环里忙活压根没有要回收的意思。只要父进程不退出也不调用wait这个僵尸就会一直飘在进程表里成为系统里一道安静又顽固的风景。1.4 深入拓展——内核对象分配与SLAB技术当父进程终于良心发现调用waitpid()把退出信息取走了这块残留下来的task_struct到底是怎么被销毁的这就得把目光伸向Linux内核里一套相当精巧的内存管理机制了。1.4.1 什么是SLAB在Linux运行期间像task_struct、mm_struct、file这类内核结构体对象简直可以称得上是“高频日用品”。它们会被反复创建比如你每fork 一次就得多一个task_struct进程退出它们又会被销毁。一进一出频率极高。那问题来了如果每次创建这些结构体都老老实实跑去向操作系统申请新的物理内存页每次销毁又直接把这些页释放掉这来回折腾的成本有多高每一次底层的内存申请和释放都要牵涉到内核页表映射、虚拟地址和物理地址的转换这些操作极其耗时完全是高射炮打蚊子。为了解决这个“高频小对象”的管理难题Linux内核引入了SLAB内存分配器包含SLAB/SLUB/SLOB几个具体实现。它的核心思路一句话就能说清先把一批对象提前造好放在缓存池里谁要用就直接从池子里拿用完了也不真销毁只是擦干净放回池子等下次再用。这样一来频繁创建和销毁带来的页表操作开销就被一次性摊薄了内核的内存管理效率瞬间上了一个台阶。换句话说SLAB就是内核给那些“频繁生、频繁死”的小结构体开的一个“对象缓存池”。task_struct的回收最后一步就是被擦净了放回这个池子里而不是真的把内存页还回去。这也解释了为什么僵尸进程的户口本能在父进程收尸之后被那么干净利落地处理掉不是销毁是回收复用。1.4.2 内核数据结构的对象缓存机制SLAB技术的本质其实就是内核给那些高频小结构体开的一个“对象池”。这个池子的运作逻辑可以拆成两个动作来看——回收和复用。回收不等于彻底销毁当僵尸进程的收尸流程走完内核准备释放它的task_struct时并不会把这块内存真的还回物理内存管理系统。它只是轻轻把这块内存标记成“空闲unused”然后送进一个空闲队列也就是slab缓存池里等着被再次使用。这就像退房时不是把房子拆了而是把房间打扫干净、挂上“可入住”的牌子。创建时的极致复用等到系统里又有新进程诞生需要一块新的task_struct时内核不会去物理内存里现挖一块而是直接伸手到这个缓存池里捞一块现成的、已经标为空闲的内存出来。稍稍改几个字段就变成了一块全新的task_struct。省去了重新申请、重新映射页表的全部开销。通过这种“对象缓存”的思路内核成功绕开了频繁申请和释放物理内存的昂贵开销。进程创建和销毁的效率因此提升得相当可观。在大量并发fork的场景下这个设计说是生命线也不为过。二、孤儿进程Orphan Process在多进程的家族体系里除了“子进程先走、父进程不闻不问”造成的僵尸进程还有一种情况正好相反父进程先一步退出留下子进程孤零零地继续运行。这些没了爹的孩子就是孤儿进程。2.1 什么是孤儿进程2.1.1 孤儿进程的定义与产生原因在 Linux 系统中一个父进程可能因为正常执行完毕、中途异常崩溃、或者被信号直接杀掉而退出。无论哪种死法只要它下面还有子进程在跑那些子进程瞬间就失去了唯一的监护人沦为孤儿。2.1.2 谁来“托底”——一号进程的领养机制操作系统是个极度注重安全和稳定的系统它绝不允许任何进程在没有监护人的情况下四处游荡。原因很现实如果这些孤儿没人管等它们跑完退出、进入僵尸状态时就永远不会有父进程来为它们收尸。一个没人收尸的僵尸又回到了上一节那个内存泄漏的老问题上。所以Linux内核早早设计了一套领养机制来兜底。核心结论一句话只要父进程先退出操作系统就会强制指派1号进程把剩下的所有孤儿子进程全部领养过去。这个 1 号进程在老版本的Linux里叫init而在现代发行版CentOS 7及以上、Ubuntu等中它的名字已经换成了systemd。换了马甲职责不变它是系统里所有进程的最终监护人永远在线专门负责给失去父进程的孤儿一个家并在它们退出后完成收尸。有了这位“系统级大家长”孤儿进程才不会变成无人认领的僵尸。2.1.3 认识 1 号进程——systemd1号进程是Linux内核启动后创建的第一个用户空间进程。它是整个系统所有用户进程的“老祖宗”地位高得吓人登录认证、系统服务管理、设备初始化这些底层杂活全都由它帮着内核打理。当我们登录操作系统时系统会为我们自动创建一个bash。而这个bash的创建者正是这位1号进程。systemd是它的名字它本身就是操作系统的一部分从开机那一刻起就一直驻留在内存里直到关机才消失。正因为它的地位如此特殊由它来接管孤儿进程再合适不过。孤儿子进程一退出systemd就会立刻调用wait()族函数把它们的task_struct回收得干干净净绝不产生内存泄漏。有它在孤儿就不会变成僵尸。顺带提一句其实还有一个更老的祖宗0号进程。不过它开机之后很快就被替换掉了属于系统启动瞬间的过渡角色这里就不展开聊了。你只需要记住1号进程systemd是当代Linux里所有孤儿的终极监护人。2.2 孤儿进程实战——观察与模拟验证2.2.1 编写孤儿进程模拟程序光听理论不过瘾我们直接写一段C语言代码把“父进程活5秒就撒手人寰子进程则赖着不走”的场面搬到屏幕上。#include stdio.h #include sys/types.h #include unistd.h #include stdlib.h int main() { pid_t id fork(); if (id 0) { perror(fork error); return 1; } else if (id 0) { // 子进程没有退出计划一头扎进死循环 while (1) { printf(我是子进程pid: %d, ppid: %d\n, getpid(), getppid()); sleep(1); } } else { // 父进程只活 5 秒时间一到拍拍屁股退出 int cnt 5; while (cnt) { printf(我是父进程pid: %d, ppid: %d, 剩余寿命: %d\n, getpid(), getppid(), cnt--); sleep(1); } printf(父进程生存期结束已退出\n); } return 0; }运行之后屏幕上会先交替出现父子进程的各自播报。等那5秒倒计时走完父进程吐出最后一句“已退出”头也不回地消失了。而子进程呢它还在继续刷屏唯一不同的是它打印出来的ppid 会悄悄变掉原来的爸爸不在了那个新的ppid就是1号进程systemd的ID。一瞬间孤儿就找到了新的监护人。接下来我们就在另一个终端里用ps把这一切看个真切。2.2.2 命令行监控与进程关系分析在终端里编译并运行程序然后另开一个窗口用ps命令高频捕捉进程状态你会看到这样一段输出流我是父进程pid: 22702, ppid: 21931, 剩余寿命: 5 我是子进程pid: 22703, ppid: 22702 ... 父进程生存期结束已退出 [abc]$ 我是子进程pid: 22703, ppid: 1 我是子进程pid: 22703, ppid: 1前5秒里一切如常。子进程的ppid稳稳指向22702那是它的亲生父亲。屏幕上的父子俩你一句我一句正常的家庭日常。第5秒一到父进程准时退出命令行提示符重新跳了出来仿佛刚刚那场父子对话只是场短暂的演出。然而就在Shell提示符的后头子进程却还在疯狂刷屏好像什么都没发生过。唯一变了的是它的ppid。原来那个22702不见了取而代之的是一个孤零零的数字1。这不是巧合这是领养机制的现场实拍。就在父进程咽气的那一瞬间1号进程systemd已经伸手把这名孤儿揽进了怀里。从此它的监护人变了但它的生命还在继续。孤儿进程的整个诞生过程全在这几行输出里讲完了。2.3 两个容易混淆的进程细节在模拟孤儿进程的过程中你一定会撞上两个让人愣一下、想通了又拍大腿的现象。这两个细节恰恰是理解进程家族关系的关键。2.3.1 父进程退出后为什么自己不会变成孤儿进程这个问题问得相当刁钻。按我们前面的逻辑“父进程退出子进程还在运行子进程就变成孤儿”那父进程退出的时候它自己的父亲不是也活得好好的吗那个父亲是谁就是一直蹲在后台盯着它的命令行终端bash进程。既然bash还活着父进程怎么没变成孤儿原因其实很干脆父进程退出时它的子进程还赖在世上所以子进程成了孤儿。而父进程自己的老爹bash从始至终都活着还一直在后台盯着它。父进程刚咽气bash就秒调用wait把它的退出信息回收得干干净净。整个收尸流程在极短时间内走完父进程直接被清理出局根本没有机会触发孤儿机制。换句话说孤儿机制的触发前提是“退出者还有活着的子进程且没有活着的父进程”。而父进程退出时虽然它确实还有活着的子进程但它的父进程bash也没死。两个条件只满足了一个所以它成不了孤儿。子进程那边则刚刚好反过来——它没有活着的父进程了父进程退出却又还没退出于是孤儿身份稳稳落到它头上。一个退出一个存活身份的转换就藏在这一进一出之间。2.3.2 前台进程与后台进程是如何发生变化的注意观察你在终端里的操作。当父进程退出、控制台重新跳出[abc...]$ 提示符之后不管你疯按多少下Ctrl C那个子进程都完全无动于衷照样刷屏。为什么键盘突然管不了它了因为子进程被 1 号进程领养后它的“人设”发生了一个隐蔽的转变从台前退到了幕后。进程阶段状态标志键盘交互能力阶段一父进程健在时S/R带号为前台进程占据终端可以Ctrl C 强制终止阶段二被1号进程领养后S/R不带号为后台进程失去终端控制Ctrl C杀不死它但依然能向屏幕刷屏输出那个小小的号就是前台身份的象征。父进程活着的时候子进程跟它一起霸占着终端你的键盘输入只能喂给它们。可父进程一退出终端控制权重归Shell子进程被systemd领走成了没资格碰终端输入的后台进程。它能继续往屏幕上吐字却再也听不到你的Ctrl C了。那怎么清理这个赖在后台不走的孤儿指望Ctrl C是没戏了只能祭出最直接的九号信号。另开一个终端执行kill -9 22703把22703换成你实际查到的子进程PID就行。一记SIGKILL下去这位后台孤儿就彻底安静了。如果这篇文章对你有帮助欢迎点赞、收藏、关注三连支持你的反馈是我继续肝下一篇的最大动力。我们下篇见。
返回列表