Files
Operating-System/Thread/schedule.md
2026-03-22 17:40:44 +08:00

14 KiB
Raw Permalink Blame History

好,这个问题你其实已经踩在“操作系统最核心的一条主线”上了——线程调度本质就是CPU 时间如何被分配。我从你之前问的“线程 / 进程 / 虚拟内存 / 内核”这些点串起来,从底层一步一步讲清楚。


一、从最底层出发CPU 到底在干嘛?

CPU 在任意时刻只做一件事:

执行某个线程的指令流instruction stream

注意几个关键点:

  • CPU 不关心“进程”

  • CPU 不关心“程序”

  • CPU 只认:

    • 寄存器状态context
    • 下一条指令地址PC / RIP

所以:

线程 = 可被调度执行的最小单位


二、线程调度的本质

线程调度可以用一句话总结:

操作系统决定:下一刻 CPU 跑哪个线程

这就引出三个核心问题:

  1. 有哪些线程可以运行?
  2. 选哪个?
  3. 怎么切过去?

三、线程的生命周期(调度的基础)

线程在操作系统中通常有几种状态:

          ┌────────────┐
          │  Running   │
          └─────┬──────┘
                │
        时间片用完 / 被抢占
                ↓
          ┌────────────┐
          │   Ready    │ ←─────┐
          └─────┬──────┘       │
                │              │
            被调度              │
                ↓              │
          ┌────────────┐       │
          │  Running   │       │
          └────────────┘       │
                               │
          ┌────────────┐       │
          │  Blocked   │ ──────┘
          └────────────┘
             ↑
         IO / 锁 / 信号量

三个核心状态

  • Running:正在 CPU 上执行
  • Ready:可以运行,但没轮到 CPU
  • Blocked等资源IO / 锁 / 信号量)

四、调度发生的时机(非常关键)

调度不是随便发生的,只会在这些点触发:

1. 主动让出 CPU

  • sleep()
  • wait()
  • 等锁mutex
  • 等信号量

👉 进入 Blocked


2. 被动抢占(重点)

  • 时间片用完timer interrupt
  • 更高优先级线程出现

👉 这是“操作系统控制力”的核心


3. 中断/系统调用返回

比如:

  • IO 完成
  • page fault 处理完

五、调度器内部在干嘛?

调度器核心就是一个函数:

schedule()

它做三件事:

1. 保存当前线程上下文

包括:

  • 寄存器RIP, RSP, 通用寄存器)
  • CPU 状态

👉 保存到:

task_structLinux

2. 选择下一个线程

就绪队列ready queue 里选一个


3. 恢复新线程上下文

  • 加载寄存器
  • 切换栈
  • 跳转执行

六、调度算法(怎么选线程)

这是策略层。


1. 先来先服务FCFS

简单但有问题:

  • 长任务霸占 CPU
  • 响应差

2. 时间片轮转Round Robin

你最应该掌握的:

  • 每个线程分一个时间片(比如 10ms
  • 用完就换

优点:

  • 公平
  • 简单

问题:

  • 上下文切换开销

3. 优先级调度

每个线程有 priority

  • 高优先级先执行

问题:

👉 优先级反转(你后面会遇到)

低优先级线程持锁 → 高优先级线程被阻塞


4. 多级反馈队列MLFQ

现代 OS 核心算法之一:

特点:

  • 多个队列(不同优先级)
  • 新任务优先级高
  • CPU 密集型任务会被降级
  • IO 密集型任务保持高优先级

👉 目标:

让“交互程序更快响应”


5. Linux 的调度CFS

Linux 用的是:

CFSCompletely Fair Scheduler

核心思想:

每个线程“应该得到公平的 CPU 时间”

实现方式:

  • 用红黑树维护

  • 每个线程有:

    vruntime虚拟运行时间
    
  • 谁 vruntime 最小 → 选谁


七、上下文切换Context Switch

这是性能关键点。

一次切换包括:

保存:

  • 寄存器
  • 栈指针
  • 程序计数器

恢复:

  • 新线程所有状态

额外开销:

  • TLB flush可能
  • cache miss

👉 所以:

线程不是越多越好


八、线程 vs 进程调度(你之前问过的点)

相同点:

  • 都可以被调度
  • 都有 task_struct

不同点:

项目 线程 进程
地址空间 共享 独立
切换开销
调度单位

👉 Linux

调度单位其实统一是 task轻量级进程


九、结合你之前的问题:锁 / 信号量 / 管程

调度和同步是强耦合的。


1. mutex互斥量

  • 拿不到锁 → 线程进入 Blocked
  • 调度器切走

2. semaphore信号量

  • P 操作失败 → Blocked
  • V 操作 → 唤醒线程 → 进入 Ready

3. 管程monitor

你之前问的那个关键点:

“是不是为了不可中断操作块?”

更准确说:

管程 = 把共享数据 + 操作 + 同步机制打包

它通过:

  • 自动加锁
  • 条件变量

👉 本质还是:

控制线程什么时候进入 Ready / Blocked


十、一个完整运行例子(你应该重点理解)

假设:

线程 A计算
线程 B读磁盘

过程:

  1. A 运行
  2. 时间片结束 → A → Ready
  3. B 被调度
  4. B 发起 IO → Blocked
  5. 调度器选 A
  6. 磁盘完成 → 中断 → B → Ready
  7. 下一轮调度 → B 运行

👉 核心:

调度器一直在“Ready队列”里选


十一、你需要真正掌握的“本质总结”

我给你压缩成三句话:


1 线程调度 = CPU 分配问题


2 操作系统通过:

  • Ready 队列
  • 调度算法
  • 上下文切换

来控制执行顺序


3 同步原语mutex / semaphore

本质就是:

控制线程进入 Ready 还是 Blocked


如果你想继续深入(建议下一步)

你现在这个水平,可以往这三个方向继续挖:

  1. Linux CFS 源码vruntime 细节)
  2. 调度 + 虚拟内存TLB / cache 影响)
  3. 用户线程 vs 内核线程调度模型1:1 / M:N

如果你愿意,我可以下一步给你画一个:

“Linux 线程从创建 → 调度 → 阻塞 → 唤醒”的完整内核级流程图

那个会直接把你带到“可以看内核源码”的水平。


很好,这一块已经进入“操作系统调度的核心实现层”了。我分三部分讲:

  1. MLFQ多级反馈队列——经典策略模型
  2. CFSLinux 完全公平调度)——工程实现
  3. 上下文切换Context Switch——底层机制

尽量做到:不仅知道“是什么”,还知道“为什么这么设计”。


一、MLFQMulti-Level Feedback Queue

1. 设计目标(先抓本质)

MLFQ 要解决的是:

既要高吞吐CPU利用率高又要高响应交互快

核心矛盾:

  • CPU 密集型任务:一直占 CPU
  • IO 密集型任务:频繁等待,但需要快速响应

2. 基本结构

MLFQ = 多个优先级队列:

Q0最高优先级时间片最短
Q1
Q2
...
Qn最低优先级时间片最长

特点:

  • 越高优先级 → 时间片越短
  • 越低优先级 → 时间片越长

3. 五条经典规则(必须掌握)

这是 MLFQ 的“算法本体”。


规则 1高优先级优先运行

如果 Q0 有任务 → 一定先运行 Q0

规则 2同级别轮转RR

同一队列内部 → Round Robin

规则 3新任务进入最高优先级

新线程 → Q0

👉 目的:快速响应


规则 4用完时间片 → 降级

如果线程用完整个时间片 → 降到下一层

👉 推断:

“你是 CPU 密集型任务”


规则 5周期性提升避免饿死

每隔一段时间 → 所有任务提升到最高优先级

👉 解决 starvation


4. 为什么这样设计(关键理解)

MLFQ 本质在做一件事:

用“行为”动态推测线程类型

行为 推测
很快放弃 CPU IO 密集
一直用满时间片 CPU 密集

5. 一个典型过程(你要能脑补)

情况:

  • 线程 A计算密集
  • 线程 B频繁 IO

执行过程:

  1. A、B 都在 Q0
  2. A 用满时间片 → 降到 Q1
  3. B 很快 IO → 留在 Q0
  4. B 每次都优先执行

👉 结果:

  • B 响应快(交互流畅)
  • A 慢慢跑(后台任务)

6. MLFQ 的问题

MLFQ 很聪明,但也有问题:


可被“欺骗”

线程可以:

每次时间片快结束时主动让出 CPU

👉 永远停在高优先级


参数难调

  • 队列数
  • 时间片长度
  • 提升周期

不够“精确公平”

只是启发式


二、CFSCompletely Fair Scheduler

MLFQ 是“经验派”CFS 是“数学派”。


1. 核心思想

每个线程都应该获得“公平的 CPU 时间”

但现实问题:

  • 线程数量不同
  • 优先级不同

2. 核心概念vruntime

每个线程维护一个值:

vruntime = 虚拟运行时间

理解:

“你已经跑了多少 CPU 时间(加权)”


3. 调度规则(极其重要)

总是选择 vruntime 最小的线程运行

👉 直觉:

  • 谁“欠 CPU”最多 → 谁先跑

4. vruntime 怎么增长?

不是简单的时间,而是:

vruntime += 实际运行时间 × 权重

权重由 nice 值决定:

nice 权重 效果
低(-20 跑得慢(不容易涨 vruntime
高(+19 跑得快vruntime 快涨)

👉 结果:

  • 高优先级线程 → 更常被调度

5. 数据结构:红黑树(关键)

CFS 用:

红黑树(按 vruntime 排序)

结构:

        (最小 vruntime)
              ↑
            root
           /    \
         ...    ...

👉 调度就是:

取最左节点(最小 vruntime

复杂度:

O(log n)

6. 时间片在 CFS 里怎么体现?

CFS 没有“固定时间片”,而是:

调度周期sched_period

每个线程分到:

时间 = sched_period / 线程数

👉 线程越多 → 每个时间越短


7. CFS vs MLFQ 本质区别

维度 MLFQ CFS
思想 启发式 数学公平
数据结构 多队列 红黑树
决策依据 行为 vruntime
可预测性
工程使用 教学为主 Linux 实际使用

8. 一个直觉总结

MLFQ

“我猜你是什么类型任务”

CFS

“我不猜,我只算你跑了多少”


三、上下文切换Context Switch

这是最底层、最硬核的一块。


1. 什么是上下文?

线程的“全部运行状态”:


CPU 上下文

  • 通用寄存器RAX, RBX...
  • 程序计数器RIP
  • 栈指针RSP
  • 标志寄存器EFLAGS

内存相关

  • 页表CR3
  • 虚拟地址空间

内核数据

  • task_struct
  • 调度信息

2. 上下文切换发生的完整过程

我给你一个“真实接近内核”的流程:


Step 1触发调度

来源:

  • 时钟中断(最常见)
  • 系统调用
  • 阻塞操作

Step 2进入内核态

用户态 → 内核态

发生:

  • 保存用户寄存器到内核栈

Step 3保存当前线程上下文

本质代码类似:

current->thread.sp = rsp;
current->thread.ip = rip;
保存所有寄存器

Step 4调用调度器

schedule()

选出 next


Step 5切换地址空间关键

如果是不同进程:

切换 CR3页表基址

影响:

  • TLB 失效(可能 flush

Step 6恢复新线程上下文

rsp = next->thread.sp;
rip = next->thread.ip;
恢复寄存器

Step 7返回用户态

iret / sysret

3. 线程切换 vs 进程切换


线程切换(同进程)

不需要:

  • 切换页表
  • TLB flush

👉 很快


进程切换

需要:

  • 切换 CR3
  • 刷新 TLB或部分失效

👉 很慢


4. 性能开销在哪里?

主要三块:


1 寄存器保存/恢复(小)

几十个寄存器


2 Cache 失效(大)

新线程:

  • L1/L2 cache 不命中
  • 指令 cache 失效

3 TLB 失效(很大)

地址转换缓存失效


👉 结论:

真正慢的不是“切换”,而是“切换后的冷启动”


5. 一个关键理解(很多人忽略)

上下文切换不是“免费暂停”:

它破坏了 CPU 的局部性locality

这直接导致:

  • cache miss
  • pipeline stall

四、把三者串起来(你必须形成这个模型)


调度MLFQ / CFS

决定:

选谁运行

上下文切换

负责:

怎么切过去

同步原语mutex / semaphore

控制:

谁能进入 Ready

五、最后给你一个“工程级理解”

你现在可以用一句话总结整个调度系统:

操作系统维护一个“可运行线程集合”用调度算法CFS选出“最该运行的线程”再通过上下文切换把 CPU 从当前线程切到目标线程。


如果你想继续深入(建议)

下一步你可以挑战这些:

  1. Linux CFS 源码sched_fair.c逐行解析
  2. TLB shootdown 是怎么实现的(多核)
  3. NUMA 对调度的影响
  4. 用户级线程Go / 协程)如何绕开内核调度

如果你想,我可以下一步带你:

“从 Linux 内核源码角度,完整走一遍 schedule()”

这个会直接把你带到“能读内核”的水平。