Linux进程内核机制详解:task_struct、fork、调度与僵尸进程

发布时间:2026/10/23 3:49:24
Linux进程内核机制详解:task_struct、fork、调度与僵尸进程 1. 先破除一个误区进程不是运行中的程序而是内核维护的一份状态快照教科书上那句进程是程序的一次执行考试能得分但真到了排查线上问题的时候基本帮不上忙。我印象最深的一次是一台机器上某个服务突然不写日志了top里能看到它的 PID但 CPU 占用是 0用kill -9发了信号过去进程纹丝不动过了两三分钟才自己消失。那时候我对着ps输出的那一列D状态盯了很久才想明白一件事进程不是代码在跑而是内核手里维护的一坨数据结构代码只是这坨数据结构被调度上 CPU 时才会短暂发生的事。一旦你把视角从程序切换到内核里的数据结构很多原本想不通的现象就顺了。为什么fork()调用一次会返回两次为什么僵尸进程占着 PID 却查不到任何代码在跑为什么两个进程读同一个文件会互相把偏移量带跑这些问题的答案全都在内核源码里那几个结构体和几条链表上。这篇内容就是把我这些年翻内核源码理解进程的过程整理出来适合已经会写fork()、用过ps、但总觉得概念是飘着的中高级开发者也适合想从会用往看得懂走一步的同学。全程以主流开源内核的源码结构为例讲的是机制不绑定某个具体版本号。1.1 从 top 里那一列奇怪的进程状态说起大多数人学进程是从概念定义开始的但我建议你从top或ps输出的那一列状态字母反过来入手因为它是最直观的入口而且每一个字母都能在内核源码里找到对应的枚举值。你平时看到的R、S、D、T、Z其实对应的是内核里一个叫state的字段它在task_struct里的类型是一个无符号长整型取值来自一组宏定义比如表示可运行的、表示可中断睡眠的、表示不可中断睡眠的、表示停止的、表示僵尸的。这些名字不是随便起的每一个背后都绑定了一套调度行为能不能被信号打断、会不会被调度器选中、退出时会不会立刻释放资源。这里有一个特别容易被忽略的点R状态在top里显示的是运行中但内核里它同时涵盖了正在 CPU 上执行和在运行队列里排队等着执行两种情况。这就是为什么一台 8 核机器上你能看到几十个R状态的进程——真正在跑的只有 8 个其他都在排队。理解这一点很重要因为你在判断系统是不是卡住了的时候R的数量多不代表 CPU 忙得结合运行队列长度和负载均值一起看。我踩过一个坑早年看到一个进程长时间处于R状态第一反应是它在死循环结果用strace一看它其实是在疯狂地做系统调用然后立刻返回每次返回都重新进运行队列。这种进程在宏观上看起来是一直在跑实际上是高频进出调度器性能瓶颈不在 CPU 计算而在系统调用开销。只盯着状态字母不看上下文很容易误判。1.2 task_struct 里究竟装了什么内核描述进程的核心结构体是理解一切的地基。我把它简化成下面这个样子真实结构体有几百个字段但骨架大致如此struct task_struct { volatile long state; /* 进程状态对应 ps 里的那个字母 */ void *stack; /* 内核栈指针每个进程独享 */ unsigned int flags; /* 进程标志位PF_xxx 系列 */ int prio, static_prio, normal_prio; /* 调度优先级 */ struct list_head tasks; /* 挂在全局进程链表上的节点 */ struct mm_struct *mm; /* 用户地址空间内核线程为 NULL */ struct mm_struct *active_mm; /* 借用的地址空间 */ struct files_struct *files; /* 打开的文件描述符表 */ struct fs_struct *fs; /* 当前工作目录、根目录 */ struct signal_struct *signal; /* 信号处理相关的共享部分 */ struct sighand_struct *sighand; /* 信号处理函数表 */ struct cred *cred; /* 权限凭证uid、gid、capabilities */ struct nsproxy *nsproxy; /* 命名空间集合 */ struct css_set *cgroups; /* 所属的控制组集合 */ struct sched_entity se; /* 调度实体调度器只认这个 */ struct thread_struct thread; /* CPU 寄存器上下文切换时保存恢复 */ };看这个结构你会发现一个规律它不是一个被精心设计过的完整对象而是几十年里不同子系统各自往里塞字段的结果。文件系统塞了files和fs信号子系统塞了signal和sighand权限子系统塞了cred容器相关的塞了nsproxy和cgroups调度器塞了se。进程在这一刻就变成了内核所有资源的聚合点——所有需要按进程隔离的东西都挂在这个结构体上或者挂在它指向的结构体上。这解释了为什么创建进程那么贵。你fork()一次内核要申请一个task_struct、一个内核栈、复制一份页表、复制文件描述符表、复制信号处理表、分配一个新的 PID、把它挂进好几个链表、初始化调度实体。每一步都在动内存。所谓进程是资源分配的基本单位在源码里的含义就是这一整套结构体的复制和初始化动作。我的经验是读这个结构体不要试图一次记住所有字段。更好的方式是按需求反查你今天要搞清楚为什么改了工作目录会影响子进程就去查fs_struct要搞清楚为什么容器里的进程看不到外面的进程就去查nsproxy和pid的关系。带着问题读源码效率比从头读到尾高十倍。1.3 为什么地址空间必须挂在进程上task_struct里最重要的一个指针是mm它指向mm_struct描述这个进程的整个用户地址空间代码段、数据段、堆、栈、共享库映射区、页表基址等等。进程之间互相看不见对方的内存本质原因只有一个它们的页表不一样。CPU 每次访存都要经过 MMU 查页表页表的根地址存在一个专用寄存器里不同架构名字不同x86 上常说的 CR3。进程切换时内核把 CR3 换成新进程的页表根地址于是同一个虚拟地址在两个进程里翻译出来的物理地址完全不同。这就是隔离的全部秘密没有魔法。mm_struct里除了页表根还挂着一棵由vm_area_struct组成的树每个节点描述一段连续的虚拟内存区域起始地址、结束地址、权限可读可写可执行、对应的文件如果有的话、是否是共享映射。你cat /proc/pid/maps看到的那一长串输出就是从这棵树上遍历出来的。这里有个很实用的对应关系所谓线程在源码层面就是多个task_struct指向同一个mm_struct。创建线程时内核不走完整的内存复制流程而是直接把mm指针共享过去同时把mm里的引用计数加一。所以线程之间共享全局变量不需要任何特殊机制因为它们看的是同一套页表。反过来线程各自有独立的栈和寄存器是因为task_struct是各自的thread_struct也是各自的。我见过不少人对进程是资源分配单位线程是调度单位这句话背得滚瓜烂熟但一到实际场景就糊涂。比如有人问为什么多线程程序里一个线程崩溃整个进程都挂掉答案就在这个结构关系里信号是进程级别的signal_struct是共享的收到致命信号时整个线程组都会被干掉。搞清楚指针关系这类问题不用背推就能推出来。2. fork 只调用一次却返回两次源码里那条创建链路新手写的第一段多进程代码几乎都会遇到同一个诡异现象一段printf在fork()之后打印了两次。很多人以为是终端出了问题或者编译器优化搞鬼实际上是标准输出缓冲区在fork时被完整复制了一份父子进程各持一份未刷新的缓冲退出时各刷一次于是你看到两遍。这个现象是个很好的引子因为它逼着你去问fork()到底复制了什么为什么它快得像没干活为什么有些资源复制之后父子还会互相影响把这三个问题回答清楚进程创建这条链路就算摸透了。2.1 从用户态调用到 copy_process 的横向拆解一次fork()的完整路径大致是这样用户态代码调用封装函数触发软中断或专用指令陷入内核进入系统调用入口根据系统调用号分发到一个叫sys_fork的入口它再调用通用的克隆函数最终落到copy_process。copy_process是真正的重型工厂它按固定顺序做了一长串事情。我把顺序整理成表方便对照步骤大致动作关键点1复制 task_struct 和内核栈新进程拿到独立的栈但内容是从父进程拷来的2检查各种限制进程数上限、用户进程数上限、权限校验3复制调度相关信息初始化调度实体设置继承的优先级4复制信号处理表处理函数继承但待处理信号队列清空5复制地址空间触发写时复制只复制页表不复制物理页6复制文件描述符表复制表结构共享底层文件对象7复制文件系统信息工作目录、根目录引用计数加一8复制命名空间默认共享clone 参数可指定新建9复制凭证uid、gid、capabilities 继承10分配并设置 PID在新命名空间里分配编号11建立亲属关系挂到父进程的子进程链表上12唤醒新进程放入运行队列等调度器选中顺序不是随便排的。比如第 5 步的地址空间复制必须在凭证复制之前因为复制内存涉及权限检查PID 分配必须在唤醒之前因为一旦进入运行队列其他子系统可能立刻通过 PID 来找这个进程。这类顺序上的约束在内核代码里到处都是读源码时留意这些先后关系比记住每个函数名有价值得多。有一个细节值得单独说新进程的内核栈是从父进程复制过来的这意味着父进程调用链的返回地址、局部变量都还在。这也是为什么fork能做到返回两次——父子进程沿着同一个函数调用链返回只是返回值不同。父进程返回子进程的 PID子进程返回 0。这个设计非常巧妙用同一个返回点区分两种执行流但也埋了个坑如果你在fork之前的调用栈上有锁、有分配的资源子进程会带着一份拷贝继续走容易出现重复释放。我的实操建议是在任何fork之前把调用栈拍平尽量不要在深层嵌套函数里调用它。宁可写个薄薄的封装把准备工作做在前面也不要在一个已经持有一堆状态的函数中途分叉出去。2.2 写时复制省下的到底是什么很多人知道fork用了写时复制但说不清楚它到底省了什么、代价又在哪里。省的是物理内存拷贝。假设父进程用了 1GB 物理内存fork时如果老老实实逐页复制得再分配 1GB还得把内容全搬一遍可能耗时几百毫秒。写时复制的做法是把父进程的页表复制一份给子进程但两张页表里的页表项都指向同一批物理页同时把两边的页表项都改成只读。整个过程只需要复制页表本身——一个进程的页表大小跟它的虚拟内存用量有关通常比物理内存小得多所以速度是数量级的差距。代价体现在后续。任何一方对某个页执行写操作MMU 会触发一个保护异常内核的缺页处理函数介入发现这是个写时复制页于是新申请一个物理页把原内容拷过去改页表指向新页恢复可写重新执行那条写指令。所以对于fork之后立刻exec这种模式写时复制几乎零成本因为根本没机会写。《UNIX 环境高级编程》里提到过fork加exec的组合在某些系统上能用专门的接口优化掉就是这个道理——既然子进程马上就要把自己整个换掉那复制内存纯属浪费。但对于fork之后父子都继续跑很久并且大量写内存的场景写时复制就不划算了。每个被写的页都要经历一次异常、一次分配、一次拷贝总开销可能比一次性复制还高因为你多了几十万次缺页中断。我做过一个粗糙的测量一个持有一批大数组的进程fork之后父子双方都对数组做全量写入整体耗时比顺序复制方案高出不少差距主要来自缺页处理。所以选择方案时不要迷信写时复制一定快得看你后续的访问模式。还有一个容易忽略的成本是页表本身的复制量。虚拟内存用量大的进程页表可能有几十兆fork时这部分是实打实要复制的。有人做过实验一个大量mmap了大文件的进程fork的开销远超预期原因就在这里。2.3 哪些资源是复制、哪些是共享这是fork最容易埋雷的地方。我把常见资源列一下资源行为潜在问题用户内存写时复制写入触发缺页性能不均文件描述符表复制表结构共享底层文件对象文件偏移量共享父子互相干扰工作目录、根目录复制并加引用计数父进程chdir不影响子进程信号处理函数复制父子各自独立设置待处理信号队列清空子进程收不到父进程挂起的信号命名空间默认共享容器隔离必须显式指定新建控制组继承子进程默认在父进程的组里进程组、会话继承终端信号会影响整个组文件偏移量共享这一条最能搞出生产事故。举个例子父子进程都往同一个已打开的文件里写日志如果这个文件是以追加模式打开的内核每次写之前会重新定位到末尾问题不大。但如果是以普通写模式打开的父子共享同一个偏移量两次写的顺序和位置就完全取决于调度日志会错乱甚至互相覆盖。我遇到过一次类似的问题某个服务在启动时打开了日志文件然后再fork出工作进程。结果主进程和工作进程的日志内容搅在一起中间还夹杂着半行。排查了半天才发现是文件偏移共享导致写入位置互相踩。最后的处理很简单要么用追加模式打开要么在子进程里重新open一次拿到独立的文件对象。这个经验我后来写进了团队的编码规范里。另外一个坑是缓冲区。标准输出在重定向到文件时是块缓冲的fork会把缓冲区内容整份复制。如果父子都调用exit而不是_exit缓冲区会被刷两次。所以在子进程里如果确定不再回来应该用_exit或者先手动刷新再退出。3. TASK_RUNNING 并不代表正在跑进程状态机的源码真相状态这个词太抽象我更喜欢用账本状态来理解。内核维护着成千上万个task_struct它必须清楚地知道每一个当前处于什么处境是在等 CPU、在等 IO、在等一个锁、被暂停了、还是已经死了但还没被清理。这套状态机的设计直接决定了系统的响应性和稳定性也决定了你排查问题时看到的每一个现象。3.1 五个状态字母背后的调度语义把内核的状态宏和ps的输出对上是这样一张表显示字母内核状态语义能否被信号唤醒R可运行在运行队列里含正在跑和排队不需要S可中断睡眠等待事件可被信号打断能D不可中断睡眠等待底层事件不可被打断不能T停止被作业控制暂停需继续信号Z僵尸已退出但未被回收已死关键在于R的语义。很多资料写R 表示运行中容易误导。准确的说法是可运行包含了正在 CPU 上执行和已经在运行队列里等待调度两种情况。内核调度器根本不区分这两者它只关心这个进程是否在运行队列里。所以你在top里看到一堆R可能 CPU 一点都不忙只是这些进程在频繁地短暂运行然后重新排队。S和D的区别也值得说清楚。两者都表示睡眠区别在于唤醒条件S状态下内核在睡眠期间会检查信号如果有信号待处理就提前返回这就是为什么你CtrlC能打断一个卡在读取上的进程。D状态下内核完全忽略信号必须等到特定事件发生才会醒来。3.2 一次 sleep 到 wake_up 的完整路径我拿一个最典型的场景来拆进程调用read读一个管道管道里没数据它得等。第一步内核在管道的数据结构上找到对应的等待队列把自己打包成一个等待节点挂进去。第二步把自己的状态设置成可中断睡眠。第三步调用调度器主动让出 CPU。第四步当另一个进程往管道里写数据时会遍历等待队列把挂在上面的进程逐个唤醒——所谓唤醒就是把状态改回可运行并放进运行队列。第五步被唤醒的进程重新获得 CPU从调度器返回点继续执行但此时它不能假设数据一定就绪了必须回到第一步检查条件如果还是空继续睡。这个循环检查的结构非常关键源码里通常写成这样for (;;) { prepare_to_wait(queue, wait, TASK_INTERRUPTIBLE); if (condition_is_true()) /* 条件已满足别睡了 */ break; if (signal_pending(current)) /* 有信号要处理退出 */ break; schedule(); /* 让出 CPU醒来后回到循环开头 */ } finish_wait(queue, wait);注意顺序先把状态设成睡眠再检查条件然后才调度。这个顺序不能反。如果先检查条件再设状态在两次操作之间可能发生唤醒事件而那时你的状态还不是睡眠唤醒操作对你不生效接着你把自己设成睡眠并调度出去——唤醒丢了进程就永远醒不过来。这类丢失唤醒的 bug 在驱动代码里非常经典一度是最常见的竞态来源。我早年写过一个自制的队列就是犯了这个错测试环境跑了几天没问题压测时偶尔卡死。后来对着内核的等待队列实现看了一遍才意识到顺序的重要性。这个教训就是凡是涉及检查条件和进入等待的组合顺序和原子性必须反复推敲不能想当然。3.3 为什么 D 状态 kill -9 也杀不掉这是被问得最多的一个问题。答案其实很简单信号的处理是在进程返回用户态之前检查的D状态的进程根本不在用户态也不在运行队列里它卡在内核代码的某个位置上等待底层事件完成。内核根本没有机会去检查它有没有待处理信号。那什么情况下会进入不可中断睡眠常见的有几种等待块设备 IO 完成、等待某个文件系统的锁、等待网络文件系统的响应、等待驱动里的某个硬件事件。设计成不可中断是因为这些操作的上下文不允许被打断——比如你正在从磁盘读一个数据块到缓冲区中途被打断的话缓冲区状态就不一致了恢复起来极其麻烦。宁可让进程等到底也不能让它半路跑掉。所以当你看到一个进程长期D住不动正确的思路不是怎么杀掉它而是底层卡在哪。可用的排查手段有这么几个# 看进程卡在哪个内核函数 cat /proc/pid/stack cat /proc/pid/wchan # 看整体 IO 情况和阻塞情况 iostat -x 1 cat /proc/pid/status | grep -i state/proc/pid/stack会直接打印内核栈能看出它卡在哪个函数里。如果是文件系统相关的函数就往存储方向查如果是驱动相关的可能得看硬件和驱动日志。我遇到过的几次长时间D状态原因分别是一块盘开始出现坏道导致重试、一个网络挂载的对端不响应、以及一个存储控制器固件问题导致队列卡死。三次都不是进程本身的问题杀掉它只会让问题掩盖得更深。还有一个提醒D状态的进程如果父进程也不管会在系统里堆积。极端情况下进程数打满新进程创建失败。所以监控里应该有一条针对D状态进程数量和持续时间的告警比等到系统假死再排查要从容得多。4. 调度器怎么在几百个进程里挑一个权重、vruntime 与红黑树讲完状态自然要讲调度。调度器是进程这个概念的使用方所有前面讲的结构、状态、优先级最终都是为了让调度器做一个决策下一个该谁上 CPU。理解调度你在做容量规划、判断性能瓶颈、调整优先级的时候才不会瞎猜。4.1 从 nice 值到权重的换算逻辑用户能直接调的只有 nice 值范围通常是从 -20 到 19数字越小优先级越高。但调度器内部并不直接用 nice 值而是把它换算成一个权重。换算公式大致是权重等于一个基准值除以 1.25 的 nice 次方。这样设计有几个考虑。第一n ice 每差一级权重按固定比例变化而不是固定差值这符合相对重要性的直觉。第二权重永远为正方便做除法运算。第三nice 0 附近的曲线比较平缓越往两边越陡意味着在高优先级区间调整一级的收益比在中间区间大。实际数值大致是这样nice 值相对权重含义-20约 88761极高优先级-10约 9548高优先级01024基准10约 110较低19约 15最低从这个表能看出一个 nice 为 -20 的进程权重是一个 nice 为 0 的进程的八十多倍。所以别以为调高优先级就是调一点点从 0 调到 -5 已经是数量级的差距了。我见过有人为了让这个任务快一点直接给 -20结果把整台机器的其他服务饿死。这种操作要么不做要么在一个受控的范围内小步调整。另外要提醒的是这个换算只在同一个调度类内部有效。如果系统里同时有实时调度类和普通调度类实时类的进程会完全压过普通类nice 值在实时类里根本不参与决策。所以你调 nice 没效果的时候先确认一下对方是不是实时进程。4.2 vruntime 最小者优先的实际含义普通进程用的调度策略核心是一个叫虚拟运行时的计数器每个进程有一个调度器每次挑虚拟运行时最小的那个上 CPU。虚拟运行时的增长方式是这样的进程每实际运行一段时间虚拟运行时就增加一段增加量不是 1:1而要乘上一个系数这个系数等于基准权重除以该进程的权重。也就是说权重高的进程同样跑 10 毫秒虚拟运行时增长得少权重低的进程增长得多。这样一来权重高的进程在红黑树里的位置会更靠左更容易被再次选中。用个通俗的比喻每个进程有个里程表跑得越久数字越大调度器永远挑里程表最小的那位。但不同进程的里程表刻度不一样重要进程的刻度走得慢普通进程的刻度走得快。最后效果就是重要进程实打实拿到更多 CPU 时间。数据结构上所有可运行的进程按虚拟运行时排在一棵红黑树里。选最左节点这个操作是对数复杂度插入和删除也是。这就是为什么调度器在几千个进程的规模下还能保持很低的决策开销。我在一台跑了几千个线程的机器上观察过调度相关的 CPU 占用并不夸张红黑树这个选择是有道理的。有几个参数值得知道调度周期和最小粒度。调度器不是每时每刻都在重新决策它按周期做规划保证每个进程在这个周期里至少能拿到一段最短运行时间。如果可运行进程太多导致按权重分下去每个都低于最小粒度那就退化成轮流上此时公平性让位于响应性。这也解释了为什么超高负载下你看到的是所有进程都变慢而不是部分进程完全不动。4.3 上下文切换的成本构成进程切换到底贵在哪值得掰开了看。第一步是保存现场把当前进程的通用寄存器、程序计数器、栈指针、浮点寄存器状态统统压到它的内核栈和thread_struct里。第二步是切换栈从当前进程的内核栈切到新进程的内核栈。第三步是恢复现场把新进程的寄存器状态载入。第四步是切换地址空间把页表根地址换成新进程的这一步会导致 TLB 里的缓存全部或大部分失效之后一段时间内每次访存都要重新走页表性能下降明显。第五步新进程的代码和数据不在 CPU 缓存里会产生大量缓存未命中。前四步是直接成本通常几微秒级别。第五步是隐性成本可能比前四步加起来还大因为它影响的是后续一段时间的执行效率而不是切换这个动作本身。这也是为什么有时候减少线程数反而能提升吞吐——线程多了切换频繁每次切换后都要重新暖缓存有效工作时间被压缩。有个经验判断如果你的服务是计算密集型线程数超过核数的两倍基本没好处如果是 IO 密集型多一点线程是合理的因为等待的时候可以切走。但即便是 IO 密集也不该无限加我一般会把线程数控制在核数的几倍范围内超出就改用异步模型。测量切换次数很简单# 每秒上下文切换次数 vmstat 1 # 或者针对单个进程 grep ctxt /proc/pid/status曾经有一次排查性能问题发现一台机器的切换次数在某个时间段突然飙升最后定位到一个线程池的队列实现有问题大量线程在争抢同一把锁抢不到就睡被唤醒又抢不到反复循环。改成分段锁之后切换次数掉了一个数量级吞吐直接翻倍。这种问题靠看代码很难发现得靠指标。5. pid 只是进程的工号命名空间与资源账本很多人以为 PID 就是个整数task_struct里存一个int pid就完事了。实际上远不止尤其是容器普及之后PID 的语义变得相当复杂。搞不清楚这层关系你在容器里排查问题会非常痛苦。5.1 pid 结构体与 pid 命名空间的多层映射在内核里进程标识不是简单整数而是一个结构体里面存着一组映射关系每一层命名空间对应一个编号。也就是说同一个进程在不同的命名空间视角下可以有完全不同的 PID。举个具体例子你在宿主机上看到一个进程 PID 是 8342进到它的容器命名空间里看可能是 1 号进程。两个都是对的只是视角不同。getpid()返回的是当前命名空间下的编号而内核内部要用全局唯一的编号来定位进程。这个设计带来几个实际影响。第一kill命令用的编号是当前命名空间视角的跨命名空间操作要用宿主机的视角。第二PID 1 有特殊语义在命名空间里1 号进程负责回收孤儿进程如果它不处理僵尸就会堆积。第三线程组的概念在这里也有体现一个线程组有一个主线程 ID其他线程有各自的 ID/proc里的目录名用的是主线程 ID。我踩过的坑写了一个脚本去监控容器内的进程用的是容器内的 PID 去宿主机上查/proc/pid结果查到的是完全不相干的进程。这种错误非常隐蔽因为/proc目录存在、文件能读只是内容不对。后来改成从cgroup里读进程列表或者从宿主机的视角重新映射才解决。5.2 从进程组、会话到容器边界除了 PID 命名空间还有几层组织关系一定要清楚否则在排查信号和终端相关问题时容易抓瞎。进程组是一组进程的集合主要作用是批量发信号。你在终端里按CtrlC内核实际上是给整个前台进程组发信号而不是只给那一个进程。这就是为什么一个 shell 管道里的多个进程会一起退出。会话是进程组的集合通常跟一个终端绑定。会话首进程如果是终端的控制进程那么这个会话里的进程就能收到终端信号。当你用setsid创建一个新会话时它就脱离了原来的终端nohup之所以能让进程不受终端关闭影响原理就在这里——虽然nohup主要是忽略挂断信号但配合会话脱离效果更稳。容器则是另一层概念用命名空间做视图隔离PID、网络、挂载、用户等用控制组做资源限制再加上一个独立的根文件系统。一个容器内的1 号进程其实是宿主机上某个普通进程只是它在新的 PID 命名空间里恰好是第一个。这也解释了为什么容器里的 1 号进程必须处理信号和回收孤儿——如果它是个不处理这些的简单程序容器里的僵尸就会堆积。有个很实用的技巧排查容器内进程问题时先在宿主机上确认进程的真实 PID再进命名空间看容器视角的编号两边对照着看能少走很多弯路。5.3 cgroup 是怎么给进程记账的控制组解决的是限制问题CPU 用多少、内存用多少、IO 带宽多少。它的工作方式跟进程结构体紧紧绑在一起。每个task_struct有一个指向控制组集合的指针这个集合描述了该进程属于哪些控制组。当进程fork时子进程默认继承父进程的集合所以你在容器里起的任何进程都在容器的资源限制之下。限制是怎么生效的不同资源走不同路径。CPU 是在调度器里做的每个控制组有自己的调度实体组内的进程先竞争组内配额组与组之间再按权重分配。内存是在分配页的时候做的进程申请内存时内核顺着控制组往上检查每一层的限制超了就触发回收回收不动就触发 OOM 处理。IO 是在块层做的请求经过控制组的限流器。这里有一个非常容易误解的点内存限制统计的是控制组内所有进程的页缓存和匿名页之和不只是进程自己申请的堆内存。所以一个读大量文件的服务即使自己的堆很小也可能因为页缓存把控制组的内存顶到上限然后被杀。我第一次遇到这种情况时非常困惑明明进程占用的内存看着不多为什么 OOM 了。后来查了控制组的内存统计文件才明白页缓存占了大部分。排查方法很简单# 查看某个控制组的内存明细 cat /sys/fs/cgroup/group/memory.stat # 查看当前使用量 cat /sys/fs/cgroup/group/memory.current如果发现页缓存占大头可以在业务低峰期主动释放或者调整内存上限时把页缓存的因素算进去。这个经验我写在了好几份运维手册里因为它的反直觉程度很高。6. 进程退出之后do_exit、僵尸态与托孤机制讲完了创建、调度、组织最后一块是退出。这块看起来简单——进程跑完了就没了呗——实际上内核在这里做了一堆收尾工作任何一步没做干净就会留下僵尸或者资源泄漏。6.1 do_exit 的收尾顺序进程主动退出、或者被信号杀死最终都会走到同一个收尾函数。它做的事情按顺序大致是先把退出码记录下来供父进程查询。然后通知所有相关方给父进程发信号告诉它有孩子走了给线程组里的其他线程发信号让它们也退出。接着处理控制组和资源的退出钩子释放文件描述符表、关闭文件系统信息、释放信号处理表。再然后释放地址空间——这一步会解除所有内存映射回收页表。如果进程是最后一个使用某个命名空间的命名空间也被销毁。如果是线程组的最后一个线程就通知父进程来收尸。最后一步很有意思进程把自己标记成僵尸状态然后调用调度器彻底让出 CPU而且不再返回。从这一刻起它不再执行任何用户代码只剩一个task_struct和内核栈还留着等着父进程来读退出状态。注意顺序上的讲究地址空间是在通知父进程之后才释放的因为父进程可能想通过/proc去看子进程的某些信息比如内存峰值统计。如果先释放了这些统计就没了。这种先通知后清理的顺序在内核里反复出现是保证可观测性的常见手法。6.2 僵尸进程是怎么产生的僵尸的本质是进程已经执行完了退出流程但它的task_struct还没被释放因为退出码还没被父进程取走。内核刻意保留这个结构体就是为了让父进程能拿到子进程的退出状态——这是设计意图不是 bug。所以僵尸的形成条件很明确子进程退出了父进程还没调用wait系列函数。这时候你在ps里能看到一个Z状态的进程占用一个 PID占用一点点内核内存不占 CPU 也不占物理内存页地址空间已经释放了。什么情况下会大量出现僵尸最常见的是父进程写得不好创建了子进程但从不回收。比如一个循环fork的服务忘了wait跑一天就攒几千个僵尸。另一种情况是父进程自己卡住了正常应该回收但没机会执行到那段代码。还有一种情况容易被忽略容器里的 1 号进程如果是个不处理子进程退出的简单程序那么容器内所有孤儿进程都会挂到它下面它不回收就一直留着。这就是为什么做容器镜像时要么让主程序正确处理子进程要么在前面加一层 init 进程做回收。怎么处理如果父进程还在能改就改加个信号处理或者在主循环里定期回收。如果改不了临时办法是杀掉父进程让子进程被托管给上级上级会回收。但要注意杀父进程可能影响业务得评估。6.3 一次完整的排查链路我拿一个真实场景把排查流程走一遍这样你遇到类似情况可以照着做。现象一台机器上进程数持续增长ps一看全是Z状态内存倒是没涨但达到进程数上限之后新进程创建失败服务开始报错。第一步确认僵尸数量和对应用户ps -eo stat,ppid,pid,comm | awk $1 ~ /^Z/ {print $2} | sort | uniq -c | sort -rn这条命令按父进程 ID 分组统计僵尸数量一眼就能看出是哪个父进程在制造僵尸。第二步看那个父进程在干什么。用ps看它的命令和状态用strace看它是不是根本没调用wait或者卡在别的地方。第三步判断是设计问题还是偶发问题。如果父进程正常跑着但从不回收那是代码缺陷如果父进程自己卡在某个D状态那是另一个问题导致它没法回收子进程得先解决上游。第四步短期止血。如果确认父进程不重要可以杀掉它让子进程被托管僵尸立刻消失。如果父进程重要就得重启或者热修复。第五步长期修复。在父进程里加回收逻辑或者在代码审查里加一条检查凡是创建子进程的地方必须配套回收路径。我把这条写进了团队的代码规范检查清单后来这类问题再没出现过。有个额外的经验监控僵尸数量时不要只看绝对值要看增长趋势。有些系统长期有一两个僵尸属于正常现象父进程恰好还没轮到回收但如果曲线是持续上升的那一定是泄漏。用ps配合简单的统计脚本每分钟采一次数画出来一眼就能判断。另外一个提醒僵尸进程用kill -9是杀不掉的因为它已经死了。你能杀的只有它的父进程。这一点很多人不知道会在僵尸上浪费很多时间。最后分享一个我在实际工作中养成的小习惯每接手一个新的服务我都会先把它创建子进程的路径摸清楚——哪些地方fork、哪些地方起线程、哪些地方起子进程、谁来回收。这个信息在排查问题时价值极高但通常没有文档。花半个小时读一遍代码比出事后花三个小时猜要划算得多。进程这个概念在源码里其实不复杂难的是把它和你面前那个正在出问题的系统对上号。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询