前言
上一篇把程序、进程、Platform Thread 和 Virtual Thread 放到了同一条执行链上。
接下来继续往 Linux Kernel 里走,会遇到另一组高频概念:
fork
execve
task_struct
父进程
子进程
Zombie
Orphan
wait
Nice
CFS
EEVDF
SCHED_FIFO
SCHED_RR
如果只是背概念,很容易得到一些互相冲突的印象:
fork()到底是在“复制进程”,还是“调用 clone 创建进程”?- 子进程明明已经退出了,为什么
ps里还能看到<defunct>? - Zombie 既然已经死了,为什么
kill -9也杀不掉? - 父进程先退出以后,子进程一定会被 PID 1 接管吗?
- Linux 调度的到底是“进程”还是“线程”?
- CFS 为什么叫“完全公平”,它真的给每个任务平均时间吗?
- 现在的 Linux 还主要是 CFS 吗?
- Nice 值、实时优先级、
SCHED_FIFO、SCHED_RR又是什么关系?
这篇就沿着一个 Linux Task 的完整生命周期来理解:
一、Linux 是怎么描述一个 Task 的?
1. PCB 在 Linux 里对应什么?
操作系统教材里经常使用:
PCB
Process Control Block
来表示操作系统保存进程信息的数据结构。
这个概念没问题,但进入 Linux 源码以后,真正应该记住的是:
struct task_struct
Linux 通过 task_struct 记录一个 Task 的大量运行信息,例如:
- 标识信息;
- 调度状态;
- 地址空间关联;
- 文件资源;
- 信号状态;
- Credential;
- CPU Affinity;
- 调度实体;
- 父子关系;
- Thread Group 关系。
所以可以理解成:
PCB
↓ 理论概念
Linux task_struct
↓ 真实实现
Task
不要把“PCB”误认为 Linux 源码中一定存在一个固定名字就叫 PCB 的结构体。
2. Linux 调度器真正面对的是 Task
Linux 文档在很多地方会直接使用:
Task
来同时描述传统意义上的进程和线程。
这也是为什么 Linux 调度时,真正进入 Run Queue 的不是“一个 Java Process 整体”,而是其中可运行的 OS Thread / Task。
例如一个 JVM 进程拥有:
main
GC Thread
JIT Compiler Thread
业务 Worker
在 Kernel Scheduler 看来,它们最终都是可以独立获得 CPU 的 Task。
可以画成:
所以一句更准确的话是:
进程常被用来描述资源和地址空间边界,而 Linux Scheduler 实际调度的是可运行 Task。
二、fork() 到底创建了什么?
1. fork() 不是“从磁盘重新加载一次程序”
在 Unix / Linux 体系中,一个经典的进程创建方式是:
pid_t pid = fork();
调用成功以后,会出现两个执行流:
Parent
Child
最让初学者觉得奇怪的是:
fork()只调用了一次,为什么像返回了两次?
因为返回以后,父子进程都会从 fork() 后面继续执行。
返回值不同:
Parent: 返回 Child PID
Child : 返回 0
失败 : Parent 得到 -1
所以程序常写成:
pid_t pid = fork();
if (pid < 0) {
// 创建失败
} else if (pid == 0) {
// Child
} else {
// Parent
}
2. fork() 不是立刻完整复制所有物理内存
如果父进程已经占用数 GB 内存,而每次 fork() 都立即复制几 GB 数据,成本会非常夸张。
现代 Linux 通常利用:
Copy-on-Write
COW
来避免不必要的复制。
可以先建立这个模型:
刚 fork() 完时,父子进程可以暂时映射相同物理页。
当某一方真正写入时,再复制相关 Page,使两个进程保持彼此独立的地址空间语义。
这就是为什么:
逻辑上像“复制了进程”,物理上却不必马上复制所有内存。
3. fork() 和 clone() 不能简单说成“前者底层就是后者”
Linux 还有:
clone()
clone3()
它们能更精细地控制新 Task 与调用方共享哪些资源。
在用户空间某些 libc 实现路径中,fork() 包装器可能借助 clone 语义实现;线程库也会利用 clone 系列能力创建共享资源的 Task。
但写原理文章时,更稳妥的表述是:
fork() → Unix 进程复制语义
clone() → Linux 提供更细粒度的 Task 创建与共享控制
而不是把所有版本、架构、libc 都写死成:
fork()一定就是直接调用某一条固定的clone()。
三、为什么还需要 execve()?
fork() 创建出来的 Child,一开始继承的是 Parent 的程序映像语义。
但很多时候,我们真正需要的是:
创建一个新进程,然后运行另一个程序。
于是 Unix 经典组合出现了:
fork
↓
exec
1. execve() 不是再创建一个进程
这是非常重要的一点。
执行:
execve(...)
并不是:
当前 Process
↓
再创建一个新 Process
而是:
用新的程序映像替换当前进程的用户空间程序内容。
PID 通常仍然是当前这个进程的 PID。
可以画成:
所以 Shell 执行一条外部命令时,可以粗略理解成:
四、Zombie 为什么“死了还在”?
Zombie Process 是 Linux 里最容易被名字误导的概念之一。
1. 进程退出以后并不是所有信息立刻消失
假设 Child 已经执行:
_exit(0);
它真正运行所需要的大多数资源可以释放:
- 用户空间内存;
- 打开的相关资源引用;
- CPU 执行上下文等。
但 Parent 可能还需要知道:
Child 为什么退出?
退出码是什么?
是否被 Signal 杀死?
因此 Kernel 会暂时保留足够的退出状态,等待 Parent:
wait(...)
waitpid(...)
进行回收。
如果 Child 已经退出,而 Parent 迟迟不 wait:
Child
↓
已结束执行
↓
退出信息仍等待 Parent 获取
↓
Zombie
2. 为什么 kill -9 杀不掉 Zombie?
因为它已经死了。
SIGKILL 的意义是让一个仍在执行 / 存在执行生命的进程终止。
Zombie 已经不再执行用户代码。
所以:
kill zombie
本身就像在要求:
把一个已经终止的执行实体再终止一次。
真正要做的是:
让 Parent wait / waitpid
或者让 Parent 自己退出,使 Zombie 被新的 Reaper 接管并回收。
3. 实验:亲自观察 <defunct>
下面写一个故意不 wait() 的程序:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
printf("child pid=%d ppid=%d exiting\n",
getpid(), getppid());
fflush(stdout);
_exit(0);
}
printf("parent pid=%d child=%d sleeping\n",
getpid(), pid);
fflush(stdout);
sleep(5); // 故意不 wait
return 0;
}
编译:
gcc zombie_demo.c -o zombie_demo
运行后,在 Parent 还没有退出的几秒内查看:
ps -o pid,ppid,stat,cmd --ppid <PARENT_PID>
本文在 Linux 6.18 环境中实际得到:
parent pid=1701 child=1703 sleeping
child pid=1703 ppid=1701 exiting
PID PPID STAT CMD
1703 1701 Z [zombie_demo] <defunct>
这里:
STAT = Z
就是 Zombie 状态。
五、Orphan 为什么通常不危险?
Orphan Process 的情况正好反过来。
Zombie 是:
Child 先死
Parent 还活着
Parent 没回收
Orphan 是:
Parent 先退出
Child 还活着
1. Parent 没了,Child 不会自动一起死
假设:
Parent PID 1000
↓ fork
Child PID 1001
Parent 先退出,但 Child 仍然需要继续工作。
Linux 不会因为“父亲没了”就直接把 Child 杀掉。
而是会进行:
Reparenting
也就是把它重新挂到另一个能够负责回收后代的进程下面。
2. 不要再写死“一定被 PID 1 收养”
在简单 Unix 教学模型中,经常会说:
Orphan 一定会被 init,也就是 PID 1 收养。
这个结论今天不够完整。
Linux 支持:
Child Subreaper
如果祖先链中存在被设置为 Child Subreaper 的进程,那么 Orphan 可以先被最近的活跃 Subreaper 接管。
例如容器、服务管理器、Supervisor 类程序都可能利用这种机制。
所以更准确的关系是:
这也是为什么不能根据:
PPID 变成了谁
简单推出“机器一定是什么桌面环境”。
六、Scheduler 到底在调度什么?
前面已经知道 Linux 的核心执行对象是:
Task
那么 Scheduler 的工作就很清楚了:
从当前可以运行的 Task 中,决定下一步谁获得 CPU,以及什么时候需要重新选择。
1. Runnable 不等于正在运行
假设一个 CPU Core 上同时有:
Task A
Task B
Task C
Task D
它们都处于 Runnable 状态,但一个时刻真正执行的通常只有一个。
所以:
Runnable = 有资格运行
Running = 当前真的占着 CPU
2. Task 等 I/O 时为什么会让出 CPU?
如果 Task 执行:
read socket
数据还没到,它继续占用 CPU 没有任何意义。
于是典型过程是:
这就是为什么 I/O-Bound 程序并不是一直“占着自己的时间片干等”。
它阻塞以后会离开 Runnable Queue,等事件就绪以后再被唤醒。
七、抢占式调度解决了什么?
1. 协作式调度的问题
假设系统完全依赖 Task 自己主动说:
我用够了,现在把 CPU 让给别人。
那么只要某个程序写成:
while (1) {
// 永远不主动让出
}
其他任务就可能长期得不到运行机会。
这种模型要求程序彼此“合作”,属于:
Cooperative Multitasking
2. 抢占式调度由 Kernel 强制决定
现代通用 Linux 采用抢占式调度思想。
Scheduler 可以在合适时机决定:
Current Task 暂停
↓
保存执行上下文
↓
选择另一个 Task
↓
恢复新 Task 上下文
↓
继续执行
这就是:
Preemption
所以源代码里:
Thread.yield();
不能理解成:
我把 CPU 正式释放给指定线程,而且对方一定马上运行。
Java Thread.yield() 只是给 Scheduler 一个 Hint,具体是否切换、谁运行都不能依赖它保证程序正确性。
八、CFS 为什么曾经这么重要?
很长一段时间里,普通 Linux Task 最著名的调度设计就是:
CFS
Completely Fair Scheduler
CFS 从 Linux 2.6.23 合入主线。
1. CFS 不是简单“每人固定 10ms”
这是一个常见误解。
CFS 的核心思想更接近:
假设存在一颗理想 CPU,可以同时按权重公平运行所有 Runnable Task;真实 CPU 做不到,就尽量模拟这种结果。
为此它维护:
vruntime
Virtual Runtime
可以先理解成:
Task 按自身权重折算以后“已经享受了多少 CPU”。
谁的 vruntime 更小,就意味着谁相对“欠 CPU 时间”。
所以 CFS 并不是简单:
A 10ms
B 10ms
C 10ms
永远机械轮询
而是在持续追求加权公平。
2. Nice 值为什么会影响公平份额?
普通 Task 常见 Nice 范围:
-20 ... 19
Nice 越小,通常权重越高。
它影响的是 Scheduler 认为:
这个 Task 应该获得多大的 CPU Share。
所以“公平”也不是:
所有任务绝对 50 / 50
而是:
按照权重公平
九、现在 Linux 还是只讲 CFS 吗?
如果资料停留在 Linux 2.6 时代,会得到:
普通任务调度 = CFS。
这个说法今天已经不够新了。
Linux Kernel 从 6.6 开始逐步向:
EEVDF
Earliest Eligible Virtual Deadline First
迁移。
当前 Linux Kernel 官方文档已经明确写到:
CFS 正在给 EEVDF 让位。
1. EEVDF 仍然追求公平,但选择方式变了
EEVDF 仍然需要回答:
哪个 Runnable Task 现在最应该获得 CPU?
它引入两个很重要的直觉:
Lag
Virtual Deadline
可以粗略理解:
lag > 0:这个 Task 相对欠了 CPU 时间;lag < 0:它已经用得偏多;- 对 Eligible Task 计算 Virtual Deadline;
- 优先选择 Virtual Deadline 更早的 Task。
它既延续公平调度思路,也能更好地表达延迟敏感任务的需求。
因此今天写 Linux Scheduler,比较合适的表达是:
历史核心:CFS
当前演进:EEVDF
而不是把 CFS 当成永远不会变化的终点。
十、实时调度和普通调度是什么关系?
Linux 不只有一种 Scheduler Policy。
1. 普通任务
普通任务常见策略包括:
SCHED_OTHER / SCHED_NORMAL
SCHED_BATCH
SCHED_IDLE
它们属于 Fair Scheduling 体系。
2. 实时任务
实时策略最常见的有:
SCHED_FIFO
SCHED_RR
实时优先级通常使用:
1 ... 99
这里和 Nice 的:
-20 ... 19
不是一套数值体系。
SCHED_FIFO
在同一策略体系下,高优先级实时 Task 可以压过低优先级实时 Task 和普通任务。
同优先级的 SCHED_FIFO Task 不依赖普通 Round-Robin 时间片轮换;它通常会一直运行到:
- 阻塞;
- 主动让出;
- 退出;
- 被更高优先级实时任务抢占。
SCHED_RR
它在实时优先级基础上增加同优先级 Task 之间的 Round-Robin 时间片轮换。
可以画成:
3. “只要有实时进程,普通进程永远不能运行”也太绝对
从理论优先级模型看,Runnable 高优先级实时 Task 会优先于普通 Task。
但现代 Linux 还有实时带宽限制 / Throttling 等保护机制。
默认配置通常会为非实时任务保留一定 CPU 带宽,避免一个失控实时任务把整台机器永久饿死。
所以不要把实时优先级理解成:
任何情况下只要存在一个 RT Task,所有普通 Task 都必然永远停止。
十一、I/O-Bound 和 CPU-Bound 为什么表现不同?
1. CPU-Bound
CPU-Bound Task 的大部分时间都在:
Running / Runnable
例如:
- 压缩;
- 编码;
- 密集数学计算;
- 大量序列化计算。
它真正需要的是:
CPU Time
2. I/O-Bound
I/O-Bound Task 经常执行:
Running
↓
发起 I/O
↓
Waiting
↓
Event Ready
↓
Runnable
它可能单次只需要非常短的 CPU 时间,但希望:
数据一到,就尽快被唤醒并执行。
所以优秀 Scheduler 不能只看:
谁运行时间长
还需要兼顾:
- Fairness;
- Wakeup Latency;
- Task Weight;
- Scheduling Class;
- Deadline / Eligibility;
- CPU Affinity;
- 多核负载均衡等。
十二、实验:看看当前 Shell 属于什么调度策略
在 Linux 上可以直接执行:
ps -o pid,ppid,cls,pri,ni,stat,comm -p $$
本文 Linux 6.18 环境中得到:
PID PPID CLS PRI NI STAT COMMAND
1712 1711 TS 19 0 S bash
其中:
NI = 0
就是 Nice 值。
还可以:
chrt -p $$
实际输出:
pid 1712's current scheduling policy: SCHED_OTHER
pid 1712's current scheduling priority: 0
pid 1712's current runtime parameter: 2100000
注意:
SCHED_OTHER priority = 0
和实时调度的 1~99 Priority 不要混为一谈。
普通任务的权重更多通过 Nice / Fair Scheduler 体系体现。
十三、常见误区
1. 进程创建与退出
误区 1:fork() 会立刻完整复制父进程全部物理内存。
错误。Linux 通常使用 Copy-on-Write,只有后续写入相关 Page 时才进行必要复制。
误区 2:execve() 会再创建一个新进程。
错误。它主要是替换当前进程的程序映像;不是再生成一个独立 PID 的新 Child。
误区 3:fork() 在所有 Linux 环境里都可以简单等同于“底层调用 clone”。
过度简化。fork、clone 是不同接口语义;具体 libc 包装和 Kernel 实现路径还涉及版本与架构。
2. Zombie 与 Orphan
误区 4:Zombie 还在运行,只是运行得不正常。
错误。Zombie 已经终止执行,只是 Parent 尚未回收其退出状态。
误区 5:对 Zombie 执行 kill -9 就能清理它。
错误。它已经死了,需要 Parent wait / waitpid,或者由新的 Reaper 回收。
误区 6:Orphan 永远直接归 PID 1。
不准确。Linux 的 Child Subreaper 机制允许孤儿后代先被最近的 Subreaper 接管。
3. Scheduler
误区 7:Scheduler 调度的是整个 Process,而不是线程。
不准确。Linux Scheduler 面对的是 Runnable Task;同一个多线程 Process 的多个 OS Thread 可以分别被调度。
误区 8:CFS 就是每个任务固定轮流运行 10ms。
错误。CFS 的核心是 vruntime 和加权公平,不是固定时间片轮询模型。
误区 9:现代 Linux 普通任务调度永远就是 CFS。
过时。Linux 6.6 起已经开始向 EEVDF 迁移,当前官方文档明确描述 CFS 正在让位给 EEVDF。
误区 10:Thread.yield() 可以保证当前线程立即让给另一个指定线程。
错误。它只是调度提示,不能作为同步或正确性机制。
误区 11:Nice 和实时 Priority 是同一套优先级数字。
错误。普通任务 Nice 常见为 -20~19;实时 SCHED_FIFO / SCHED_RR 使用另一套静态实时优先级。
十四、把一个 Task 的生命周期串起来
现在把本文所有概念重新放回同一张图:
这条链解释了:
创建
运行
等待
唤醒
抢占
退出
回收
为什么会形成 Linux 进程管理最核心的一套机制。
十五、总结
理解 Linux 进程管理,最重要的不是把:
fork
Zombie
CFS
Nice
分别背下来,而是看它们在同一个 Task 生命周期中处于什么位置。
创建阶段:
fork / clone / clone3
决定新的执行实体如何出现、共享哪些资源。
程序切换阶段:
execve
让一个已经存在的进程开始执行新的 Program Image。
运行阶段:
Scheduler
决定 Runnable Task 谁真正获得 CPU。
普通调度历史上以 CFS 为代表,而当前 Linux 已经逐步转向 EEVDF。
退出阶段:
exit
wait / waitpid
决定 Child 的退出状态什么时候真正被 Parent 回收。
于是 Zombie 和 Orphan 也不再是两个奇怪名字:
Zombie
= Child 已结束,但退出状态仍等待 Reaper
Orphan
= Parent 先结束,仍活着的 Child 需要重新寻找 Reaper
Linux 所谓“进程管理”,本质上就是 Kernel 持续管理 Task 的创建、资源关系、可运行状态、CPU 调度、阻塞唤醒和最终回收。
评论区