第 18 章

调度:谁先运行

调度:谁先运行

假设你是一家诊所的护士,候诊室里有 20 个病人。你怎么决定谁先看病?先来先得?最快能看完的先来?还是根据病情紧急程度?这个决策过程就是调度(Scheduling)——操作系统用完全一样的逻辑决定哪个进程/线程下一个获得 CPU。

好的调度器需要同时满足多个矛盾的目标:让每个任务响应迅速、让 CPU 利用率最大化、让紧急任务优先、让长任务也能最终完成。没有一个调度算法能对所有场景都最优——这就是调度算法研究了几十年还没有"标准答案"的原因。

Level 1:建立直觉

为什么需要调度

CPU 只有一个(或几个核),但同时运行的进程可能有几百个。调度器的工作是:

可运行队列(Run Queue):
  [进程A] [进程B] [进程C] [进程D] ...

调度器每隔 1-10ms 问一次:
"现在应该让谁运行?"

基础调度算法

先来先服务(FCFS - First Come First Served)

最简单的策略:先到的进程先运行,直到完成或阻塞。

进程  到达时间  执行时间
A     0ms      100ms
B     1ms      10ms
C     2ms      5ms

执行顺序:A(100ms) → B(10ms) → C(5ms)
B 等了 99ms 才运行,C 等了 109ms

问题:长任务拖累短任务(护航效应)。

轮转调度(Round Robin)

每个进程轮流运行一个固定时间片(如 10ms),用完就切换:

时间片 = 10ms:
A: ██ (10ms) → B: ██ → C: ██ → A: ██ → B → A → A → A → A → A → 完成

A(100ms)等待时间 = 4次 × 10ms = 40ms
B(10ms)等待时间 = 10ms  
C(5ms)等待时间  = 20ms(加上切换开销)

优先级调度

给每个进程一个优先级,高优先级的优先运行:

Linux 优先级范围:-20(最高)到 +19(最低)
内核线程:最高优先级
普通进程:nice值0(默认)
后台任务:可以设置 nice 19
# 用低优先级运行 CPU 密集型任务
nice -n 19 ./heavy_computation

# 提高运行中进程的优先级(需要 root)
renice -n -5 -p 1234

问题:低优先级进程可能永远等不到 CPU(饥饿)。解决:老化(Aging)——随着等待时间增加,动态提升优先级。

抢占式 vs 协作式

协作式调度(Cooperative):进程主动让出 CPU(调用 sleep()yield() 或等待 I/O)。不主动让出就一直运行。

抢占式调度(Preemptive):操作系统可以在任意时刻强制切换进程(通过时钟中断)。

Level 2:原理剖析

Linux CFS:完全公平调度

Linux 2.6.23(2007年)引入 CFS(Completely Fair Scheduler),至今仍是默认调度器。

核心思想:让每个进程感觉自己独占了 CPU

实现方式:虚拟运行时间(vruntime)

vruntime = 实际运行时间 × (默认权重 / 进程权重)

权重由 nice 值决定(nice=-20 最高权重,nice=+19 最低)

每次调度:选 vruntime 最小的进程运行
→ 运行时间最少的进程优先
→ 自然实现公平分配

数据结构:红黑树(按 vruntime 排序)

CFS 红黑树:
          [vt=100]
         /        \
    [vt=50]     [vt=150]
    /    \         \
[vt=20] [vt=80]  [vt=200]

每次调度:选最左节点(vt最小)= O(log n) 操作
运行完后:更新 vt,重新插入树 = O(log n)

CFS 的优雅之处:nice -20 的进程权重是 nice +19 的 ~1000 倍,但不会让低优先级进程完全饥饿——只是运行得更少。

时间片的权衡

时间片太长:响应性差(用户感觉卡顿) 时间片太短:切换开销大(每次切换 ~5-10µs,时间片 1ms 则 0.5-1% CPU 被调度开销消耗)

Linux CFS 的解决方案:动态时间片,根据可运行进程数调整:

# Linux CFS 时间片计算(简化)
TARGET_LATENCY = 6ms     # 目标:每个进程每 6ms 运行一次
MIN_GRANULARITY = 0.75ms # 最小时间片(防止切换太频繁)

def calc_timeslice(nr_running):
    if nr_running <= 8:
        return TARGET_LATENCY / nr_running
    else:
        return MIN_GRANULARITY  # 进程太多时,固定最小时间片

I/O 密集型 vs CPU 密集型

调度器需要区别对待两类进程:

CPU 密集型(如视频编码、科学计算):
  - 很少等待 I/O
  - 总是有计算要做
  - 希望得到长时间片,减少切换

I/O 密集型(如 Web 服务器、数据库查询):
  - 频繁等待网络、磁盘
  - 等待时不需要 CPU
  - 希望 I/O 完成后立刻能运行(响应性)

CFS 的 I/O 奖励机制:当一个进程从阻塞(等待 I/O)中恢复时,它的 vruntime 不会是"应有的最低值"(这样会让它运行太长),而是设为当前最小 vruntime + 一个小量("唤醒预支"),让它能很快被调度到,保证交互响应性。

多核调度

单核调度的所有原则都适用,但多核有新问题:

负载均衡:把进程均匀分配到各个核,避免某核忙死、某核空闲:

NUMA 意识调度:
  服务器有 2 个 CPU(各 16 核),各有自己的内存
  进程最好留在它的"主核"附近,减少跨 NUMA 内存访问延迟

CPU 亲和性(CPU Affinity):
  让某个进程只在指定的 CPU 核上运行
  减少缓存失效(每次迁移都会导致 L1/L2 缓存失效)
# 将进程 1234 绑定到 CPU 核 0 和 1
taskset -cp 0,1 1234

# 查看进程的 CPU 亲和性
taskset -p 1234

缓存感知调度:进程在一个核上积累了缓存,迁移到另一核会损失缓存("缓存迁移成本")。调度器会倾向于让进程留在同一核上(cache warm),除非负载严重不均衡。

调度延迟和 jitter

调度延迟(Scheduling Latency):从进程"就绪"到真正开始运行的时间。

影响因素:

  1. 有多少进程在排队
  2. 是否有更高优先级进程在运行
  3. 中断是否被屏蔽

Linux CFS 目标延迟:6ms(少于 8 个进程时)到 0.75ms × n(进程多时)。

调度 jitter:延迟的波动。实时系统最怕 jitter——你无法容忍"通常 1ms,偶尔 50ms"。

Level 3 · 规范怎么定义的(资深)

调度策略的标准与形式化分析

POSIX 标准定义了三种调度策略:SCHED_FIFO(实时先进先出)、SCHED_RR(实时轮转)和 SCHED_OTHER(默认,由实现定义)。实时策略的优先级范围至少为 1-32(Linux 实现为 1-99),实时任务始终优先于普通任务执行。POSIX 还要求实现提供 sched_setscheduler()sched_getparam() 等接口来查询和修改调度参数。

Linux 的 CFS(Completely Fair Scheduler) 由 Ingo Molnár 在 2007 年引入(内核 2.6.23)。CFS 使用红黑树按"虚拟运行时间"(vruntime)排序所有可运行任务,每次选择 vruntime 最小的任务执行。CFS 的设计理论基于 公平排队(Fair Queuing)算法——源自网络领域的 WFQ(Weighted Fair Queueing),由 Demers、Keshav 和 Shenker 在 1989 年提出。CFS 的时间复杂度是 O(log n)(红黑树操作),这也是它能高效处理数万个进程的基础。

2024 年,Linux 内核引入了 EEVDF(Earliest Eligible Virtual Deadline First) 调度器替代 CFS,进一步改善了延迟敏感任务的响应时间。EEVDF 基于 Stoica 和 Abdel-Wahab 1995 年的论文,核心改进是为每个任务计算一个"虚拟截止时间",优先调度截止时间最近的任务,从而避免 CFS 中"所有任务都公平但没有任务获得及时响应"的问题。

Level 4 · 边界与陷阱(所有人)

陷阱 1:优先级反转可以让高优先级任务饿死

当高优先级任务 H 等待低优先级任务 L 持有的锁,而 L 又被中优先级任务 M 抢占时,H 实际上被 M 阻塞了——尽管 H 的优先级比 M 高。这就是优先级反转(Priority Inversion)。1997 年火星探路者号(Mars Pathfinder)着陆后频繁重启,原因正是优先级反转导致看门狗定时器超时。修复方案是优先级继承(Priority Inheritance):当 L 持有 H 需要的锁时,L 的优先级被临时提升到 H 的级别,防止 M 抢占 L。Linux 的 PTHREAD_PRIO_INHERIT 互斥锁属性和内核的 rt_mutex 都实现了这一机制。

陷阱 2:CPU 亲和性设置不当导致性能不升反降

将线程绑定到特定 CPU 核心(sched_setaffinity()/taskset)可以减少缓存失效——线程不会在核心间迁移,L1/L2 Cache 中的数据始终有效。但如果绑定策略不合理(如把所有线程绑到同一个核心),反而导致该核心过载,其他核心空闲。更隐蔽的问题是 NUMA 架构下的绑核:如果线程被绑到远端 NUMA 节点的核心上,它访问的内存却在本地节点,每次内存访问都要跨越 NUMA 互连,延迟增加 50-100%。正确做法是同时绑定 CPU 亲和性和内存策略(numactl --cpubind=0 --membind=0)。

陷阱 3:时间片太短增加开销,太长损害交互性

CFS 的默认调度延迟(sched_latency)是 6ms,最小时间片(sched_min_granularity)是 0.75ms。如果系统中有 100 个可运行进程,每个进程的时间片仅 0.06ms(60μs),而一次上下文切换本身就需要 1-5μs——切换开销占了总时间的 2-8%。反过来,如果把时间片设得太长(如 100ms),桌面环境中鼠标移动和键盘输入会出现明显卡顿。Linux 提供了 /proc/sys/kernel/sched_latency_ns 等调优参数,服务器通常增大这些值(减少切换),桌面系统则使用默认值或更小的值。

本章评分
4.7  / 5  (12 评分)

💬 留言讨论