侧边栏壁纸
  • 累计撰写 140 篇文章
  • 累计创建 21 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

Linux进程创建_僵尸孤儿与调度器

YaFuX
2023-10-03 / 0 评论 / 0 点赞 / 0 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言

上一篇把程序、进程、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_FIFOSCHED_RR 又是什么关系?

这篇就沿着一个 Linux Task 的完整生命周期来理解:

等待 I/O / Event
被抢占
退出
创建 Task
Runnable
被 Scheduler 选中
Running
接下来发生什么?
Sleeping / Waiting
Exit
父进程回收了吗?
Zombie
彻底释放

一、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。

可以画成:

JVM Process / TGID 5000
Task 5000
main
Task 5001
GC
Task 5002
JIT
Task 5003
worker
CPU Run Queue
CPU Core

所以一句更准确的话是:

进程常被用来描述资源和地址空间边界,而 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

来避免不必要的复制。

可以先建立这个模型:

Parent Page Table
Child Page Table
同一批 Physical Pages
某一方写入
复制相关 Page
写入方得到新的 Physical Page

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。

可以画成:

fork
execve
Parent Process
Child Process
同一个 Child PID
运行新的 Program Image

所以 Shell 执行一条外部命令时,可以粗略理解成:

KernelChildShellKernelChildShellfork()Parent 得到 Child PIDChild 从 fork 后继续execve("program", ...)替换地址空间与程序映像从新程序入口执行

四、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 类程序都可能利用这种机制。

所以更准确的关系是:

没有合适祖先
Orphan Child
祖先链有 Subreaper?
最近的 Child Subreaper
Namespace Init / PID 1

这也是为什么不能根据:

PPID 变成了谁

简单推出“机器一定是什么桌面环境”。

六、Scheduler 到底在调度什么?

前面已经知道 Linux 的核心执行对象是:

Task

那么 Scheduler 的工作就很清楚了:

从当前可以运行的 Task 中,决定下一步谁获得 CPU,以及什么时候需要重新选择。

1. Runnable 不等于正在运行

假设一个 CPU Core 上同时有:

Task A
Task B
Task C
Task D

它们都处于 Runnable 状态,但一个时刻真正执行的通常只有一个。

Runnable A
Runnable B
Runnable C
Runnable D
Run Queue
Scheduler
CPU Core

所以:

Runnable = 有资格运行
Running  = 当前真的占着 CPU

2. Task 等 I/O 时为什么会让出 CPU?

如果 Task 执行:

read socket

数据还没到,它继续占用 CPU 没有任何意义。

于是典型过程是:

Scheduler 选中
等待 I/O / Event
Event Ready / Wakeup
被抢占
执行结束
Runnable
Running
Waiting
Exit

这就是为什么 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 时间”。

Task A
vruntime 较小
Task B
vruntime 中等
Task C
vruntime 较大
优先被选中
继续等待

所以 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。
Runnable Tasks
计算 Lag
Eligible?
等待
计算 Virtual Deadline
选择 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 时间片轮换。

可以画成:

Scheduler Class
Real-Time
Fair
SCHED_FIFO
SCHED_RR
SCHED_OTHER / NORMAL
SCHED_BATCH
SCHED_IDLE

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”。

过度简化。forkclone 是不同接口语义;具体 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 的生命周期串起来

现在把本文所有概念重新放回同一张图:

fork / clone semantics
被抢占
I/O / Event Wait
Wakeup
Exit
wait / waitpid
现有 Process
New Task / Child
是否 execve?
替换 Program Image
继续原程序执行流
Runnable
Scheduler
Scheduling Class
SCHED_FIFO / RR
Fair Scheduling
CFS / EEVDF
Running on CPU
发生什么?
Waiting
Parent 已 wait?
Zombie
Reaped / Released

这条链解释了:

创建
运行
等待
唤醒
抢占
退出
回收

为什么会形成 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 调度、阻塞唤醒和最终回收。

0

评论区