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

资讯详情

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

Linux进程管理全解析:从task_struct到调度与僵尸进程

Linux进程管理全解析:从task_struct到调度与僵尸进程 你有没有遇到过这种情况线上服务没多少用户量CPU 却飙得离谱或者你 fork 完子进程后父子进程的打印顺序完全不按你预期的来又或者一个程序明明不复杂却频繁卡顿、僵尸进程堆积。这些问题看着五花八门追根溯源全部指向同一个领域——Linux 进程管理。这篇内容我会从内核视角把进程管理这条链路完整梳理一遍重点拆解两个核心环节进程是如何被创建的以及创建之后调度器又是如何决定“谁先跑、谁后跑、跑多久”的。同时会延伸到进程退出、回收和僵尸进程这些工程实战中一定会踩到的场景基本覆盖了从创建到调度、再到销毁的完整生命周期。适合正在学 Linux 内核原理的学生也适合那些写多进程服务、做性能排查时经常需要面对进程模型的开发者和运维。1. 进程在Linux里到底是什么——从task_struct说起1.1 “进程”不是抽象概念它就是一个结构体实例教科书上说“进程是运行中的程序”这句话没错但对内核开发者来说太朦胧了。在 Linux 内核里进程的本质就是一块数据一个叫task_struct的结构体实例。内核每创建一个进程都会kmalloc出一块内存初始化一个task_struct然后把这个结构体挂到全局任务链表上。进程的切换、调度、信号处理、资源统计说白了都是在对这块数据做操作。你ps看到一行输出背后就是遍历这一堆task_struct节点然后把字段打印成表格。task_struct是一个非常庞大的结构体包含的字段有几百个按职责大致可以分成这几类职责代表字段作用状态与调度state、se、prio、static_prio、normal_prio、rt_priority表示进程当前状态、调度实体、优先级内存管理mm、active_mm指向进程地址空间描述符文件系统fs、files记录根目录、当前工作目录、打开的文件表信号处理sigpending、signal挂起信号和信号处理函数身份与权限cred、uid、gid记录用户态身份和权限相关信息时间与统计start_time、utime、stime启动时间、用户态/内核态 CPU 时间资源限制rlim进程可使用的资源上限用生活里的类比来说每个进程像是一个在公司里的员工task_struct就是他的完整档案袋。档案里写着他是谁、在哪个部门调度器、住哪内存、负责什么项目文件描述符、上面有多少领导权限权限体系。调度器做决策的时候看的不是“人”就是这份档案。1.2 在 Linux 里进程和线程没有那么泾渭分明很多人问“Linux 进程和线程到底怎么区分”。答案可能会颠覆你的认知在内核视角下进程和线程没有本质区别它们都是task_struct。区别只在于clone()系统调用时传入哪些共享标志如果父子进程共享地址空间CLONE_VM、共享文件表CLONE_FILES、共享信号处理器CLONE_SIGHAND那看起来就是一个“线程组”里的多个线程。如果不共享这些东西各自拥有独立的地址空间和资源这就是传统意义上的“进程”。所以 Linux 里的线程准确叫法是轻量级进程Lightweight Process。你平时用ps -eLf可以看到一个进程对应的多个线程每行一个task_structLWP 就是线程 ID。我实际排查线程问题时踩过一个坑用top看某个进程 CPU 占用不高但整个系统负载很高。后来用top -H -p PID按线程查看才发现是其中一个线程在死循环。这就是因为进程是一种“资源容器”CPU 调度其实是针对于线程内核可调度实体进行的并不以进程为最小单位。2. 进程的诞生fork、写时复制与执行流切换2.1 fork 的语义调用一次返回两次Linux 创建进程最经典的方式就是fork()。这个系统调用的语义在大学课本里就写着调用一次返回两次。如果是父进程成功调用fork()内核会在父进程的地址空间基础上复制一份子进程的task_struct然后让父进程返回子进程的 PID让子进程返回 0。这样程序里就能通过返回值判断自己是父还是子#include stdio.h #include unistd.h int main() { pid_t pid fork(); if (pid 0) { perror(fork); return 1; } if (pid 0) { printf(子进程PID%d父进程PID%d\n, getpid(), getppid()); } else { printf(父进程PID%d子进程PID%d\n, getpid(), pid); } sleep(1); return 0; }编译运行gcc -o fork_demo fork_demo.c ./fork_demo你会发现输出两行但顺序不一定固定。父和子谁先运行完全由调度器决定。如果你假设父进程一定先执行大概率会写出有并发 Bug 的程序。fork 失败的情况也得留意。最常见的原因是进程数达到RLIMIT_NPROC限制或者内存不足申请不到内核态数据结构。线上服务器如果 fork 频繁报 EAGAIN首先去查是不是某个系统服务把进程数打满了。2.2 写时复制COW为什么 fork 可以这么快早期 Unix 的fork()确实会完整复制父进程的地址空间包括所有物理页。后来工程师们想明白一个关键问题fork 之后绝大多数情况是紧接着调用exec()加载新程序父进程的整个地址空间很快就会被覆盖丢弃前面复制那么多页纯属浪费。于是内核引进了写时复制Copy-on-Write机制。原理一句话概括fork 的时候内核不会复制父进程的物理页只是把父进程的页表复制一份并把父子进程页表中所有可写页表项标记为只读。父子进程的虚拟地址此时映射到同一个物理页。谁先尝试写这个页面谁就会触发缺页异常内核在异常处理里做真正的物理页复制、更新页表权限然后重放这条写指令。这个过程快在哪fork 本身只需要复制页表不需要复制物理内存时间复杂度大大下降。只有实际发生写入的页面才会被复制很多“复制后不写”的场景开销为零。如果 fork 后立刻exec()几乎不会触发缺页复制开销极小。这也是为什么一些 Web 服务器喜欢用 fork 模型每次都复制一个很大的进程出来但在 exec 之前只做很少的操作整体成本可控。2.3 fork 的变体与 exec工程里到底怎么用真实工程里直接用裸fork()的情形其实不算多因为大多数语言运行时会自己做封装。但你理解原理之后看 C/C 里的pthread_create、Python 的multiprocessing底层就不虚了。fork()有三个值得注意的亲戚系统调用行为典型场景fork()复制进程配合 COW父子独立地址空间经典进程创建vfork()不复制页表父进程阻塞子进程共享父进程地址空间直到子进程 exec 或退出嵌入式/内存受限环境现在基本被 forkCOW 替代clone()通过 flags 精细控制父子共享哪些资源pthread_create的底层实现再来看exec系列。这里有个常见误区exec 不是创建进程而是替换进程。execve()系列系统调用execl、execlp、execvp等会把当前进程的代码段、数据段、堆、栈全部换成新可执行文件的内容但进程的 PID、打开的文件描述符默认情况下等保持原样。所以整个生命周期是fork()创建新进程。子进程调用exec()加载目标可执行程序。父进程wait()回收子进程。这条链路从 Unix 时代延续至今Linux 上跑任何程序的本质都是这一套组合拳。提示很多人分不清“执行一个程序”和“创建一个进程”。执行程序必然先创建进程或者复用当前进程但创建进程之后不一定 exec。比如环境变量设置、命令行解析这些逻辑都得在 exec 之前做好准备。3. 调度器的核心机制vruntime、CFS与调度类3.1 调度器演进从遍历所有进程到红黑树选最左节点说完了进程怎么来接下来是主角调度器。它的任务可以浓缩成一句话——多个进程都在可运行队列里CPU 到底给谁。Linux 调度器历史上经历过三个阶段O(n) 调度器2.4 时代每次调度都要遍历整个运行队列计算每个进程的优先级、当前状态等。进程数量多了以后调度本身消耗的 CPU 时间线性上升这在高负载服务器上完全不可接受。O(1) 调度器2.6 早期引入 active / expired 两个优先级数组调度时间变成常数级。它更重视响应速度但对“公平性”的理解比较粗暴交互式进程体验不稳定。CFS完全公平调度器2.6.23 之后不再用优先级数组而是维护一棵红黑树用虚拟运行时间vruntime作为键值每次取最左叶子节点作为下一个运行进程。名字叫“完全公平”但实际是通过权重实现“按比例公平”不是绝对平均。现在的 6.x 内核又在 CFS 基础上引入了 EEVDF最早虚拟截止时间优先理念有些变化但 CFS 的虚拟时间模型依然是理解这一切的地基所以我们从 CFS 讲起。3.2 核心概念vruntime 是怎么算出来的CFS 的关键变量是vruntime翻译过来叫“虚拟运行时间”。每个进程更准确说是每个调度实体都维护一个自己的vruntime。进程运行的时间越长vruntime就越大。内核每次选择下一个进程时就从红黑树里挑出vruntime最小的那个进程来运行。这样做的好处很直观哪个进程欠的 CPU 时间最多就先补给他。vruntime的增长速度并不是简单的 1:1而是经过权重换算delta_vruntime delta_exec * NICE_0_LOAD / se_load其中NICE_0_LOAD 1024se_load是当前进程的权重。权重的来源就是 nice 值。nice 值范围是 -20 到 19内核里通过一张静态映射表把它转换成权重nice 值权值-2088761-109546010245335101101915从这个表格能看到两个要点。第一nice 0 的进程权重就是 1024vruntime增长速度和真实运行时间一致。第二nice 值每降低一级权重约增加 1.25 倍每提高一级权重约减少 0.8 倍。但权重差不是均匀的。nice 0 到 nice 19 权重相差约 68 倍。所以如果一个 nice 0 的进程和一个 nice 19 的进程同时竞争一个 CPUnice 19 的进程几乎跑不动它的vruntime涨得飞快很快就被红黑树推到右边。为了让你更直观地感受假设 nice 0 进程运行 1ms它的vruntime增加 1ms同样是 1ms 运行时间nice 19 进程的vruntime增加约1 * 1024 / 15 ≈ 68ms。这就是“低优先级进程得到更少 CPU”在数学上的精确表达。3.3 为什么是红黑树而不是链表或数组这个问题我面试时经常被问到。原因很朴素CFS 需要频繁做两种操作——插入新唤醒的进程、删除运行中的进程、找出最小vruntime节点。链表查找最小值是 O(n)数组插入删除涉及移动都不适合大量进程的场景。红黑树能在 O(log n) 时间内完成插入、删除、查找最小节点平衡操作又能控制树的深度保证最坏情况下性能也可接受。实际代码里调度器直接把红黑树的最左叶子节点作为下一个要运行的进程。这棵树的根在 per-CPU 运行队列的cfs_rq结构里如果两个 CPU 上各有一个cfs_rq调度就是各跑各的只有发生负载均衡时才会把进程从忙的 CPU 迁移到闲的 CPU。3.4 调度类不同需求的进程走不同的“车道”CFS 管的是普通进程但 Linux 里还活着多种调度需求。为了灵活处理内核把调度器代码抽象成了“调度类”sched_class每个task_struct都会挂到某个调度类下面。从高优先级到低优先级核心调度类大致如下调度类优先级典型用途stop_sched_class最高停止 CPU、CPU 热插拔等内核内部任务dl_sched_class高Deadline 调度严格保证最晚完成时间用于实时任务rt_sched_class较高SCHED_FIFO / SCHED_RR传统实时调度fair_sched_class中CFS普通进程idle_sched_class最低每个 CPU 的空闲进程调度器选择进程时会从高优先级调度类开始找只有当前调度类没有可运行任务才会往下走。这就是为什么实时进程能抢占普通进程的原因rt_sched_class 在 fair_sched_class 前面。rt 调度类内部又有两种策略SCHED_FIFO先进先出。相同优先级的任务先到先跑没有时间片的概念直到任务自己阻塞或退出或者更高优先级的任务出现。SCHED_RR时间片轮转。同优先级的任务轮流运行时间片用完就排到队尾。用实时策略时要特别谨慎。如果系统里有一个 SCHED_FIFO 的死循环进程普通进程包括 shell、监控程序完全得不到 CPU系统看起来就像死机了键盘和鼠标都无响应。这也是为什么设置实时策略需要 root 权限或CAP_SYS_NICE权限。3.5 上下文切换的代价为什么更推荐线程调度器做出决策后就要执行上下文切换。这个操作不是简单切换寄存器而是要保存当前进程的 CPU 现场恢复下一个进程的现场。进程间的上下文切换开销更大因为两个进程有不同的地址空间内核需要切换内核栈。切换 CR3 寄存器使 MMU 指向新进程的页表。刷新 TLBTranslation Lookaside Buffer。重置流水线状态新进程的指令和数据基本都在冷缓存里后续访问会频繁 miss。线程之间切换就好一些它们共享地址空间CR3 不变TLB 也基本不用全部刷新只是切换栈、寄存器、指令指针。所以在高并发场景里线程模型往往比进程模型有更好的上下文切换性能。我在做性能优化时测过一个处理服务用进程模型每秒只能承受约 2 万请求换成线程模型以后接近 5 万主要差距就是上下文切换的 TLB miss 少了很多。4. 优先级、nice值与CPU绑定不知道这些命令等于白搭4.1 nice 值给低优先级进程“让路”在用户态绝大多数进程都是普通进程走 CFS。你能直接用到的干预手段就是 nice 值。nice名字本身就很有意思——“礼貌”。你的进程越礼貌nice 值越大越把 CPU 让给别人。启动时指定 nice 值nice -n 10 ./cpu_bound_process对已运行的进程调整renice -n 5 -p 1234这里有个很实际的规则普通用户只能把 nice 值调高让优先级更低不能调低让优先级更高。比如你自己启动一个进程你可以把自己的进程从 nice 0 改成 nice 10但不能改成 nice -10因为调低优先级会抢占其他用户进程的资源内核不让你这么干。只有 root 或者有CAP_SYS_NICE权限的进程才能随便调。从实测角度一个 nice 0 的 CPU 密集进程和一个 nice 19 的 CPU 密集进程在单核上跑nice 19 的进程能被分配到的 CPU 时间会少到让任务几乎无法完成。做批处理任务时经常用 nice 调低优先级避免影响了线上服务的响应。4.2 chrt实时调度策略的正确用法如果你需要严格保障某个任务的最坏情况响应时间普通 CFS 满足不了可以考虑实时调度策略。chrt就是设置实时策略和优先级的工具# 以 SCHED_FIFO 策略运行优先级 50 chrt -f 50 ./realtime_task # 以 SCHED_RR 策略运行优先级 30 chrt -r 30 ./realtime_task # 查看当前进程的调度策略 chrt -p $$实时优先级范围是 1~99数字越大优先级越高。普通进程的 nice 值范围是 -20~19和这个实时优先级完全是两套体系不要混淆。什么场景适合用实时调度音频采集、工业控制、高频交易这种对抖动极其敏感的任务它们需要的是“到了时间就必须运行”的硬保证。普通 Web 服务完全没必要用了反而有风险。我亲眼见过有人给一个日志处理进程设了 SCHED_FIFO 高优先级结果它占住 CPU 不放数据库主进程饿死整个服务雪崩。实时优先级不是越高越好它的本质是抢占权必须控制使用范围。4.3 taskset 与 NUMA绑核要不要用再往下一层是 CPU 亲和性CPU affinity。用taskset可以把进程绑定到指定的 CPU 核心上# 将进程绑定到 CPU0 和 CPU1 taskset -c 0,1 ./cpu_bound # 绑定到 CPU0~3 的任意核心 taskset -c 0-3 ./cpu_bound # 修改已有进程的绑定 taskset -pc 0 1234绑核的价值主要体现在缓存和 NUMA 上。如果一个进程在多核之间来回迁移它的热数据在 L1/L2 缓存里反复失效性能损耗明显。绑定到固定核心后一级二级缓存能保持相对热度。在 NUMA 架构下进程访问本地内存和远端内存的延迟差距能达到数倍把进程绑定到内存所在节点的本地 CPU 上能显著降低内存访问延迟。但绑核也不是越多越好。绑定后调度器就不能在这个进程上做负载均衡了。如果该 CPU 核心本身已经很忙其他核心很闲绑核反而会浪费算力。我的建议是先不要绑核用性能分析工具比如perf观察确认缓存 miss 和远程内存访问是瓶颈再考虑绑定别一上来就绑。5. 进程的“身后事”退出、回收与僵尸进程5.1 从 exit 到 do_exit进程退出到底清理了什么进程退出不是简单的“内存释放”。当进程调用exit()或者从 main 函数返回时内核会执行do_exit()整个清理过程大概涵盖释放用户态地址空间mm销毁页表和物理页面。关闭进程打开的所有文件描述符。释放文件系统相关结构当前目录引用等。向父进程发送 SIGCHLD 信号唤醒正在 wait 的父进程。把进程状态设置为僵尸ZOMBIE保留最小信息等待父进程回收。注意最后一步。进程死了但task_struct并没有立刻消失。它会以一个僵尸状态继续留在进程表里直到父进程调用wait()或waitpid()来读取退出状态。5.2 僵尸进程为什么内核不立刻清干净新同学第一次看到僵尸进程时通常会问既然进程都结束了为什么不立刻回收掉答案是父进程有权知道子进程的退出码。比如一个子进程因为某些校验失败而退出退出码是 3。父进程需要拿到这个 3 来判断子进程为什么退出。内核必须有地方存这个退出码又不能为此保留整个地址空间所以就在进程表里留下一个最小化的task_struct里面主要是 PID、退出码、资源统计、时间统计。所以僵尸进程占用的资源其实很少它本身没问题。真正的问题在于如果父进程一直不 wait僵尸进程就会一直累积。大量僵尸会消耗 PID 表项让系统无法创建新进程。同时运维看top的时候满屏 zombie会严重影响排障判断。一个经典的制造僵尸进程的 C 代码是这样的#include stdio.h #include unistd.h #include sys/wait.h int main() { pid_t pid fork(); if (pid 0) { // 子进程立即退出 return 0; } // 父进程不调用 wait直接挂起 sleep(30); return 0; }编译运行后另开一个终端执行ps -el | awk $2 Z你就能看到状态为 Z 的僵尸进程。父进程睡 30 秒后退出僵尸会被系统收养并回收就消失了。5.3 排查和清理僵尸进程的实战流程在线上服务器碰到僵尸进程第一步不是急着 kill而是理清父子关系。推荐按这个顺序排查# 1. 找出所有僵尸进程 ps -el | awk $2 Z # 或 top -b -n 1 | grep zombie # 2. 看僵尸进程的父进程是谁 ps -o ppid -p zombie_pid # 3. 看父进程到底在干什么 ps -fp ppid处理方式取决于父进程的情况如果父进程是我们的服务进程那问题大概率是代码里 fork 了子进程却没有调用 wait或者只 wait 了一个、另一个没处理。正确做法是补上waitpid(-1, NULL, WNOHANG)循环。如果父进程是可以安全的重启或 kill 的进程直接把父进程 kill 掉它的孤儿进程们会被 systemd 收养systemd 会循环 wait 回收僵尸自然消失。如果父进程是不能动的重要进程可以用SIGCHLD信号处理器或者现成的子进程管理器来做回收。提示硬 kill 僵尸进程kill -9 zombie_pid是没有意义的。内核里它已经“死”了kill 信号不会起任何作用。能做的只有从父进程层面去 wait或者干掉父进程让子进程被收养。5.4 孤儿进程父进程没了谁来管和僵尸进程经常一起出现的是孤儿进程——父进程先退出子进程还在运行。正常情况下子进程的父进程字段ppid会被改成 1也就是被 init 进程现在一般是 systemd收养。systemd 作为祖先会在子进程退出时主动调用wait完成回收。所以孤儿进程只要不自己挂住一般不会变僵尸。在容器化环境里有个细节值得注意容器往往没有完整的 init 进程如果容器里的 1 号进程不是专门负责收尸的 init就可能导致容器内的孤儿进程没人回收积累成僵尸。这也是很多容器基础镜像额外引入tini之类 init 程序的原因。5.5 不可中断睡眠 D 状态和僵尸不是一回事排查进程状态时除了 Z还经常会看到 D不可中断睡眠状态。D 状态通常意味着进程正在等待内核态 IO比如等待磁盘写入完成、等待 NFS 响应这期间信号杀不掉它因为内核正在做一些不能半途而废的操作。大量 D 状态进程基本说明底层 IO 子系统出问题了比如存储卡死、网络文件系统超时。这种情况排查重点不在进程层而是去查存储和网络。我处理过一例 MySQL 实例无响应ps一看几十个 D 状态线程最后定位到是底层存储设备故障和数据库配置没什么关系。结尾一点个人体会进程管理这块内容学的时候确实枯燥但每次线上出问题最后都会回到这些基础概念。我自己排查过不少 CPU 飙高、服务挂起的问题最开始的判断往往不是代码逻辑而是先看进程状态、调度策略、优先级这几个入口。其中 fork 和 wait 的配合、僵尸进程和孤儿进程的机制、实时调度策略的使用边界这三个点踩过坑以后就再也忘不掉了。如果你刚接触 Linux建议不要只盯着top那一排数据而是抽时间亲手跑一遍 fork、wait、taskset、chrt 的示例看看进程状态在ps -el里怎么变化。只要把这些实验做一遍你对“进程是怎么活过来又是怎么被安排运行的”这件事的理解会比看十篇文章都深刻。
返回列表