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

资讯详情

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

【Linux】 进程(5) 僵尸进程与内存泄漏扩展

【Linux】 进程(5) 僵尸进程与内存泄漏扩展 僵尸进程与内存泄漏一个被反复追问的问题一、问题的起源这是一个在学习 Linux 进程管理时几乎每个人都会遇到的思考链条如果我就是不回收子进程呢 ↓ 子进程永远处于 Z僵尸状态 ↓ task_struct 不会被释放了吗 ↓ 那它不就一直占据内存空间吗 ↓ 这难道不是内存泄漏 ↓ 那如果对应的父进程也结束了内存泄漏问题还在吗 ↓ 答案是不在这个思考链看似简单但每一步都值得深入挖掘。僵尸进程到底算不算内存泄漏为什么父进程结束后泄漏就消失了 本文将从内核数据结构、资源回收机制、PID 管理等多个角度彻底讲透这个问题。二、第一步不回收子进程会发生什么2.1 僵尸进程的诞生当子进程调用exit()退出时内核会执行do_exit()做以下事情释放用户态资源地址空间mm_struct、文件描述符表files_struct、信号处理sighand_struct、文件系统信息fs_struct等全部释放保留内核数据结构task_struct和其内核栈thread_info不释放设置退出状态exit_state EXIT_ZOMBIE通知父进程向父进程发送SIGCHLD信号此时子进程就成为了一具僵尸——用户态的肉体已经腐烂消失但内核中的骨架task_struct还留在进程表中。2.2 为什么要保留 task_struct这是理解整个问题的关键。task_struct中保存着父进程可能关心的信息字段含义父进程为何需要exit_code退出码子进程是正常退出exit(0)还是异常退出exit(1)exit_signal终止信号子进程是被哪个信号杀死的如 SIGSEGVutime/stime用户态/内核态 CPU 时间子进程消耗了多少计算资源min_flt/maj_flt次缺页/主缺页次数子进程的内存访问行为统计nvcsw/nivcsw主动/被动切换次数调度统计信息maxrss最大常驻内存子进程的内存峰值这些信息必须等父进程通过wait()/waitpid()读取后才能释放。如果子进程一退出就把task_struct销毁父进程就永远无法得知子进程的退出原因和资源使用情况了。本质僵尸态是一种等待父进程收尸的中间状态它的存在是为了在父子进程之间传递退出信息。三、第二步task_struct 不释放占据多少内存3.1 task_struct 的实际大小很多人以为僵尸进程会占用大量内存实际上并非如此。僵尸进程已经释放了所有用户态资源只保留了内核中的task_struct。在 64 位 Linux 系统上task_struct的大小大约为 1.7KB ~ 2KB不同内核版本略有差异。我们可以通过内核代码确认// include/linux/sched.h struct task_struct { #ifdef CONFIG_THREAD_INFO_IN_TASK struct thread_info thread_info; // 约 64 字节 #endif volatile long state; // 8 字节 void *stack; // 8 字节 atomic_t usage; // 4 字节 unsigned int flags; // 4 字节 unsigned int ptrace; // 4 字节 int on_cpu; // 4 字节 int exit_state; // 4 字节 int exit_code, exit_signal; // 8 字节 int pdeath_signal; // 4 字节 unsigned int jobctl; // 4 字节 pid_t pid; // 4 字节 pid_t tgid; // 4 字节 struct task_struct __rcu *real_parent; // 8 字节 struct task_struct __rcu *parent; // 8 字节 struct list_head children; // 16 字节 struct list_head sibling; // 16 字节 struct task_struct *group_leader; // 8 字节 struct pid_link pids[PIDTYPE_MAX]; // 多个 pid 引用 struct signal_struct *signal; // 8 字节已释放为 NULL struct sighand_struct *sighand; // 8 字节已释放为 NULL struct mm_struct *mm; // 8 字节已释放为 NULL struct mm_struct *active_mm; // 8 字节 struct files_struct *files; // 8 字节已释放为 NULL struct fs_struct *fs; // 8 字节已释放为 NULL // ... 还有调度、时间、权限、审计等字段 };虽然字段很多但总计也就约 2KB。相比一个正常运行的进程至少几 MB 到几 GB 的用户态内存僵尸进程的内存开销微乎其微。3.2 真正的开销不是内存而是 PID比内存更严重的问题是 PID 号的占用。Linux 系统中 PID 是有限资源。默认情况下PID 的最大值由/proc/sys/kernel/pid_max决定cat /proc/sys/kernel/pid_max # 3276832 位系统默认 # 419430464 位系统默认即 2^22每个僵尸进程占用一个 PID 号。如果父进程不断创建子进程且从不回收PID 号会被逐渐耗尽。当 PID 耗尽时fork()会失败并返回EAGAINpid_t pid fork(); if (pid -1) { perror(fork); // fork: Resource temporarily unavailable // errno EAGAIN }结论僵尸进程的危害主要不是内存泄漏2KB/个可以忽略而是 PID 资源泄漏。大量僵尸进程会导致系统无法创建新进程。四、第三步这到底算不算内存泄漏4.1 什么是内存泄漏严格意义上的内存泄漏Memory Leak 是指程序动态分配的内存在不再使用后既没有被释放也无法再被访问导致这块内存永久丢失直到程序退出或系统重启。经典的内存泄漏示例void leak() { char *buf malloc(1024); // 分配 1KB // 忘记 free(buf) } // 函数返回后buf 指针丢失1KB 内存永远无法访问和释放4.2 僵尸进程符合内存泄漏的定义吗部分符合但不完全是经典意义上的内存泄漏。对比维度经典内存泄漏僵尸进程分配的内存是否未释放是是task_struct 约 2KB是否无法再访问是否——父进程可以通过 wait() 访问并释放是否永久丢失是直到进程退出否——父进程调用 wait() 即可回收泄漏主体用户态堆内存内核 task_struct PID回收方式进程退出后由 OS 回收父进程 wait() 或父进程退出后由 init 回收关键区别经典内存泄漏的内存是真的丢了——指针没了谁也找不到那块内存。而僵尸进程的task_struct是有人知道它在哪——父进程知道子进程的 PID可以随时调用wait()来回收。它更像是一种延迟回收或待回收资源而非严格意义上的泄漏。4.3 业界的通常说法在实际交流和面试中大家通常会说僵尸进程会造成资源泄漏主要是 PID 和少量内核内存如果父进程长期不回收且不断创建子进程最终会导致 PID 耗尽系统无法创建新进程。用资源泄漏比内存泄漏更准确因为泄漏的主体是 PID进程号和 task_struct内核对象不是用户态内存这种泄漏是可回收的父进程 wait 即可不是永久丢失真正的危害是 PID 耗尽导致 fork 失败而非内存不足五、第四步父进程结束后泄漏还在吗5.1 答案不在这是整个思考链的最后一环也是最关键的一环。当父进程也退出后僵尸子进程的资源泄漏问题会自动解决。5.2 为什么——孤儿进程与 init 收养机制当父进程退出时内核会处理它的所有子进程。对于还活着的子进程R/S/D/T 态和已经成为僵尸的子进程Z 态内核会将它们的父进程重新设置为 init 进程PID 1——这个过程称为孤儿进程收养。父进程退出 ↓ 内核遍历父进程的所有子进程 ↓ 将每个子进程的 real_parent 指向 initPID 1 ↓ 对于僵尸子进程init 会立即调用 wait() 回收 对于活子进程init 成为新的父进程子进程退出时 init 会回收 ↓ 原父进程的僵尸子进程被彻底释放泄漏消失5.3 init 进程为什么能自动回收init 进程PID 1是 Linux 系统中所有进程的老祖宗。它有一个非常重要的职责回收孤儿进程。init 在启动时会注册SIGCHLD信号处理函数或者在主循环中不断调用waitpid()来回收所有被它收养的子进程// init 进程的伪代码 int main() { // ... 初始化系统 ... while (1) { int status; // 回收所有已退出的子进程包括被收养的孤儿进程 // WNOHANG不阻塞没有子进程退出则立即返回 pid_t pid waitpid(-1, status, WNOHANG); if (pid 0) { // 成功回收一个子进程 continue; } // 没有需要回收的子进程做其他工作或休眠 // ... } }现代 Linux 系统中init 可能是 systemd、upstart 或其他 init 系统但它们都承担了回收孤儿进程的职责。5.4 完整的资源回收链路让我们用一个完整的例子来梳理整个流程【初始状态】 父进程 PPID1000创建子进程 CPID1001 ↓ 【子进程退出】 C 调用 exit() → 释放用户态资源 → 进入 Z 态 → 向 P 发送 SIGCHLD ↓ 【父进程不回收】 P 忽略 SIGCHLD不调用 wait() → C 永远处于 Z 态 → C 的 task_struct约 2KB和 PID(1001) 被占用 → 这就是所谓的资源泄漏 ↓ 【父进程也退出】 P 调用 exit() → 内核执行 do_exit() → 内核调用 forget_original_parent() → 遍历 P 的所有子进程将它们的父进程改为 initPID 1 → CZ 态被 init 收养 ↓ 【init 回收】 init 检测到新收养的子进程 C 处于 Z 态 → init 调用 waitpid(1001, status, 0) → 内核执行 release_task(C) → 释放 C 的 task_struct 和内核栈 → PID 1001 归还到 PID 分配器可被重用 ↓ 【最终状态】 C 的所有资源被彻底释放 → 内存泄漏问题完全消失 → 系统恢复正常5.5 一个反直觉的点父进程退出时僵尸子进程会被立即回收很多人以为父进程退出后僵尸子进程会先变成孤儿僵尸然后等 init 慢慢回收。实际上内核在处理父进程退出时会立即将僵尸子进程的状态通知给 init而 init 的回收机制会立刻处理它们。这个过程通常在毫秒级完成你几乎不可能用ps捕捉到被 init 收养但还未回收的僵尸进程。内核中的关键函数是forget_original_parent()定义在kernel/exit.c// kernel/exit.c简化版 static void forget_original_parent(struct task_struct *father, struct list_head *dead) { struct task_struct *p, *n; // 遍历父进程的所有子进程 list_for_each_entry_safe(p, n, father-children, sibling) { // 将子进程的父进程重新设置为 init reparent_thread(p, father, dead); } } static void reparent_thread(struct task_struct *p, struct task_struct *father, struct list_head *dead) { // 找到新的父进程init 或其他线程组中的进程 struct task_struct *new_parent find_new_reaper(father); // 修改父子关系 p-real_parent new_parent; // ... // 如果子进程已经是僵尸态将其加入 dead 列表 // 后续会由 release_task() 统一释放 if (p-exit_state EXIT_ZOMBIE) { list_add(p-ptrace_entry, dead); } }六、延伸思考6.1 如果 init 进程也不回收呢理论上不可能。init 进程的设计职责之一就是回收孤儿进程如果 init 都不回收那整个系统的进程管理就崩溃了。但在某些极端情况下如 init 进程挂起、容器中的 PID 1 进程异常可能会出现 init 无法回收的情况。在 Docker 容器中如果 PID 1 进程没有正确处理SIGCHLD容器内的孤儿进程就不会被回收可能导致僵尸进程堆积。这也是为什么容器中的 PID 1 进程需要特别注意信号处理。6.2 孤儿进程 vs 僵尸进程这是一个经典的面试对比题对比项孤儿进程僵尸进程定义父进程已退出子进程还在运行子进程已退出父进程未回收状态R/S/D/T 等正常状态ZEXIT_ZOMBIE危害无直接危害被 init 收养后正常运行占用 PID 和少量内核内存回收方式子进程退出时由 init 回收父进程调用 wait()或父进程退出后由 init 回收能否被 kill可以正常进程不能已经退出kill 无意义6.3 如何编写不会产生僵尸进程的代码最佳实践注册 SIGCHLD 信号处理函数#include stdio.h #include stdlib.h #include unistd.h #include signal.h #include sys/wait.h #include errno.h void sigchld_handler(int sig) { // 保存 errno避免信号处理函数中修改 errno 影响主程序 int saved_errno errno; // 循环回收所有已退出的子进程 // -1等待任意子进程 // WNOHANG不阻塞如果没有已退出的子进程则立即返回 while (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; // 自动重启被信号中断的系统调用 if (sigaction(SIGCHLD, sa, NULL) -1) { perror(sigaction); exit(1); } // 创建子进程 for (int i 0; i 10; i) { pid_t pid fork(); if (pid 0) { // 子进程执行任务后退出 printf(子进程 %d 运行中\n, getpid()); sleep(1); exit(0); } } // 父进程继续做其他工作 while (1) { sleep(10); } return 0; }关键点说明为什么用while循环而不是if多个子进程可能同时退出而信号不排队同一信号多次递达只处理一次一次SIGCHLD可能对应多个子进程退出需要循环回收全部为什么用WNOHANG信号处理函数中不能阻塞否则会影响主程序的响应如果没有已退出的子进程waitpid立即返回 0循环结束为什么保存和恢复errno信号处理函数可能在任意时刻打断主程序如果修改了errno主程序被打断后读取errno会得到错误的值这是信号处理函数的标准写法为什么用sigaction而不是signalsignal在不同 Unix 系统中的行为不一致System V vs BSDsigaction是 POSIX 标准行为明确可控SA_RESTART标志可以自动重启被信号中断的系统调用如read、accept七、面试高频追问Q1僵尸进程会导致内存泄漏吗为什么参考答案 严格来说不算经典意义上的内存泄漏但会造成资源泄漏。僵尸进程只保留了task_struct约 2KB和 PID用户态内存已全部释放。2KB 的内存开销可以忽略但 PID 是有限资源大量僵尸进程会导致 PID 耗尽fork失败。而且这种泄漏是可回收的——父进程调用wait()即可释放不像经典内存泄漏那样永久丢失。Q2父进程退出后它的僵尸子进程会怎样参考答案 父进程退出时内核会调用forget_original_parent()将所有子进程包括僵尸子进程重新设置父进程为 initPID 1。init 进程有专门的回收机制会立即调用waitpid()回收这些僵尸子进程释放它们的task_struct和 PID。因此父进程退出后僵尸进程的资源泄漏问题会自动解决。Q3孤儿进程和僵尸进程有什么区别参考答案孤儿进程父进程已退出子进程还在运行。被 init 收养后正常运行无危害。僵尸进程子进程已退出父进程未调用wait()回收。处于 Z 态占用 PID 和少量内核内存有资源泄漏风险。孤儿进程退出时会被 init 回收不会变成僵尸僵尸进程的父进程如果退出也会被 init 回收。Q4如何避免僵尸进程参考答案父进程显式调用wait()/waitpid()简单但会阻塞父进程注册SIGCHLD信号处理函数在处理函数中用waitpid(-1, NULL, WNOHANG)循环回收非阻塞推荐方案父进程先退出子进程被 init 收养自动回收但不适用于需要父进程长期运行的场景两次 fork父进程 fork 子进程子进程立即 fork 孙子进程然后退出孙子进程被 init 收养父进程只需等待快速退出的子进程Q5kill -9能杀死僵尸进程吗参考答案 不能。僵尸进程已经退出只是task_struct未释放它不响应任何信号包括 SIGKILL。要清除僵尸进程只能让父进程调用wait()回收杀死父进程让 init 收养并回收重启系统Q6为什么 init 进程不会产生僵尸进程参考答案 init 进程PID 1在设计上就承担了回收孤儿进程的职责。它要么注册了SIGCHLD信号处理函数要么在主循环中持续调用waitpid(-1, NULL, WNOHANG)来回收所有子进程。因此 init 的子进程退出后会被立即回收不会堆积成僵尸。但在容器环境中如果 PID 1 进程没有正确实现回收逻辑比如直接用 shell 脚本作为 PID 1容器内仍可能产生僵尸进程。八、总结回到最初的思考链条我们现在可以给出完整的答案如果我就是不回收子进程呢 → 子进程永远处于 Z 状态task_struct 不释放 task_struct 不会被释放了吗 → 是的直到父进程调用 wait() 或父进程退出 占据内存空间 → 只占约 2KB 的内核内存task_struct用户态内存已全部释放 → 更重要的是占用了一个 PID 号 内存泄漏 → 严格来说是资源泄漏而非经典内存泄漏 → 泄漏的主体是 PID 和 task_struct不是用户态堆内存 → 这种泄漏是可回收的父进程 wait 即可不是永久丢失 如果对应的进程结束了内存泄漏问题还在吗 → 不在 → 父进程退出时僵尸子进程被 initPID 1收养 → init 会立即调用 waitpid() 回收释放 task_struct 和 PID → 所有资源彻底释放泄漏消失核心 takeaway僵尸进程的危害主要是 PID 耗尽而非内存不足僵尸进程是可回收的资源延迟释放不是永久内存泄漏父进程退出是僵尸进程的终极清理器——init 会接管并回收一切编写健壮代码的最佳实践是注册SIGCHLD处理函数用waitpid(WNOHANG)循环回收理解了这些你不仅能在面试中从容应对僵尸进程的各种追问更能在实际开发中写出不会泄漏系统资源的健壮代码。
返回列表