进程调度算法

面试官问:"操作系统如何决定下一个运行哪个进程?"

小王说:"用调度算法...有先来先服务、短作业优先..."

面试官继续追问:"那什么是时间片?为什么时间片太大会导致系统响应变慢?"

小王说:"因为...进程要等很久?"

面试官又问:"Linux用的CFS调度器是什么原理?为什么它能保证公平性?"

小王彻底卡住了。

进程调度是操作系统的核心功能之一,看似简单,背后涉及的权衡和优化多得让人眼花缭乱。今天,我们把常见的调度算法全部讲透。

一、从一个问题开始

先想象一个餐厅的场景:

你是餐厅经理,需要决定:
- 现在有5桌客人等位
- 1号桌:点了10道菜,需要30分钟
- 2号桌:点了2道菜,需要5分钟
- 3号桌:点了5道菜,需要15分钟
- 4号桌:点了1道菜,需要2分钟
- 5号桌:点了3道菜,需要8分钟

你选择按什么顺序做菜?

选择1:按点菜顺序(先来先服务)
结果:
- 1号桌等30分钟完成
- 2号桌等35分钟完成
- ...
- 平均等待时间 = (30 + 35 + 50 + 55 + 63) / 5 = 46.6分钟

选择2:先做快的(短作业优先)
结果:
- 4号桌等2分钟完成
- 2号桌等7分钟完成
- 5号桌等15分钟完成
- ...
- 平均等待时间 = (2 + 7 + 15 + 30 + 35) / 5 = 17.8分钟

短作业优先的等待时间减少了62%!

这就是调度算法要解决的问题:如何分配CPU时间,让整体效率最高

【直观类比】

进程调度 = 餐厅出餐顺序

┌─────────────────────────────────────────────────┐
│            不同的调度策略                        │
├─────────────────────────────────────────────────┤
│  先来先服务(FCFS):按排队顺序做                │
│  - 公平,但效率可能不高                          │
│  - 长作业会拖住后面的短作业                      │
├─────────────────────────────────────────────────┤
│  短作业优先(SJF):先做快的                      │
│  - 平均等待时间最短                              │
│  - 但长作业可能"饿死"                           │
├─────────────────────────────────────────────────┤
│  时间片轮转(RR):轮着来,每人做一会儿          │
│  - 响应时间快                                    │
│  - 但切换开销大                                 │
├─────────────────────────────────────────────────┤
│  优先级调度:VIP优先                            │
│  - 重要任务先执行                              │
│  - 但低优先级可能饿死                           │
└─────────────────────────────────────────────────┘

调度算法的评价指标

指标说明优化目标
CPU利用率CPU忙碌时间占比越高越好
吞吐量单位时间完成进程数越高越好
周转时间进程从提交到完成的时间越短越好
等待时间进程在就绪队列等待的时间越短越好
响应时间从提交到首次响应的时间越短越好
周转时间 = 完成时间 - 到达时间
等待时间 = 开始时间 - 到达时间
周转时间 = 等待时间 + 执行时间

二、核心原理

1. 先来先服务(FCFS, First Come First Served)

最简单的调度算法:

def fcfs_schedule(processes):
    """
    processes: [(arrival_time, burst_time), ...]
    按到达时间排序
    """
    # 按到达时间排序
    sorted_procs = sorted(processes, key=lambda x: x[0])
    
    current_time = 0
    waiting_time = 0
    
    for arrival, burst in sorted_procs:
        if current_time < arrival:
            current_time = arrival
        
        waiting_time += current_time - arrival
        current_time += burst
    
    return waiting_time / len(processes)

# 示例:进程P1-P4
# P1: 到达0,执行4
# P2: 到达1,执行3
# P3: 到达2,执行2
# P4: 到达3,执行1

# 执行顺序:P1 → P2 → P3 → P4
# Gantt图:
# | P1 | P2 | P3 | P4 |
# 0    4    7    9   10

# 等待时间:
# P1: 0 - 0 = 0
# P2: 4 - 1 = 3
# P3: 7 - 2 = 5
# P4: 9 - 3 = 6
# 平均等待时间 = (0 + 3 + 5 + 6) / 4 = 3.5

优点

  • 简单,容易实现
  • 公平(按顺序)

缺点

  • 护航效应(Convoy Effect):长作业在前面会导致后面短作业等待很久
  • 平均等待时间不是最优

2. 短作业优先(SJF, Shortest Job First)

优先执行预计执行时间最短的作业:

def sjf_schedule(processes):
    """
    非抢占式SJF
    """
    processes = sorted(processes, key=lambda x: x[1])  # 按执行时间排序
    # 注意:这里简化了,实际还需要考虑到达时间
    
    current_time = 0
    total_waiting = 0
    
    # 使用优先级队列
    import heapq
    ready_queue = []
    idx = 0
    
    while idx < len(processes) or ready_queue:
        # 添加所有已到达的进程
        while idx < len(processes) and processes[idx][0] <= current_time:
            arrival, burst = processes[idx]
            # 按burst时间排序
            heapq.heappush(ready_queue, (burst, arrival))
            idx += 1
        
        if ready_queue:
            burst, arrival = heapq.heappop(ready_queue)
            current_time += burst
            total_waiting += current_time - arrival - burst
        else:
            # 没有可运行的进程,跳到下一个到达时间
            current_time = processes[idx][0]
    
    return total_waiting / len(processes)

SJF vs FCFS(假设所有进程同时到达)

进程:P1=10, P2=4, P3=3, P4=2

FCFS顺序:P1 → P2 → P3 → P4
- P1等待:0,周转:10
- P2等待:10,周转:14
- P3等待:14,周转:17
- P4等待:17,周转:19
- 平均等待:10.25

SJF顺序:P4 → P3 → P2 → P1
- P4等待:0,周转:2
- P3等待:2,周转:5
- P2等待:5,周转:9
- P1等待:9,周转:19
- 平均等待:4.0

SJF的问题

  • 需要预先知道作业执行时间(实际很难准确预测)
  • 长作业可能"饿死"

3. 最短剩余时间优先(SRTF, Shortest Remaining Time First)

抢占式版本的SJF:

def srtf_schedule(processes):
    """
    最短剩余时间优先(抢占式SJF)
    """
    import heapq
    
    current_time = 0
    ready_queue = []
    completed = []
    idx = 0
    n = len(processes)
    
    while len(completed) < n:
        # 添加新到达的进程
        while idx < n and processes[idx][0] <= current_time:
            arrival, burst = processes[idx]
            # (remaining_time, arrival_time, original_burst)
            heapq.heappush(ready_queue, (burst, arrival, burst))
            idx += 1
        
        if ready_queue:
            remaining, arrival, original = heapq.heappop(ready_queue)
            current_time += 1
            remaining -= 1
            
            # 检查是否有新进程到达
            while idx < n and processes[idx][0] <= current_time:
                new_arrival, new_burst = processes[idx]
                heapq.heappush(ready_queue, (new_burst, new_arrival, new_burst))
                idx += 1
            
            if remaining > 0:
                heapq.heappush(ready_queue, (remaining, arrival, original))
            else:
                completed.append((current_time - arrival - original, arrival, original))
        else:
            current_time = processes[idx][0]
    
    return sum(p[0] for p in completed) / len(completed)

4. 时间片轮转(RR, Round Robin)

每个进程执行一个时间片,然后切换到下一个:

def round_robin_schedule(processes, time_slice=2):
    """
    时间片轮转调度
    """
    from collections import deque
    
    current_time = 0
    ready_queue = deque()
    remaining = {}  # pid -> remaining_time
    idx = 0
    
    # 初始化
    while idx < len(processes) and processes[idx][0] <= current_time:
        arrival, burst = processes[idx]
        ready_queue.append(arrival)
        remaining[arrival] = burst
        idx += 1
    
    total_waiting = 0
    
    while ready_queue:
        arrival = ready_queue.popleft()
        burst = remaining[arrival]
        
        if burst <= time_slice:
            # 进程完成
            current_time += burst
            waiting = current_time - arrival - burst
            total_waiting += waiting
            
            # 添加新到达的进程
            while idx < len(processes) and processes[idx][0] <= current_time:
                new_arrival, new_burst = processes[idx]
                ready_queue.append(new_arrival)
                remaining[new_arrival] = new_burst
                idx += 1
        else:
            # 时间片用完,放回队列
            current_time += time_slice
            remaining[arrival] = burst - time_slice
            ready_queue.append(arrival)
            
            # 添加新到达的进程
            while idx < len(processes) and processes[idx][0] <= current_time:
                new_arrival, new_burst = processes[idx]
                ready_queue.append(new_arrival)
                remaining[new_arrival] = new_burst
                idx += 1
    
    return total_waiting / len(processes)

时间片大小的影响

时间片太大:
- 响应时间变慢(像FCFS)
- 但上下文切换开销小

时间片太小:
- 响应时间快
- 但上下文切换开销大(CPU时间浪费在切换上)

最优时间片 ≈ 80%的进程都在这段时间内完成
通常选择 10-100ms

5. 优先级调度

给每个进程分配优先级,高优先级先执行:

def priority_schedule(processes):
    """
    优先级调度(数值越小优先级越高)
    """
    import heapq
    
    current_time = 0
    ready_queue = []
    idx = 0
    
    while idx < len(processes) or ready_queue:
        # 添加所有已到达的进程
        while idx < len(processes) and processes[idx][0] <= current_time:
            arrival, burst, priority = processes[idx]
            heapq.heappush(ready_queue, (priority, arrival, burst))
            idx += 1
        
        if ready_queue:
            priority, arrival, burst = heapq.heappop(ready_queue)
            current_time += burst
        else:
            current_time = processes[idx][0]

优先级反转问题

场景:
- 进程A(低优先级):获取了锁
- 进程B(中优先级):抢占CPU,进程A无法运行
- 进程C(高优先级):等待锁,但锁被A持有

结果:高优先级进程反而等最久!

解决方案:
1. 优先级继承:持有锁的低优先级进程继承等待者的高优先级
2. 优先级天花板:访问资源前提升优先级

6. 多级反馈队列(MLFQ)

Linux CFS之前广泛使用的调度器:

┌─────────────────────────────────────────────────┐
│         多级反馈队列示意图                       │
├─────────────────────────────────────────────────┤
│  队列0(最高优先级)  RR时间片=8ms               │
│  ┌─────────────────────────┐                   │
│  │ P5 │ P6 │ P7 │ ...     │                   │
│  └─────────────────────────┘                   │
│                    ↓ 队列1(次高)RR时间片=16ms │
│  ┌─────────────────────────┐                   │
│  │ P3 │ P4 │ ...          │                   │
│  └─────────────────────────┘                   │
│                    ↓ 队列2(中)RR时间片=32ms   │
│  ┌─────────────────────────┐                   │
│  │ P1 │ P2 │ ...          │                   │
│  └─────────────────────────┘                   │
│                    ↓ ...                       │
│  队列n(最低)     RR时间片=更大                │
└─────────────────────────────────────────────────┘

规则:
1. 新进程进入最高优先级队列
2. 如果时间片用完,降级到下一队列
3. 如果没时间片用完就阻塞,提升到上一队列
4. 最高队列为空才运行次高队列

优点:
- I/O密集型进程保持在高优先级(响应快)
- CPU密集型进程自动降级(避免饿死)

三、CFS调度器(Linux)

Linux 2.6.23之后使用CFS(Completely Fair Scheduler):

// CFS的核心思想:给每个进程分配"虚拟运行时间"
// 谁的实际运行时间最少,谁下次优先运行

struct sched_entity {
    struct load_weight load;           // 权重(Nice值影响)
    struct rb_node run_node;          // 红黑树节点
    unsigned int on_rq;               // 是否在就绪队列
    
    u64 exec_start;                   // 上次开始执行时间
    u64 sum_exec_runtime;             // 总执行时间
    u64vruntime;                      // 虚拟运行时间
    u64 last_sum_exec_runtime;        // 上次总执行时间
};

// 优先级映射
// Nice值 -20 → 权重 1024
// Nice值 0   → 权重 1024
// Nice值 +19 → 权重 15
// 权重比例 ≈ 1.25^(nice)

static const int prio_to_weight[40] = {
 /* -20 */     88761, 71755, 56483, 46273, 36291,
 /* -15 */     29154, 23254, 18705, 14949, 11916,
 /* -10 */      9548,  7620,  6100,  4904,  3906,
 /*  -5 */      3121,  2501,  1991,  1586,  1277,
 /*   0 */      1024,   820,   655,   526,   423,
 /*   5 */       335,   272,   215,   172,   137,
 /*  10 */       110,    87,    70,    56,    45,
 /*  15 */        36,    29,    23,    18,    15,
};

CFS的红黑树结构

就绪队列中的进程按vruntime排列:

        vruntime最小(最应该运行)

         ┌───┴───┐
        P3       P5
       /   \    /  \
      P1    -  -   P7
     /
    P2

新进程加入时:
- 计算其vruntime
- 插入到红黑树中的正确位置
- 最左边的节点最先运行

进程运行时:
- vruntime += 运行时间 * ( NICE_0_LOAD / 进程权重 )
- Nice值越高(优先级越低),vruntime增长越快

四、边界与特例

1. 调度算法的选择标准

场景推荐算法原因
批处理系统SJF/SRTF最大化吞吐量
交互系统RR/MLFQ最小化响应时间
实时系统优先级/EDF确保deadline
通用LinuxCFS公平+高效

2. 实时调度算法

EDF(Earliest Deadline First)

// 最晚截止时间优先
// 每次调度最紧急(最早截止)的任务

struct task {
    int id;
    long period;        // 周期
    long execution;     // 执行时间
    long deadline;      // 下次截止时间
    long next_deadline; // 绝对截止时间
};

// 调度器总是选择deadline最近的任务
// 条件:如果 sum(execution_i / period_i) <= 1,则可调度

Rate Monotonic(周期单调)

周期越短的任务优先级越高

例如:
- Task A:周期20ms,执行10ms → 利用率 50%
- Task B:周期50ms,执行25ms → 利用率 50%

总利用率 = 100%,可调度

RM优先级:A > B

3. 多核调度

多核CPU的调度更复杂:

┌─────────────────────────────────────────────────┐
│            多核调度问题                          │
├─────────────────────────────────────────────────┤
│  1. 负载均衡                                      │
│     - 不同核心的负载可能不均                       │
│     - 需要定期迁移进程                             │
│                                                  │
│  2. 亲和性                                        │
│     - 进程最好在同一核心运行(缓存友好)           │
│     - 但不能为了亲和性牺牲负载均衡                 │
│                                                  │
│  3. 迁移成本                                      │
│     - 迁移后缓存失效                              │
│     - 需要权衡迁移收益和成本                       │
└─────────────────────────────────────────────────┘

五、常见误区

❌ 误区一:SJF是最优调度算法

SJF在平均等待时间上是最优的,但:

1. 需要预先知道执行时间(实际不可能准确预测)
2. 长作业可能饿死
3. 实际系统用的是预测算法(如指数平均)

❌ 误区二:时间片越小越好

时间片太小的问题:
- 上下文切换开销占比增加
- 如果切换开销是1ms,时间片也是1ms
- 50%的时间浪费在切换上!

实际测试:
- 时间片 4ms,上下文切换开销 0.1ms → 开销占比 2.5%
- 时间片 1ms,上下文切换开销 0.1ms → 开销占比 10%

❌ 误区三:Linux只用CFS

Linux实际上有多种调度器:

- CFS:普通进程的默认调度器
- BFS:desktop版本,曾经是CFS的替代
-实时调度器:SCHED_FIFO, SCHED_RR, SCHED_DEADLINE

❌ 误区四:响应时间和吞吐量是同义词

响应时间:用户提交到首次响应的时间
- 关注交互体验
- 需要RR等调度器

吞吐量:单位时间完成的任务数
- 关注系统效率
- 需要SJF等调度器

可能矛盾:
- RR响应快但吞吐量低(切换开销)
- SJF吞吐高但响应慢(长作业在前)

六、记忆技巧

一句话总结

调度算法就是在"公平性"和"效率"之间找平衡

对比速记表

算法等待时间吞吐量响应时间复杂度
FCFSO(n)
SJF最短O(n log n)
RRO(n)
优先级不确定不确定依赖优先级O(n log n)
MLFQ
CFSO(log n)

口诀

"批处理用SJF,交互用RR" "响应要快时间片短,吞吐要高上下文少" "高优先级防饿死,负载均衡多核跑"

七、实战检验

自检题目

题目1:有4个进程同时到达,执行时间分别为8、4、4、4。用FCFS和SJF计算平均等待时间。

点击查看答案

FCFS(按顺序执行):

顺序:P1(8) → P2(4) → P3(4) → P4(4)

等待时间:
P1: 0
P2: 8
P3: 12
P4: 16
平均等待:36/4 = 9

周转时间:
P1: 8
P2: 12
P3: 16
P4: 20
平均周转:56/4 = 14

SJF(短作业优先):

顺序:P2(4) → P3(4) → P4(4) → P1(8)

等待时间:
P2: 0
P3: 4
P4: 8
P1: 16
平均等待:28/4 = 7

SJF比FCFS减少2个单位等待时间

题目2:时间片轮转中,时间片大小对系统性能有什么影响?

点击查看答案

时间片大小的权衡:

时间片优点缺点
太小响应时间短,交互性好上下文切换开销大,吞吐量下降
太大切换开销小,吞吐高响应时间长,像FCFS

最佳选择:

  • 大多数进程是IO密集型 → 时间片可以大一些
  • 大多数进程是CPU密集型 → 时间片要小一些

经验公式:

最优时间片 ≈ 80% * 最长执行时间
或者
最优时间片 ≈ 上下文切换时间的10-100倍

面试追问预测

问题考察点进阶追问
CFS原理Linux调度器虚拟时间怎么计算
优先级反转实时调度优先级继承怎么实现
多核调度扩展性负载均衡怎么做

八、生产实战案例

案例:Nginx的连接调度

Nginx使用多种调度策略:

# upstream配置
upstream backend {
    # 默认:轮询
    server 192.168.1.1:8080;
    server 192.168.1.2:8080;
    
    # 加权轮询
    server 192.168.1.1:8080 weight=3;
    server 192.168.1.2:8080 weight=1;
    
    # 最少连接
    least_conn;
    
    # IP哈希(会话保持)
    ip_hash;
}

Nginx调度算法

  1. 轮询(Round Robin):默认,适合负载均衡
  2. 加权轮询:按权重分配请求
  3. 最少连接:分配给连接数最少的服务器
  4. IP哈希:同一IP的请求发到同一服务器

案例:Java线程池的队列选择

线程池本质上也是一种调度:

public class ThreadPoolExecutor {
    // 核心线程数
    private final int corePoolSize;
    
    // 最大线程数
    private final int maximumPoolSize;
    
    // 队列选择策略
    // 1. LinkedBlockingQueue:无界队列,可能堆积
    // 2. ArrayBlockingQueue:有界队列,拒绝策略
    // 3. SynchronousQueue:直接提交,不排队
    
    public void execute(Runnable command) {
        if (command == null) throw new NullPointerException();
        
        int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        
        // 队列选择决定调度策略
        if (isRunning(c) && workQueue.offer(command)) {
            // ...
        } else if (!addWorker(command, false)) {
            // 拒绝策略
        }
    }
}
💡

调度算法的选择,本质上是在"公平性"、"响应速度"、"吞吐量"之间做权衡。没有完美的算法,只有适合场景的算法。理解这些权衡,才能在系统设计中做出正确的选择。