队列调度算法:从FIFO到多级反馈队列

调度算法这东西,说白了就是决定「下一个该谁上」。我在做消息中间件的时候,经常要跟这些算法打交道。不同的场景,选错了调度策略,性能能差出一个数量级。今天咱们就把五种经典算法掰开揉碎讲清楚。

一、FIFO调度:最简单的,也是最容易被欺负的

FIFO(First In First Out),先来先服务。就像银行排队,谁先到谁先办。实现起来极其简单,一个队列就搞定。

// 伪代码示例
Queue taskQueue = new Queue();

void enqueue(Task t) {
    taskQueue.push(t);
}

Task dequeue() {
    return taskQueue.pop(); // 从队头取
}

但问题也很明显——一个长任务能堵死后面所有短任务。我遇到过线上事故,一个批量处理任务跑了20秒,后面几百个实时查询全部超时。这就是FIFO的「 convoy effect 」( convoy 效应)。

避坑指南:我曾经在日志处理系统里用了纯FIFO,结果一个解析大文件的慢任务导致整个链路延迟飙升。后来我加了个超时打断机制才解决。

二、优先级调度:给任务分个三六九等

优先级调度给每个任务分配一个优先级,高优先级的先执行。你想想看,实时任务肯定比后台批处理任务重要,对吧?

实现上可以用多个队列,每个优先级一个队列。或者用优先队列(堆)结构。

// 优先队列实现
PriorityQueue<Task> pq = new PriorityQueue<>(
    (a, b) -> b.priority - a.priority
);

void enqueue(Task t) {
    pq.offer(t);
}

Task dequeue() {
    return pq.poll(); // 取优先级最高的
}

但这里有个大坑——优先级反转。低优先级任务持有高优先级任务需要的锁,高优先级任务反而被阻塞。我记得在某个嵌入式项目中,一个低优先级的日志任务锁住了共享资源,导致高优先级的控制任务卡死,整个设备重启了。

我的经验:解决优先级反转,可以用优先级继承协议。就是低优先级任务在持有锁期间,临时提升到等待该锁的最高优先级任务的优先级。Linux内核里就有这个机制。

三、最短作业优先:听起来很美,做起来很难

SJF(Shortest Job First),谁执行时间短谁先上。理论上平均等待时间最短,但有个致命问题——你怎么知道任务要跑多久?

实际系统中,我们通常用「预估执行时间」。比如在数据库查询调度中,可以根据查询的复杂度、数据量来估算。但估算不准的话,效果还不如FIFO。

// 最短作业优先调度
Task selectNext(List<Task> readyQueue) {
    return readyQueue.stream()
        .min(Comparator.comparingLong(Task::getEstimatedDuration))
        .orElse(null);
}

另一个问题是饥饿——长任务可能永远得不到执行。我见过一个Hadoop集群,因为持续有短任务进来,一个需要跑2小时的大任务被饿死了整整一天。

四、时间片轮转:公平,但未必高效

RR(Round Robin),每个任务分一个时间片,轮流执行。就像自助餐厅,每个人吃30秒,然后换下一个人。

// 时间片轮转调度
int TIME_SLICE = 100; // 毫秒

void schedule(List<Task> readyQueue) {
    while (!readyQueue.isEmpty()) {
        Task current = readyQueue.remove(0);
        current.run(TIME_SLICE);
        if (!current.isFinished()) {
            readyQueue.add(current); // 放回队尾
        }
    }
}

时间片的大小很关键。设得太小,上下文切换开销太大;设得太大,就退化成FIFO了。我个人习惯是:I/O密集型的任务,时间片设小一点(10-50ms);CPU密集型的,设大一点(100-200ms)。

核心要点:时间片轮转保证了每个任务都能得到执行,不会饿死。但平均响应时间不一定最优,尤其是任务执行时间差异很大的时候。

五、多级反馈队列:集大成者

MLFQ(Multi-Level Feedback Queue),这是目前操作系统和很多中间件里最常用的调度算法。它结合了前面几种算法的优点。

核心思想:

  • 设置多个优先级队列,每个队列有自己的时间片
  • 高优先级队列时间片短,低优先级队列时间片长
  • 新任务先进入最高优先级队列
  • 任务用完时间片还没完成,就降级到下一级队列
  • 高优先级队列不空,就不执行低优先级队列
// 多级反馈队列简化实现
class MLFQ {
    Queue<Task>[] queues = new Queue[3]; // 3级队列
    int[] timeSlices = {50, 100, 200};   // 每级时间片
    
    void enqueue(Task t) {
        queues[0].add(t); // 新任务进最高级
    }
    
    Task dequeue() {
        for (int i = 0; i < 3; i++) {
            if (!queues[i].isEmpty()) {
                Task t = queues[i].poll();
                t.level = i;
                return t;
            }
        }
        return null;
    }
    
    void onTimeSliceEnd(Task t) {
        if (!t.isFinished()) {
            int nextLevel = Math.min(t.level + 1, 2);
            queues[nextLevel].add(t);
        }
    }
}

这个算法妙在哪里?短任务在高优先级队列里很快就能完成,长任务虽然降级了,但总能得到执行。既保证了响应速度,又避免了饥饿。

实战经验:我在设计一个RPC框架的线程池调度器时,就用了MLFQ。把实时性要求高的请求放高优先级队列,批量查询放低优先级队列。效果非常好,99分位延迟降低了60%。

六、五种算法对比

算法 优点 缺点 适用场景
FIFO 实现简单,公平 长任务阻塞短任务 任务执行时间相近的场景
优先级调度 区分任务重要性 优先级反转、饥饿 实时系统、有明确优先级
最短作业优先 平均等待时间最短 需要预估时间、长任务饥饿 任务时间可预估的场景
时间片轮转 公平、无饥饿 上下文切换开销 交互式系统、时间敏感
多级反馈队列 兼顾响应和吞吐 实现复杂、参数调优难 通用操作系统、中间件

七、调度算法的选择策略

没有银弹。我这些年总结的经验是:

  • 任务类型单一:FIFO就够了,别折腾
  • 有明确的优先级需求:优先级调度 + 优先级继承
  • 交互式系统:时间片轮转或MLFQ
  • 批处理系统:SJF或FIFO
  • 混合负载:MLFQ是首选
记住:调度算法的选择,本质上是在「公平性」、「响应时间」、「吞吐量」、「实现复杂度」之间做权衡。没有完美的算法,只有最适合你场景的算法。

嗯,五种调度算法就讲到这里。每种算法都有它的脾气,选对了事半功倍,选错了...嗯,你懂的。

可可精品资料库