mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5814 字
16 分钟
进程调度
2024-07-03

在上一章中,我们深入剖析了进程的创建、状态转换与生命周期管理。但一个关键问题尚未回答:当一个进程处于 TASK_RUNNING 状态时,它何时能获得 CPU?运行多久来决定下一个运行的是谁?这些问题的答案,就藏在 Linux 的进程调度器中。

调度器是内核中最核心的子系统之一——它直接决定了系统的响应速度、吞吐量和公平性。一个设计糟糕的调度器,会让桌面卡顿、服务器吞吐量骤降、实时任务错过截止时间。Linux 的调度器经历了从 O(1) 到 CFS 再到 EEVDF 的漫长演进,每一次迭代都折射出对”调度本质问题”的更深层理解。

本章将从调度的根本矛盾出发,带你逐层拆解 Linux 调度器的设计哲学、核心数据结构与算法实现。

一、调度的本质问题:公平性 vs 响应性 vs 吞吐量#

在深入 Linux 调度器的实现之前,需要先理解调度器面临的根本矛盾。任何调度器都无法同时最优化以下三个目标:

公平性(Fairness):每个进程应该获得公平的 CPU 时间份额。一个低优先级的批处理任务不应该被完全饿死,一个高优先级的交互进程也不应该独占 CPU。公平性保证了多用户、多任务环境下的资源分配正义。

响应性(Responsiveness):交互式任务(如键盘输入、鼠标点击)需要极低的延迟。用户按下按键后,系统应该在毫秒级内响应,否则用户会感知到明显的”卡顿”。响应性要求调度器能快速将 CPU 分配给刚被唤醒的交互进程。

吞吐量(Throughput):系统在单位时间内完成的总工作量。批处理任务(如编译内核、视频编码)希望尽可能少地被中断,因为每次上下文切换都有开销——寄存器保存/恢复、TLB 刷新、缓存失效。吞吐量要求调度器减少切换次数,让进程运行更长的时间片。

这三个目标之间存在深刻的张力:

  • 公平性 vs 吞吐量:越公平地分配 CPU,就需要越频繁地切换进程,上下文切换开销越大,吞吐量越低。
  • 响应性 vs 吞吐量:为了快速响应交互任务,需要抢占当前运行的批处理任务,但这会打断其缓存热状态,降低吞吐量。
  • 公平性 vs 响应性:严格公平意味着交互进程不能获得额外优待,但交互场景恰恰需要优先调度。
Note

操作系统教科书通常将调度目标分为面向用户的指标(响应时间、公平性)和面向系统的指标(CPU 利用率、吞吐量)。Linux 调度器的设计哲学是:在保证公平性的前提下,通过启发式方法提升交互响应,而不是牺牲公平性来换取响应性。

Linux 的 CFS 调度器选择了一条独特的道路:以公平性为第一原则,通过虚拟运行时间(vruntime)实现精确的公平分配,同时利用唤醒抢占、最小粒度等机制兼顾响应性。这种设计哲学与传统的优先级驱动调度器(如 FreeBSD 的 ULE、Windows 的多级反馈队列)形成了鲜明对比。

二、Linux 调度器演进史:O(1) → CFS → EEVDF#

Linux 调度器并非一蹴而就,它经历了多次重大重构,每次演进都解决了前一代的核心缺陷。

2.1 O(1) 调度器(2.4 - 2.6 早期)#

O(1) 调度器由 Ingo Molnar 在 2.4 内核中引入,其核心设计是优先级数组 + 活跃/过期双队列

  • 维护 140 个优先级队列(0-139,0 最高),每个优先级对应一个链表
  • 活跃队列(active)存放有时间片剩余的进程,过期队列(expired)存放时间片耗尽的进程
  • 选择下一个进程只需找到活跃队列中最高优先级的非空链表,时间复杂度 O(1)
  • 时间片耗尽后,进程从活跃队列移到过期队列;活跃队列为空时,交换两个队列的角色

O(1) 调度器的致命问题在于交互进程的启发式检测:它通过分析进程的睡眠时间来猜测”这是否是交互进程”,并给予额外优待。这套启发式规则极其复杂,容易出错,且在特定负载下会导致严重的公平性问题——一个”伪装成交互进程”的批处理任务可以获取远超公平份额的 CPU 时间。

2.2 CFS 完全公平调度器(2.6.23 - 6.5)#

2007 年,Ingo Molnar 用 CFS(Completely Fair Scheduler)彻底替换了 O(1) 调度器。CFS 的设计灵感来自理想公平调度器的理论模型:

如果有一个无限精确的处理器,可以在无穷小的时间片内将 CPU 同时分配给所有 runnable 进程,那么每个进程都能获得完全公平的 CPU 份额。

CFS 不会去猜测”谁是交互进程”,而是通过**虚拟运行时间(vruntime)**精确追踪每个进程获得的 CPU 时间,并始终选择 vruntime 最小的进程运行——这就是”完全公平”的含义。

CFS 的核心优势:

  1. 消除启发式:不再需要复杂的交互进程检测逻辑
  2. 理论可证明的公平性:vruntime 的差异在数学上有界
  3. 简洁优雅:核心逻辑不到 1000 行代码(kernel/sched/fair.c

2.3 EEVDF 调度器(6.6+)#

2023 年,Peter Zijlstra 提出了 EEVDF(Earliest Eligible Virtual Deadline First)调度器,在 6.6 内核中作为默认调度器替代 CFS。EEVDF 解决了 CFS 的几个长期痛点:

  • 延迟可预测性:CFS 的唤醒抢占可能导致延迟不可预测,EEVDF 通过虚拟截止时间(virtual deadline)提供有界的调度延迟
  • 更好的实时行为:EEVDF 的 Eligibility 机制可以更精确地控制进程的调度时机
  • 权重与带宽的统一:EEVDF 将 nice 值的语义从”权重比”扩展为”带宽比”,更符合直觉
Tip

EEVDF 是 CFS 的演进而非革命——它保留了 vruntime 和红黑树的核心设计,但改变了选择下一个进程的策略。理解 CFS 是理解 EEVDF 的前提。本文以 CFS 为主进行讲解,在关键差异处会标注 EEVDF 的变化。

三、CFS 核心设计:虚拟运行时间与红黑树#

3.1 sched_entity:调度的基本单位#

在 CFS 中,调度的基本单位不是 task_struct,而是 sched_entity。这个设计使得 CFS 可以同时调度单个进程和进程组(cgroup),实现了调度的层级化:

include/linux/sched.h
struct sched_entity {
struct load_weight load; // 权重,由 nice 值决定
struct rb_node run_node; // 红黑树节点
unsigned int on_rq; // 是否在运行队列中
u64 vruntime; // 虚拟运行时间(核心字段!)
u64 sum_exec_runtime; // 实际运行总时间
u64 prev_sum_exec_runtime; // 上次迁移时的实际运行时间
// ...
};

每个 task_struct 内嵌了一个 sched_entity

struct task_struct {
// ...
const struct sched_class *sched_class; // 调度类
struct sched_entity se; // CFS 调度实体
struct sched_rt_entity rt; // 实时调度实体
struct sched_dl_entity dl; // Deadline 调度实体
int prio; // 动态优先级
// ...
};

3.2 vruntime:虚拟运行时间#

vruntime 是 CFS 的灵魂。它的含义是:考虑权重后,进程获得的”虚拟”CPU 时间

对于一个 nice 值为 0(权重 1024)的进程,vruntime 的增长速度等于实际运行时间。对于更高优先级的进程(nice < 0,权重 > 1024),vruntime 增长更慢,因此在红黑树中会靠左,更容易被选中;对于更低优先级的进程(nice > 0,权重 < 1024),vruntime 增长更快,在红黑树中会靠右,被选中的机会更少。

vruntime 的更新发生在每次时钟中断和上下文切换时:

// kernel/sched/fair.c(简化)
static void update_curr(struct cfs_rq *cfs_rq)
{
struct sched_entity *curr = cfs_rq->curr;
u64 now = rq_clock_task(rq_of(cfs_rq));
u64 delta_exec;
delta_exec = now - curr->exec_start; // 本次运行的实际时间
curr->sum_exec_runtime += delta_exec; // 累加实际运行时间
// 核心公式:vruntime 增量 = 实际时间 × (NICE_0_LOAD / 权重)
curr->vruntime += calc_delta_fair(delta_exec, curr);
}

calc_delta_fair 的核心计算:

static u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
if (unlikely(se->load.weight != NICE_0_LOAD))
delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
return delta;
}

当权重等于 NICE_0_LOAD(1024)时,vruntime 增量等于实际时间增量;否则按比例缩放。

3.3 红黑树:O(log n) 的进程选择#

CFS 使用红黑树来组织所有 runnable 进程,以 vruntime 为键值:

  • 插入:进程变为 runnable 时,以其 vruntime 为键插入红黑树,O(log n)
  • 选择:取红黑树最左节点(vruntime 最小),O(1)
  • 删除:进程阻塞或被抢占时,从红黑树中删除,O(log n)
graph TD subgraph CFS红黑树 A["vruntime=50<br/>进程 A<br/>nice=0"] --> B["vruntime=30<br/>进程 B<br/>nice=-5"] A --> C["vruntime=80<br/>进程 C<br/>nice=5"] B --> D["vruntime=10<br/>进程 D<br/>nice=0 最左"] B --> E["vruntime=45<br/>进程 E<br/>nice=0"] end style D fill:#4ade80,stroke:#166534,color:#000

上图中,进程 D 的 vruntime 最小(10),位于红黑树最左节点,CFS 将选择它作为下一个运行的进程。进程 B 的 nice=-5(高优先级),其 vruntime 增长较慢,因此更容易保持在树的左侧。

3.4 cfs_rq:CFS 运行队列#

每个 CPU 都有自己的运行队列 rq,其中包含一个 CFS 运行队列 cfs_rq

kernel/sched/fair.c
struct cfs_rq {
struct rb_root_cached tasks_timeline; // 红黑树根节点(含最左节点缓存)
unsigned int nr_running; // 队列中的进程数
u64 min_vruntime; // 队列的最小虚拟运行时间
struct sched_entity *curr; // 当前正在运行的进程
struct sched_entity *next; // 下一个待运行的进程(缓存提示)
u64 exec_clock; // 执行时间时钟
// ...
};

rb_root_cached 是关键优化——它在红黑树根节点之外额外缓存了最左节点指针,使得”选择 vruntime 最小的进程”这一最频繁的操作从 O(log n) 降为 O(1)。

四、CFS 的公平性保证:min_vruntime、时间片与权重#

4.1 min_vruntime:防止新进程饿死老进程#

min_vruntime 是 CFS 运行队列的单调递增计数器,它追踪队列中所有进程 vruntime 的最小值。它的核心作用是防止新创建或新唤醒的进程通过设置极小的 vruntime 来抢占大量 CPU 时间

当一个新进程加入 CFS 运行队列时,其初始 vruntime 不是 0,而是设置为 min_vruntime

// kernel/sched/fair.c(简化)
static void place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)
{
u64 vruntime = cfs_rq->min_vruntime;
if (initial) {
// 新创建的进程:给一点"优惠",但不会太多
vruntime += sched_vslice(cfs_rq, se);
} else {
// 唤醒的进程:给少量优惠以提升响应性
vruntime -= sysctl_sched_wakeup_granularity;
if (vruntime < cfs_rq->min_vruntime)
vruntime = cfs_rq->min_vruntime;
}
se->vruntime = max(se->vruntime, vruntime);
}

min_vruntime 的更新规则:

static void update_min_vruntime(struct cfs_rq *cfs_rq)
{
u64 vruntime = cfs_rq->min_vruntime;
if (cfs_rq->curr)
vruntime = cfs_rq->curr->vruntime;
if (cfs_rq->rb_leftmost) {
struct sched_entity *se = __node_2_se(cfs_rq->rb_leftmost);
vruntime = min(vruntime, se->vruntime);
}
// 单调递增:只增不减
cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime);
}

4.2 时间片计算:sched_period 与 sched_slice#

CFS 没有传统意义上的”固定时间片”。它使用**调度周期(sched_period)**的概念:在一个调度周期内,所有 runnable 进程都至少运行一次。

调度周期的计算:

kernel/sched/fair.c
static u64 __sched_period(unsigned long nr_running)
{
if (unlikely(nr_running > sched_nr_latency))
return nr_running * sysctl_sched_min_granularity;
else
return sysctl_sched_latency;
}
  • 当 runnable 进程数 ≤ sched_nr_latency(默认 8)时,调度周期 = sched_sched_latency(默认 6ms)
  • 当 runnable 进程数 > sched_nr_latency 时,调度周期 = 进程数 × sched_min_granularity(默认 0.75ms)

每个进程的时间片则按权重比例分配:

static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
unsigned int nr_running = cfs_rq->nr_running;
u64 slice;
slice = __sched_period(nr_running + !!se->on_rq);
// 按权重比例分配
slice = __calc_delta(slice, se->load.weight, &cfs_rq->load);
return max_t(u64, slice, sysctl_sched_min_granularity);
}
Important

CFS 的时间片不是固定的——它取决于当前 runnable 进程的数量和各进程的权重。进程越多,每个进程分到的时间片越短;权重越高(nice 值越低),分到的时间片越长。

4.3 nice 值与权重的关系#

nice 值的范围是 -20 到 19,默认 0。nice 值与权重的映射关系定义在 prio_to_weight 数组中:

kernel/sched/core.c
const int sched_prio_to_weight[40] = {
/* -20 */ 88, 110, 138, 172, 215,
/* -16 */ 270, 339, 425, 534, 671,
/* -11 */ 842, 1058, 1331, 1673, 2102,
/* -6 */ 2641, 3315, 4162, 5227, 6568,
/* -1 */ 8192, 10240, 12800, 16000, 20000,
/* +4 */ 25000, 31250, 39062, 48828, 61035,
/* +9 */ 76294, 95367, 119209, 149012, 186264,
/* +14 */ 232830, 291038, 363798, 454747, 568434,
/* +19 */ 710551,
};

关键规律:nice 值每降低 1,权重增加约 10%。这意味着:

  • nice 值差 1 的两个进程,CPU 时间比约为 1.1 : 1
  • nice 值差 5 的两个进程,CPU 时间比约为 1.6 : 1
  • nice 值差 10 的两个进程,CPU 时间比约为 2.5 : 1
  • nice 值差 19 的两个进程,CPU 时间比约为 8092 : 1
Caution

nice 值的”1”并不代表 1% 的差异!nice 值差 1 ≈ 10% 的 CPU 时间差异,差 5 ≈ 60% 的差异。很多初学者误以为 nice +1 只减少 1% 的 CPU 时间,这是错误的。

五、调度类:分层的调度策略#

Linux 调度器采用**调度类(scheduling class)**的分层架构,每个调度类封装了独立的调度策略和实现。调度类按优先级从高到低排列:

graph BT subgraph 调度类层次 IDLE["idle_sched_class<br/>SCHED_IDLE<br/>最低优先级<br/>只在 CPU 空闲时运行"] FAIR["fair_sched_class<br/>SCHED_NORMAL / SCHED_BATCH<br/>CFS 完全公平调度"] RT["rt_sched_class<br/>SCHED_FIFO / SCHED_RR<br/>实时调度,优先级 1-99"] DL["dl_sched_class<br/>SCHED_DEADLINE<br/>EDF 截止时间调度"] STOP["stop_sched_class<br/>内部使用<br/>最高优先级,不可抢占"] end IDLE --> FAIR --> RT --> DL --> STOP style STOP fill:#ef4444,stroke:#991b1b,color:#fff style DL fill:#f97316,stroke:#9a3412,color:#fff style RT fill:#eab308,stroke:#854d0e,color:#000 style FAIR fill:#22c55e,stroke:#166534,color:#fff style IDLE fill:#94a3b8,stroke:#475569,color:#fff

调度类的核心接口定义在 sched_class 结构体中:

include/linux/sched.h
struct sched_class {
const struct sched_class *next; // 下一个更低优先级的调度类
void (*enqueue_task)(struct rq *rq, struct task_struct *p, int flags);
void (*dequeue_task)(struct rq *rq, struct task_struct *p, int flags);
struct task_struct *(*pick_next_task)(struct rq *rq);
void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);
void (*put_prev_task)(struct rq *rq, struct task_struct *p);
void (*set_curr_task)(struct rq *rq);
void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
// ...
};

调度类的选择逻辑:当需要选择下一个运行的进程时,内核从最高优先级的调度类开始,依次调用 pick_next_task()。如果当前调度类没有 runnable 进程,则尝试下一个调度类。这意味着:

  1. stop 类的进程永远优先(用于内核内部停机操作,如 CPU 热插拔)
  2. deadline 类的进程优先于所有实时和普通进程
  3. rt 类的进程优先于所有普通进程
  4. fair 类的进程在无实时任务时才运行
  5. idle 类的进程只在 CPU 完全空闲时运行
Note

stop_sched_class 不对用户空间暴露,仅用于内核内部的”停机”操作(如 CPU 热插拔、RCU 同步等)。它运行一个迁移线程(migration/X),优先级高于一切,且不可被抢占。

六、实时调度:SCHED_FIFO 与 SCHED_RR#

实时调度策略用于有严格时序要求的任务,如音频处理、工业控制、高频交易。Linux 提供了两种 POSIX 实时调度策略:

SCHED_FIFO:先进先出#

SCHED_FIFO 的规则非常简单:

  1. 实时优先级范围 1-99,数值越大优先级越高
  2. 同优先级的进程按 FIFO 顺序运行
  3. SCHED_FIFO 进程没有时间片——它会一直运行,直到:
    • 主动调用 sched_yield() 让出 CPU
    • 阻塞在 I/O 操作上(如等待锁、读磁盘)
    • 被更高优先级的实时进程抢占
    • 调用 sched_setscheduler() 改变调度策略
// 设置 SCHED_FIFO 调度策略
struct sched_param param = { .sched_priority = 50 }; // 实时优先级 50
sched_setscheduler(getpid(), SCHED_FIFO, &param);

SCHED_RR:时间片轮转#

SCHED_RR 与 SCHED_FIFO 的唯一区别是:同优先级的 SCHED_RR 进程按时间片轮转。时间片长度可通过 /proc/sys/kernel/sched_rr_timeslice_ms 查看和设置(默认 100ms):

# 查看 SCHED_RR 时间片
cat /proc/sys/kernel/sched_rr_timeslice_ms
# 输出:100
# 设置为 50ms
echo 50 | sudo tee /proc/sys/kernel/sched_rr_timeslice_ms

实时调度的危险性#

Warning

SCHED_FIFO 进程如果不主动让出 CPU,会永久霸占该 CPU,导致系统完全无响应!这是实时调度最常见的”自杀式”用法。

Linux 通过 /proc/sys/kernel/sched_rt_runtime_us/proc/sys/kernel/sched_rt_period_us 两个参数限制实时任务的 CPU 占用:

# 默认配置:在 1 秒周期内,实时任务最多占用 0.95 秒
cat /proc/sys/kernel/sched_rt_period_us # 1000000 (1秒)
cat /proc/sys/kernel/ssched_rt_runtime_us # 950000 (0.95秒)

这保证了即使实时任务全速运行,也有 5% 的 CPU 时间留给普通进程,避免系统完全锁死。

SCHED_DEADLINE:截止时间调度#

SCHED_DEADLINE 是三种实时策略中最强的一种,基于 EDF(Earliest Deadline First)算法。它需要指定三个参数:

  • runtime:在一个周期内需要的最大 CPU 时间(微秒)
  • period:任务周期(微秒)
  • deadline:绝对截止时间(通常等于 period)
// 设置 SCHED_DEADLINE
struct sched_attr attr = {
.size = sizeof(attr),
.sched_policy = SCHED_DEADLINE,
.sched_runtime = 10 * 1000000, // 10ms 运行时间
.sched_deadline = 30 * 1000000, // 30ms 截止时间
.sched_period = 30 * 1000000, // 30ms 周期
};
sched_setattr(getpid(), &attr, 0);

SCHED_DEADLINE 的带宽保证:只要所有 deadline 任务的带宽之和不超过 100%(即 Σ(runtime/period) ≤ 1),内核就能保证每个任务在其截止时间前获得足够的 CPU 时间。

七、调度时机:何时发生调度#

调度不是随时都能发生的——它需要特定的触发条件。Linux 调度的触发时机可以分为以下几类:

1. 时钟中断(主动抢占)#

时钟中断是调度器最核心的驱动力。每个时钟 tick(通常 1ms 或 4ms),内核会调用 scheduler_tick()

kernel/sched/core.c
void scheduler_tick(void)
{
int cpu = smp_processor_id();
struct rq *rq = cpu_rq(cpu);
struct task_struct *curr = rq->curr;
update_rq_clock(rq);
curr->sched_class->task_tick(rq, curr, 0); // 调用当前调度类的 tick 处理
// 检查是否需要重新调度
if (test_tsk_need_resched(curr))
set_tsk_need_resched(curr);
// ...
}

对于 CFS,task_tick_fair() 会检查当前进程的运行时间是否超过其应得的时间片,如果超过则设置 TIF_NEED_RESCHED 标志。

2. 自愿让出(主动调度)#

进程主动调用以下函数让出 CPU:

  • schedule() — 直接让出 CPU,等待下次被调度
  • sched_yield() — 让出 CPU,将自己重新加入运行队列
  • 阻塞操作 — 如 mutex_lock()wait_event()read() 等会间接调用 schedule()

3. 唤醒抢占(被动抢占)#

当一个高优先级进程被唤醒时(如 I/O 完成、信号到达),内核会检查是否应该抢占当前运行的低优先级进程:

kernel/sched/core.c
static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags)
{
struct task_struct *curr = rq->curr;
// ...
if (curr->sched_class != p->sched_class) {
// 不同调度类:高优先级调度类总是抢占低优先级
if (p->sched_class > curr->sched_class)
resched_curr(rq);
return;
}
// 同调度类:调用具体调度类的 check_preempt 方法
curr->sched_class->check_preempt_curr(rq, p, wake_flags);
}

4. 抢占检查点#

即使设置了 TIF_NEED_RESCHED,调度也不会立即发生——它要等到抢占检查点

  • 用户抢占:从内核态返回用户态时(系统调用返回、中断返回),检查 TIF_NEED_RESCHED
  • 内核抢占:如果启用了 CONFIG_PREEMPT,在内核代码的抢占安全点也会检查
// arch/x86/entry/entry_64.S(简化)
// 从内核态返回用户态时的抢占检查
ENTRY(ret_from_fork)
// ...
call schedule_tail
// 检查 TIF_NEED_RESCHED
testl $_TIF_NEED_RESCHED, TI_flags(%r12)
jnz schedule
// ...

八、上下文切换的内核实现#

当调度器决定切换进程时,context_switch() 是实际执行切换的核心函数。它完成两件事:地址空间切换处理器状态切换

context_switch() 的完整流程#

// kernel/sched/core.c(简化)
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
// 第一步:切换地址空间(mm_struct)
switch_mm_irqs_off(prev->active_mm, next->mm, next);
// 第二步:切换处理器状态(寄存器、栈指针)
switch_to(prev, next, prev);
// 第三步:屏障,确保编译器不会重排
barrier();
return finish_task_switch(prev);
}

switch_mm():地址空间切换#

switch_mm() 负责将页表从旧进程切换到新进程:

// arch/x86/mm/tlb.c(简化)
void switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next,
struct task_struct *tsk)
{
// 加载新进程的页表基址到 CR3 寄存器
write_cr3(next->pgd);
// 刷新 TLB(Translation Lookaside Buffer)
// 现代 x86 使用 PCID 减少不必要的 TLB 刷新
// ...
// 更新 CPU 的 active_mm
this_cpu_write(cpu_tlbstate.active_mm, next);
}
Note

内核线程没有自己的 mm_structtsk->mm == NULL),它借用前一个进程的 active_mm,避免不必要的页表切换。这是内核线程的重要优化——因为内核空间在所有进程间共享,切换页表是浪费。

switch_to():寄存器与栈切换#

switch_to() 是架构相关的汇编宏,负责保存旧进程的寄存器并恢复新进程的寄存器:

// arch/x86/include/asm/switch_to.h(简化)
#define switch_to(prev, next, last) \
do { \
/* 保存旧进程的 RSP、RBP 等寄存器 */ \
save_regs(prev); \
/* 切换栈指针到新进程的内核栈 */ \
__switch_to(prev, next); \
/* 恢复新进程的寄存器 */ \
restore_regs(next); \
} while (0)

x86_64 的 __switch_to() 汇编实现(极度简化):

__switch_to:
/* 保存 callee-saved 寄存器到 prev 的栈帧 */
push %rbp
push %rbx
push %r12
push %r13
push %r14
push %r15
/* 保存 prev 的栈指针到 task_struct->thread.sp */
movq %rsp, TASK_thread_sp(%rdi)
/* 加载 next 的栈指针 */
movq TASK_thread_sp(%rsi), %rsp
/* 恢复 next 的 callee-saved 寄存器 */
pop %r15
pop %r14
pop %r13
pop %r12
pop %rbx
pop %rbp
/* 更新 GS 基址(per-CPU 数据) */
swapgs
wrmsr
ret

上下文切换的开销主要包括:

开销来源典型耗时说明
寄存器保存/恢复~0.1-0.5μs直接 CPU 指令开销
TLB 刷新~1-10μs切换地址空间时,TLB 失效导致后续访存变慢
缓存失效~1-100μs新进程的代码和数据不在 L1/L2 缓存中,需要从内存加载
调度器计算~0.1-0.3μs选择下一个进程、更新运行队列
Important

上下文切换的最大开销不是寄存器保存/恢复,而是缓存失效。一个”缓存热”的进程(刚运行过)和一个”缓存冷”的进程(长时间未运行)的性能差距可能高达 10 倍以上。这也是为什么调度器不应过于频繁地切换进程。

九、多核调度:负载均衡与调度域#

在多核/多处理器系统上,调度器需要解决一个额外的问题:如何将进程合理地分配到各个 CPU 上

调度域(Scheduling Domain)#

Linux 用**调度域(sched_domain)**来描述 CPU 的拓扑层次结构。每个调度域包含一个或多个调度组(sched_group),形成树状结构:

系统调度域层次(以 2-socket × 4-core × 2-thread 为例):
NUMA 域(socket 间)
├── MC 域(socket 0)
│ ├── SMT 域(core 0:CPU 0, CPU 8)
│ ├── SMT 域(core 1:CPU 1, CPU 9)
│ ├── SMT 域(core 2:CPU 2, CPU 10)
│ └── SMT 域(core 3:CPU 3, CPU 11)
└── MC 域(socket 1)
├── SMT 域(core 4:CPU 4, CPU 12)
├── SMT 域(core 5:CPU 5, CPU 13)
├── SMT 域(core 6:CPU 6, CPU 14)
└── SMT 域(core 7:CPU 7, CPU 15)

调度域的层次从底到顶:

  1. SMT 域(Simultaneous Multithreading):超线程兄弟,共享 L1/L2 缓存和执行单元
  2. MC 域(Multi-Core):同一 socket 内的物理核心,共享 L3 缓存
  3. NUMA 域:不同 socket,通过互连总线通信,访问远端内存延迟更高

负载均衡策略#

负载均衡的核心原则:尽量在低层域内均衡,减少跨域迁移。因为:

  • 同一 SMT 域内迁移:几乎无开销(共享缓存)
  • 同一 MC 域内迁移:L3 缓存可能失效
  • 跨 NUMA 域迁移:L3 缓存必然失效,且内存访问延迟增加

负载均衡的触发时机:

  1. 周期性负载均衡:由 load_balance() 在每个调度域的 sched_balance_trigger 中定期执行
  2. 空闲均衡:当 CPU 进入 idle 时,主动从其他 CPU “拉”任务
  3. fork/exec 均衡:新进程创建时,选择负载最轻的 CPU
  4. 主动迁移:由迁移线程(migration/X)执行,处理极端不均衡

亲和性:进程绑核#

用户可以通过 sched_setaffinity() 将进程绑定到特定 CPU:

#define _GNU_SOURCE
#include <sched.h>
cpu_set_t mask;
CPU_ZERO(&mask);
CPU_SET(2, &mask); // 绑定到 CPU 2
CPU_SET(3, &mask); // 和 CPU 3
sched_setaffinity(getpid(), sizeof(mask), &mask);

命令行工具 taskset 提供了更便捷的接口:

# 将进程 1234 绑定到 CPU 0 和 CPU 2
taskset -pc 0,2 1234
# 在 CPU 1 上启动新进程
taskset -c 1 ./my_program

十、cgroup v2 对调度的限制#

cgroup v2 的 CPU 控制器允许对进程组的 CPU 使用进行细粒度限制,这是容器资源隔离的基础。

cpu.max:带宽限制#

cpu.max 控制一个 cgroup 在给定周期内最多能使用的 CPU 时间:

# 格式:max_quota period
# 示例:在 100ms 周期内最多使用 50ms CPU 时间(即 50% CPU)
echo "50000 100000" > /sys/fs/cgroup/mygroup/cpu.max
# 不限制(默认)
echo "max 100000" > /sys/fs/cgroup/mygroup/cpu.max

cpu.max 的实现原理:cgroup 作为一个调度实体参与 CFS 调度,当其累计运行时间超过 quota 时,整个 cgroup 被限流(throttled),直到下一个周期开始。

cpu.weight:相对权重#

cpu.weight 控制多个 cgroup 之间的 CPU 时间分配比例(范围 1-10000,默认 100):

# 设置 cgroup A 的权重为 200,cgroup B 的权重为 100
echo 200 > /sys/fs/cgroup/A/cpu.weight
echo 100 > /sys/fs/cgroup/B/cpu.weight
# 结果:A 获得 2/3 的 CPU 时间,B 获得 1/3
Note

cpu.weight 只在 CPU 未饱和时生效——如果 CPU 有空闲,所有 cgroup 都能获得足够的 CPU 时间。cpu.max 则是硬限制,无论 CPU 是否空闲都生效。

cgroup v2 CPU 控制器的实际应用#

# 创建一个受限的 cgroup
mkdir /sys/fs/cgroup/container1
echo $$ > /sys/fs/cgroup/container1/cgroup.procs # 将当前 shell 加入
echo "20000 100000" > /sys/fs/cgroup/container1/cpu.max # 限制为 20% CPU
# 验证:运行一个 CPU 密集型任务,观察 CPU 使用率
dd if=/dev/zero bs=1M | md5sum &
# 在另一个终端用 top 观察,CPU 使用率应被限制在 ~20%

十一、nice 值、renice 与 chrt 命令详解#

nice:启动时设置 nice 值#

# 以 nice 值 10 启动命令(降低优先级)
nice -n 10 ./my_batch_job
# 以 nice 值 -5 启动命令(提高优先级,需要 root)
nice -n -5 ./my_important_task

renice:修改运行中进程的 nice 值#

# 将 PID 1234 的 nice 值改为 5
renice -n 5 -p 1234
# 将用户 alice 的所有进程 nice 值改为 10
renice -n 10 -u alice
# 将进程组 5678 的 nice 值改为 -3
renice -n -3 -g 5678

chrt:修改调度策略与实时优先级#

chrt 是操作实时调度策略的核心工具:

# 查看进程的调度策略和优先级
chrt -p 1234
# 以 SCHED_FIFO 优先级 50 运行
chrt -f -p 50 ./realtime_task
# 以 SCHED_RR 优先级 30 运行
chrt -r -p 30 ./round_robin_task
# 以 SCHED_DEADLINE 运行(runtime=10ms, deadline=30ms, period=30ms)
chrt -d --runtime 10000000 --deadline 30000000 --period 30000000 ./dl_task
# 将运行中的进程改为 SCHED_FIFO 优先级 80
chrt -f -p 80 1234
# 将进程改回普通调度策略
chrt -o -p 0 1234

十二、动手实践#

实践一:查看进程调度信息#

/proc/[pid]/sched 文件包含了进程调度的详细信息:

# 查看当前 shell 的调度信息
cat /proc/$$/sched

输出示例及关键字段解读:

bash (1234, #threads: 1)
-------------------------------------------------------------------
se.exec_start : 1234567890.123456 # 上次执行开始时间
se.vruntime : 567890.123456 # 虚拟运行时间
se.sum_exec_runtime : 1234.567890 # 实际运行总时间
se.load.weight : 1048576 # 权重(nice 0 = 1048576)
nr_switches : 456 # 上下文切换总次数
nr_voluntary_switches : 345 # 自愿切换次数
nr_involuntary_switches : 111 # 非自愿切换次数
prio : 120 # 动态优先级(120 = nice 0)
policy : 0 # 调度策略(0=SCHED_NORMAL)
Tip

nr_voluntary_switches 高说明进程经常主动阻塞(如等待 I/O),这是交互进程的典型特征。nr_involuntary_switches 高说明进程经常被抢占,可能是 CPU 密集型任务或时间片耗尽。

实践二:nice 值对 CPU 时间分配的影响#

# 终端 1:启动两个 CPU 密集型任务,一个 nice 0,一个 nice 10
nice -n 0 dd if=/dev/zero bs=1M | md5sum &
nice -n 10 dd if=/dev/zero bs=1M | md5sum &
# 终端 2:观察 CPU 使用率
top -H
# 你会发现 nice 0 的进程获得的 CPU 时间约为 nice 10 进程的 ~2.5 倍
# 清理
killall dd md5sum

实践三:chrt 实时调度实验#

# 实验:SCHED_FIFO 进程的不可抢占性
# 终端 1:以 SCHED_FIFO 优先级 1 运行一个死循环(危险!)
sudo chrt -f 1 bash -c 'while true; do :; done'
# 注意:此时该终端会被完全占用,系统可能变慢但不会完全卡死
# 因为 Linux 的 RT throttling 会保留 5% CPU 给普通进程
# 终端 2:查看 RT throttling 状态
cat /proc/sys/kernel/sched_rt_runtime_us
cat /proc/sys/kernel/sched_rt_period_us
# 终端 3:以更高优先级抢占
sudo chrt -f 2 bash -c 'echo "I can preempt!" && sleep 1'
# 这个进程会抢占优先级 1 的 SCHED_FIFO 进程

实践四:taskset 绑核实验#

# 将进程绑定到 CPU 0
taskset -pc 0 $$
cat /proc/$$/status | grep Cpus_allowed
# 输出:Cpus_allowed: 00000001
# 启动一个 CPU 密集型任务并绑定到 CPU 1
taskset -c 1 dd if=/dev/zero bs=1M | md5sum &
# 观察:只有 CPU 1 的使用率接近 100%
mpstat -P ALL 1
# 清理
killall dd md5sum

实践五:cgroup v2 CPU 限制实验#

# 确保 cgroup v2 已挂载
mount | grep cgroup2
# 创建测试 cgroup
sudo mkdir /sys/fs/cgroup/test-cpu
echo $$ | sudo tee /sys/fs/cgroup/test-cpu/cgroup.procs
# 限制为 20% CPU
echo "20000 100000" | sudo tee /sys/fs/cgroup/test-cpu/cpu.max
# 运行 CPU 密集型任务
dd if=/dev/zero bs=1M | md5sum &
# 在另一个终端观察 CPU 使用率
top -p $!
# CPU 使用率应被限制在 ~20%
# 清理
kill %1
sudo rmdir /sys/fs/cgroup/test-cpu

实践六:观察上下文切换#

# 使用 perf 观察调度事件
sudo perf sched record -- sleep 5
sudo perf sched latency
# 输出各进程的调度延迟统计
# 使用 vmstat 观察上下文切换频率
vmstat 1
# cs 列显示每秒上下文切换次数
# 使用 /proc/stat 观察全局上下文切换计数
cat /proc/stat | grep ctxt
# ctxt 123456789
# 使用 pidstat 观察特定进程的上下文切换
pidstat -w -p ALL 1

十三、核心源码导航#

文件路径核心内容
kernel/sched/core.c调度器核心:schedule()context_switch()pick_next_task()
kernel/sched/fair.cCFS 实现:enqueue_entity()pick_next_entity()update_curr()
kernel/sched/rt.c实时调度:enqueue_task_rt()pick_next_task_rt()
kernel/sched/deadline.cDeadline 调度:EDF 算法实现
kernel/sched/sched.h调度器内部数据结构定义
include/linux/sched.htask_structsched_entity 等核心结构
kernel/sched/topology.c调度域与负载均衡
kernel/sched/cpufreq.c调度器与 CPU 频率调节的交互

参考资料#

书籍#

  • 《Linux 内核设计与实现》 第 4 章 — Robert Love 对 Linux 调度器的经典讲解,涵盖 CFS 的设计哲学与实现
  • 《深入理解 Linux 内核》 第 7 章 — Daniel P. Bovet 等对调度器数据结构与算法的详细剖析
  • 《Systems Performance》 第 5 章 — Brendan Gregg 对调度性能分析与调优的实践指南
  • 《操作系统导论》(OSTEP) — Remzi H. Arpaci-Dusseau,调度理论的最佳入门教材

内核文档#

手册页#

  • man 1 chrt — 实时调度策略操作工具
  • man 2 sched_setscheduler — 设置调度策略的系统调用
  • man 2 sched_setaffinity — 设置 CPU 亲和性
  • man 7 sched — Linux 调度概述
  • man 1 taskset — CPU 亲和性设置工具

在线资源#


从本章中,理解了 Linux 调度器如何通过 vruntime 和红黑树实现”完全公平”的 CPU 分配,如何通过调度类层次支持实时与普通进程的共存,以及上下文切换、多核负载均衡、cgroup 限制等关键机制。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

进程调度
https://blog.souloss.com/posts/linux-internals/process-scheduling/
作者
Souloss
发布于
2024-07-03
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时