调度
调度系统是进程管理的核心组件,负责决定哪个进程在何时运行,以及运行多长时间。
调度的目标有
- 高效性,调度算法高效快速,开销小
- 实时性,对于一些时间敏感的任务,需要能够在要求的时间内完成
- 公平性,不同的任务都能够获得调度,并且得到应有的执行时间
在不同的使用场景下,调度的目标优先级会有所不同,但是主要矛盾是实时性和公平性之间的矛盾,因为重要的进程它需要特权和优先,但是公平要求的是每个进程都能够得到充分的执行。linux 中采用多级优先级处理器来缓解实时性和公平性之间的矛盾。
任务
任务是调度的基本单位,本质上是进程或线程(在 Linux 内核中,线程和进程本质相同,都是 task_struct。线程在 linux 中本质上是共享同一组资源的进程,从内核层面上,线程和进程的并没有什么太大的不同。这一点和其他系统例如 Windows 有显著的不同。这是一种巧妙的实现思路,使得进程的系统调用 API 相较于 Windows 等系统显得非常的简洁,同时在性能角度考虑也更加的轻量。
调度器
调度器是决定哪个任务获得 CPU 的组件。Linux 内核支持多种调度器,每种调度器针对不同的应用场景设计,使用不同的调度策略和算法。Linux 内核中的调度器按调度类(Scheduling Class)组织,主要包括:
- CFS 调度器:用于普通进程,追求公平性,基于虚拟运行时间,使用红黑树组织就绪队列
- 实时调度器:用于实时进程(SCHED_FIFO/SCHED_RR),基于优先级抢占,保证实时性要求
- 截止时间调度器:用于 SCHED_DEADLINE 策略,基于任务截止时间调度,适合硬实时应用
- 空闲调度器:当系统无其他可运行任务时使用,执行 CPU 空闲循环
CFS 调度器
CFS(完全公平调度器)是 Linux 默认的普通进程调度器,其核心思想是通过时间记账,让运行时间最少的任务先执行,从而让每个进程获得公平的 CPU 时间。
核心概念:
- 虚拟运行时间(vruntime):记录每个进程已经运行的虚拟运行时间
- 调度延迟(sched_latency):所有可运行进程应该在一个调度延迟内至少运行一次
- 最小粒度(min_granularity):进程的最小运行时间片
struct sched_entity {
struct load_weight load; // 进程权重
struct rb_node run_node; // 红黑树节点
u64 vruntime; // 虚拟运行时间
u64 sum_exec_runtime; // 实际运行时间
// ... 其他字段
};调度算法:
- 选择虚拟运行时间 vruntime 最小的进程运行
- 进程运行时,其虚拟运行时间按权重递增
- 当进程的虚拟运行时间超过其他进程时,发生调度
权重计算时使用 nice 来计算权重,用户态设置的进程优先级指标,范围从 -20(最高优先级)到 19(最低优先级),默认值为 0。nice 值通过影响进程的调度权重(weight),决定 CPU 时间分配比例。nice 值每降低 1,权重增加约 10%,高权重进程获得更多 CPU 时间。
实时调度器
实时调度器用于满足实时性要求的进程,支持两种调度策略:
SCHED_FIFO(先进先出):
- 高优先级进程可以抢占低优先级进程
- 同优先级进程按 FIFO 顺序运行
- 进程会一直运行直到主动让出 CPU 或被更高优先级进程抢占
SCHED_RR(轮转调度):
- 类似 SCHED_FIFO,但同优先级进程按时间片轮转
- 每个进程运行一个时间片后被放到队列末尾
- 时间片长度可配置
struct rt_rq {
struct rt_prio_array active; // 活跃优先级数组
struct rt_prio_array expired; // 过期优先级数组
int rt_nr_running; // 运行中的实时进程数
};截止时间调度器
截止时间调度器基于任务的截止时间进行调度,适合硬实时应用。
核心概念:
- 运行时间(runtime):任务需要的 CPU 时间
- 截止时间(deadline):任务必须完成的时间点
- 周期(period):任务的执行周期
调度算法:
- 选择截止时间最早的任务运行
- 任务运行时消耗其运行时间配额
- 当运行时间用完时,任务被挂起直到下一个周期
struct sched_dl_entity {
struct rb_node rb_node; // 红黑树节点
u64 dl_runtime; // 运行时间
u64 dl_deadline; // 截止时间
u64 dl_period; // 周期
u64 dl_bw; // 带宽
};调度器选择
Linux 使用调度类(Scheduling Class)来组织不同的调度器,每个调度类都有不同的优先级:
// 调度类优先级(数字越小优先级越高)
#define SCHED_CLASS_DEADLINE 0 // 截止时间调度器
#define SCHED_CLASS_RT 1 // 实时调度器
#define SCHED_CLASS_FAIR 2 // CFS调度器
#define SCHED_CLASS_IDLE 3 // 空闲调度器调度选择流程:
- 从最高优先级的调度类开始检查
- 如果该调度类有可运行任务,选择该调度类的任务
- 否则检查下一个优先级的调度类
- 重复直到找到可运行任务
调度流程
调度流程是调度器工作的核心机制,包括调度时机、调度决策、上下文切换等关键步骤。
调度时机
调度可能发生在以下时机:
- 主动调度/进程主动让出 CPU
- 进程调用
sched_yield()主动让出 CPU - 进程进入阻塞状态(等待 I/O、信号量、事件等)
- 进程调用
- 进程退出(正常、异常、信号终止)c
// 主动让出 CPU #include <sched.h> int sched_yield(void); // 进程阻塞示例 int fd = open("file.txt", O_RDONLY); char buffer[1024]; // 如果文件没有数据可读,进程会阻塞 ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
被动调度/时钟中断触发
- 时间片耗尽(CFS 中为虚拟运行时间超过阈值)
- 统计信息更新
- 调度检查
抢占调度/优先级抢占
- 高优先级进程变为可运行状态
- 实时进程抢占普通进程
- 内核抢占(如果启用,需要内核编译时启用
CONFIG_PREEMPT)
c// 设置进程优先级 #include <sched.h> struct sched_param param; param.sched_priority = 50; // 实时优先级 sched_setscheduler(0, SCHED_FIFO, ¶m);
调度决策过程
总体先选调度器,然后让调度器选进程,然后分时间片大小。
调度器选择
Linux 使用调度类优先级来选择调度器:c// 调度类优先级(数字越小优先级越高) #define SCHED_CLASS_DEADLINE 0 // 截止时间调度器 #define SCHED_CLASS_RT 1 // 实时调度器 #define SCHED_CLASS_FAIR 2 // CFS调度器 #define SCHED_CLASS_IDLE 3 // 空闲调度器- 从最高优先级的调度类开始检查
- 如果该调度类有可运行任务,选择该调度类的任务
- 否则检查下一个优先级的调度类
- 重复直到找到可运行任务
进程选择
进入不同的调度器之后,不同的调度器使用自身的策略来选择要运行的进程- CFS 进程选择
c// CFS 选择虚拟运行时间最小的进程 struct task_struct *pick_next_task_fair(struct rq *rq) { struct sched_entity *se; struct rb_node *left; // 从红黑树最左节点选择(vruntime 最小) left = rb_first_cached(&rq->cfs.tasks_timeline); se = rb_entry(left, struct sched_entity, run_node); return task_of(se); }- 实时进程选择
c// 实时调度器选择最高优先级进程 struct task_struct *pick_next_task_rt(struct rq *rq) { struct rt_rq *rt_rq = &rq->rt; struct rt_prio_array *array; // 从活跃数组中查找最高优先级进程 array = &rt_rq->active; return array->queue[array->highest_prio]; }时间片分配
不同的调度器使用各自的时间片分配规则。CFS 时间片计算:
c// 更新当前进程的运行时间 void update_curr_fair(struct rq *rq) { struct sched_entity *curr = &rq->curr->se; u64 now = rq_clock_task(rq); u64 delta_exec = now - curr->exec_start; // 更新实际运行时间 curr->sum_exec_runtime += delta_exec; // 更新虚拟运行时间(考虑权重) curr->vruntime += calc_delta_fair(delta_exec, curr); curr->exec_start = now; }上下文切换
在选择好调度的参数的参数,正式开始进行调度的动作,主要包括保存当前进程状态和恢复目标进程状态。- 保存当前进程的执行状态,包括通用寄存器、浮点寄存器、程序计数器(PC)、栈指针(SP)以及页表基地址等关键信息到进程控制块 task_struct 中,确保下次调度时能够准确恢复进程的执行环境。
- 恢复目标进程的执行状态,将目标进程控制块中保存的寄存器状态加载到 CPU 中,切换页表以访问目标进程的内存空间,并恢复执行上下文,使目标进程能够从上次中断的地方继续执行。
c// 简化的上下文切换伪代码 void context_switch(struct task_struct *prev, struct task_struct *next) { struct mm_struct *mm, *oldmm; // 切换内存管理上下文 mm = next->mm; oldmm = prev->active_mm; if (mm != oldmm) { switch_mm(oldmm, mm, next); } // 切换寄存器上下文 switch_to(prev, next, prev); }上下文切换开销主要包括寄存器保存/恢复(~100-200个时钟周期)、页表切换(~1000-2000个时钟周期)、缓存失效和 TLB 失效,可通过延迟 TLB 失效、缓存亲和性优化和进程迁移限制等策略进行优化。
调度优化策略
Linux 内核采用一些常见的优化手段来确保调度的优化。
负载均衡
多核负载均衡通过进程迁移机制实现,当检测到某个 CPU 负载过重时,调度器会将进程从负载重的 CPU 迁移到负载较轻的 CPU 上。负载计算主要基于运行队列长度和进程权重进行,同时设置迁移阈值来避免频繁迁移造成的性能开销。
// 负载均衡触发条件
static inline bool should_we_balance(struct lb_env *env)
{
struct sched_domain *sd = env->sd;
// 检查负载差异是否超过阈值
return env->imbalance > sd->imbalance_pct;
}多核负载需要考虑 NUMA 架构的影响,通过 NUMA 感知,调度通过优先在本地 NUMA 节点进行进程调度,同时考虑内存访问延迟的影响,并尽量避免跨节点的进程迁移,从而减少内存访问开销并提高系统整体性能。
缓存优化
缓存亲和性是指进程优先在之前运行的 CPU 上执行,通过减少缓存失效来提高内存访问效率的优化策略。
CPU 亲和性设置:
#include <sched.h>
// 设置进程的 CPU 亲和性
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(0, &cpuset); // 绑定到 CPU 0
sched_setaffinity(0, sizeof(cpuset), &cpuset);实时性优化
实时进程配置:
// 设置实时调度策略
struct sched_param param;
param.sched_priority = 99; // 最高实时优先级
sched_setscheduler(0, SCHED_FIFO, ¶m);
// 设置 CPU 隔离(避免其他进程干扰)
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(1, &cpuset); // 绑定到隔离的 CPU
sched_setaffinity(0, sizeof(cpuset), &cpuset);调度问题诊断
在高度性能敏感的开发中,需要调优系统调度器,从而优化调度性能。
常见问题
- 进程饥饿是指低优先级进程长时间无法获得 CPU 执行时间的问题。
当系统中存在大量高优先级进程时,低优先级进程可能会被无限期延迟,导致其任务无法及时完成。为了解决这个问题,调度器通常会采用动态优先级调整机制,随着等待时间的增加逐步提升进程优先级,或者通过时间片补偿的方式确保所有进程都能获得基本的执行机会。 - 优先级反转是另一个常见的调度问题,发生在低优先级进程持有高优先级进程所需资源的情况下。
当高优先级进程等待低优先级进程释放资源时,中等优先级的进程可能会抢占 CPU,导致高优先级进程被进一步延迟。为了解决这个问题,系统通常采用优先级继承机制,让持有资源的低优先级进程临时继承等待进程的优先级,或者使用优先级天花板技术,预先设置资源访问的最高优先级。 - 调度延迟过高是指进程从就绪状态到实际开始运行的时间间隔过长,这通常是由于系统负载过高或调度器配置不当导致的。
当系统中运行进程过多时,调度器需要更多时间来做出调度决策,同时频繁的上下文切换也会增加调度开销。为了诊断和解决这个问题,系统管理员需要监控调度延迟指标,优化调度器参数配置,并在必要时进行负载均衡或资源扩容。
调度统计与监控
内核在调度过程中会执行调度数据统计,开发者可以通过系统接口访问调度数据。
struct sched_statistics {
u64 wait_start; // 等待开始时间
u64 wait_max; // 最大等待时间
u64 wait_count; // 等待次数
u64 wait_sum; // 等待时间总和
u64 iowait_count; // IO 等待次数
u64 iowait_sum; // IO 等待时间总和
u64 sleep_start; // 睡眠开始时间
u64 sleep_max; // 最大睡眠时间
u64 sum_sleep_runtime; // 睡眠时间总和
u64 block_start; // 阻塞开始时间
u64 block_max; // 最大阻塞时间
u64 exec_max; // 最大执行时间
u64 slice_max; // 最大时间片
u64 nr_migrations_cold; // 冷迁移次数
// ...
};诊断工具
内核态监控接口:
// 获取进程调度统计
struct sched_statistics *stats = &task->se.statistics;
// 获取运行队列统计
struct rq *rq = cpu_rq(cpu);
u64 nr_running = rq->nr_running;
u64 load = rq->load.weight;用户态监控数据接口:
# 查看进程调度信息
cat /proc/[pid]/schedstat
# 查看调度器统计
cat /proc/schedstat
cat /proc/schedstat | grep "cpu"
# 查看 CPU 调度信息
cat /proc/sched_debug
# 监控调度延迟
cat /proc/sched_debug | grep "avg_delay"
# 使用 ftrace 跟踪调度事件
echo function_graph > /sys/kernel/debug/tracing/current_tracer
echo sched > /sys/kernel/debug/tracing/trace_options
# 使用 perf 分析调度性能
perf record -e sched:sched_switch -g -p [pid]
perf report