Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.5 调度器:CFS、EEVDF 与实时调度

本章回答

两个 CPU 密集进程同时跑,为什么各拿 50% 而不是「先跑完一个」?nice 值改 1,实际份额变多少?6.6 之后 CFS 被 EEVDF 取代,vruntime 和 deadline 到底谁决定下一个跑谁?实时进程为什么能插队,内核又为什么要给它设上限?

读完你会

画出 rq → sched_class → cfs_rq → sched_entity 的对象链;用权重表算出任意两个 nice 值的 CPU 份额比;说清 CFS 与 EEVDF 的取舍;用 chrt、/proc/sched_debug、perf sched 与 bpftrace 各取到一条调度证据。

3.4 把进程造了出来,并让它进入就绪队列。现在问:队列里有一堆 task,下一个跑谁?跑多久?被谁打断?

调度是内核里「策略最集中」的子系统。它要同时满足三个互相冲突的目标:公平(每个进程都别饿死)、吞吐(少切换、多干活)、延迟(交互式任务要立刻响应)。Linux 的解法不是找一个万能公式,而是分层:实时类绝对优先,普通类按权重分配,空闲类只在没事干时跑;每一层内部再谈公平。

这一章的主线是算法演化:先讲 CFS(4.x–6.5 的 vruntime + 红黑树),再讲 6.6 起取代它的 EEVDF(lag + 虚拟截止时间),最后讲实时类与组调度。CFS 不是「过时的知识」——大多数 4.x/5.x 的生产内核还在用它,而且 EEVDF 的权重、sched_entity、红黑树这些基础设施完全继承自 CFS。

3.5.1 骨架:运行队列与调度类

每个 CPU 有一个 struct rq(kernel/sched/sched.h),里面按调度类分成几个子队列:

struct rq(per-CPU)
├── curr                  当前运行的 task
├── nr_running            就绪任务总数
├── clock / clock_task    队列时钟(纳秒)
├── dl_rq                 SCHED_DEADLINE 队列(按 deadline 排的红黑树)
├── rt_rq                 SCHED_FIFO / SCHED_RR 队列(100 个优先级链表)
├── cfs_rq                SCHED_NORMAL / BATCH / IDLE(红黑树)
│   ├── tasks_timeline    ← sched_entity 按 vruntime 排序(6.6+ 仍如此,见 3.5.8)
│   ├── curr / next
│   ├── min_vruntime
│   └── nr_running
└── idle                 空闲类(每个 CPU 一个)

调度类按优先级串成一条链,pick_next_task() 从高到低问一遍:

优先级调度类策略典型使用者
最高stop_sched_class停止机器(hotplug、migration)内核内部
↓dl_sched_classSCHED_DEADLINE实时音视频、工业控制
↓rt_sched_classSCHED_FIFO/SCHED_RR中断线程、实时任务
↓fair_sched_classSCHED_NORMAL/BATCH/IDLE绝大多数进程
最低idle_sched_classidle每 CPU 的 swapper

每个调度类实现一组回调(struct sched_class):enqueue_task、dequeue_task、pick_next_task、task_tick、wakeup_preempt、task_fork、switched_to 等。调度器核心(kernel/sched/core.c)只负责「按顺序问」,具体怎么选由类自己决定。这就是为什么加一个调度类(比如 6.12 的 sched_ext)不需要改核心。

一次切换的路径:

/* kernel/sched/core.c: __schedule() —— 6.8 节选,已简化 */
	prev = rq->curr;
	...
	rq->clock_update_flags <<= 1;
	update_rq_clock(rq);
	...
	next = pick_next_task(rq, prev, &rf);   /* 从最高类开始问 */
	...
	if (likely(prev != next)) {
		rq->nr_switches++;
		rq->curr = next;
		...
		prepare_task_switch(rq, prev, next);
		context_switch(rq, prev, next, &rf);   /* 切页表 + switch_to */
	}

context_switch() 做两件事:switch_mm_irqs_off() 换页表(若 mm 不同),switch_to() 保存/恢复寄存器与栈(arch/x86/entry/entry_64.S 的 __switch_to_asm)。切换完成后,新任务从它上次 schedule() 的地方继续。

定义 3.5.1 · 抢占模型

内核用 CONFIG_PREEMPT_* 决定「内核态代码能否被抢占」:

  • PREEMPT_NONE:内核态不抢占,只在返回用户态或显式 cond_resched() 时切;吞吐最好,延迟最差。
  • PREEMPT_VOLUNTARY:在若干 might_sleep() 点显式检查。
  • PREEMPT:内核态可抢占(除关抢占区段);桌面/实时常用。
  • PREEMPT_RT:把 spinlock 换成可睡眠的 rtmutex、中断线程化,硬实时。

6.8 默认 PREEMPT_DYNAMIC:编译期保留多种实现,启动时用 preempt=full|voluntary|none 选择。

3.5.2 权重:nice 值的真实含义

定义 3.5.2 · 调度权重与 nice

每个 task 有一个 static_prio = MAX_RT_PRIO + nice = 120 + nice(nice 取 −20..19,所以普通优先级是 100..139)。调度器不直接用 nice,而是查表换成权重 load.weight:sched_prio_to_weight[] 以 nice 0 的 1024 为基准,每差 1 个 nice,权重约乘/除 1.25。权重决定「分到 CPU 时间的比例」,不是「优先级高低」的字面排序。

nice权重相对 nice 0 的份额两个这样的进程各占
−208876186.7×97.7%
−1095489.32×90.3%
−531213.05×75.3%
010241.00×50.0%
53350.327×24.6%
101100.107×9.7%
19150.0146×1.4%

这张表说明两件事:

  1. nice 是比例,不是绝对优先级。两个 nice 0 的进程各 50%;一个 nice 0 加一个 nice 10,比例是 1024:110,前者拿 90.3%。
  2. 1 个 nice 的差别约 10%(当只有两个进程时),不是「10% 的 CPU」而是「份额比 1.25:1」。所以「把 nice 从 0 调到 1」在双进程场景下把 50% 变成 55.6%,而不是 40%。

内核里权重是两个数组:

/* kernel/sched/core.c —— 6.8 节选 */
const int sched_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,
};

配套的 sched_prio_to_wmult[] 存的是 2^32 / weight,用于把「真实时间」换算成「虚拟时间」时避免除法。calc_delta_fair(delta, se) 就是 delta * NICE_0_LOAD / se->load.weight,权重越大,同样的真实时间换来的虚拟时间越少——这就是「权重高的进程 vruntime 涨得慢、于是更常被选中」的全部秘密。

与 OSTEP 的比例份额对照

OSTEP 第 9 章用彩票调度(lottery)与步长调度(stride)讲“按份额分配”:stride 给每个任务一个步长,每次运行把 pass 增加 stride,选 pass 最小的。CFS 的 vruntime 就是 pass,权重越大步长越小;区别是 CFS 用红黑树选最小、用 min_vruntime 处理新任务,而 stride 通常用最小堆或链表。读完 OSTEP 的 stride 例子再回看 calc_delta_fair(),会发现两者是同一个数学模型的不同工程实现。

3.5.3 CFS:让 vruntime 最小的先跑

CFS(Completely Fair Scheduler,2.6.23–6.5)的模型简单到可以一句话说完:维护每个任务的虚拟运行时间 vruntime,总是选 vruntime 最小的那个跑。权重高的任务 vruntime 涨得慢,自然跑得多。

vruntime 的更新在 update_curr() 里:

/* kernel/sched/fair.c: update_curr() —— 6.8(EEVDF 下仍保留这段主干) */
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;

	if (unlikely(!curr))
		return;
	delta_exec = now - curr->exec_start;
	if (unlikely((s64)delta_exec <= 0))
		return;

	curr->exec_start = now;
	schedstat_set(curr->statistics.exec_max, ...);
	curr->sum_exec_runtime += delta_exec;
	schedstat_add(cfs_rq->exec_clock, delta_exec);

	curr->vruntime += calc_delta_fair(delta_exec, curr);
	update_min_vruntime(cfs_rq);
	...
}

update_min_vruntime() 维护 cfs_rq->min_vruntime,它是「这个队列已经服务到哪」的水位:

/* kernel/sched/fair.c: update_min_vruntime() —— 6.8 节选 */
static void update_min_vruntime(struct cfs_rq *cfs_rq)
{
	struct sched_entity *curr = cfs_rq->curr;
	struct rb_node *leftmost = rb_first_cached(&cfs_rq->tasks_timeline);
	u64 vruntime = cfs_rq->min_vruntime;

	if (curr) {
		if (curr->on_rq)
			vruntime = curr->vruntime;
		else
			curr = NULL;
	}
	if (leftmost) {
		struct sched_entity *se = __node_2_se(leftmost);
		if (!curr)
			vruntime = se->vruntime;
		else
			vruntime = min_vruntime(vruntime, se->vruntime);
	}
	cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime);
}

红黑树按 vruntime 排序,最左节点就是「最该跑」的:

                cfs_rq->tasks_timeline(按 vruntime 排序的红黑树)
                          ┌──────────────┐
                          │  vruntime=42 │  ← root
                          └──────┬───────┘
                    ┌────────────┴────────────┐
              ┌─────▼─────┐             ┌─────▼─────┐
              │ vruntime=31│             │ vruntime=58│
              └─────┬─────┘             └─────┬─────┘
              ┌─────▼─────┐             ┌─────▼─────┐
              │ vruntime=18│             │ vruntime=67│
              └─────┬─────┘             └───────────┘
              ┌─────▼─────┐
              │ vruntime= 9│ ← rb_leftmost,下一个跑它
              └───────────┘
   min_vruntime = 18(已服务水位;新唤醒任务按它做 place_entity)

读图:树的中序是有序的,最左节点 O(log n) 可取(rb_first_cached 把它缓存成 O(1))。插入是 O(log n),删除也是。min_vruntime 是单独维护的水位,不是树里的节点——新任务或刚唤醒的任务不能直接拿自己很小的旧 vruntime 插进去(那会 monopolize CPU),要先 place_entity() 把它「补偿」到接近 min_vruntime。

CFS 的时间片不是固定的,而是按「一轮里所有任务都跑一遍」的目标算:

sched_latency = 6 ms(默认,/proc/sys/kernel/sched_latency_ns)
min_granularity = 0.75 ms(最小切片,防止切换过频)
slice_i = sched_latency * weight_i / sum(weights)
若 slice_i < min_granularity,则取 min_granularity

三个任务权重相同,各 2 ms;一个 nice 0 加一个 nice 19,权重比 1024:15,nice 19 的切片会被压到 min_granularity,否则它一轮只能跑 86 µs,切换开销比干活还大。

关于 CFS 的五个误解
  1. 「CFS 是完全公平的」:名字里的 completely 指「按权重公平」,不指「每个进程时间相同」。权重不同,份额就不同。
  2. 「vruntime 就是运行时间」:它是加权后的虚拟时间。nice 0 的任务跑 1 ms,vruntime 加 1 ms;nice 10 的任务跑 1 ms,vruntime 加约 9.3 ms。
  3. 「新进程会立刻抢占」:place_entity() 会把新进程的 vruntime 补偿到 min_vruntime 附近,避免它凭「从 0 开始」抢跑。唤醒抢占还受 sched_wakeup_granularity 限制。
  4. 「min_granularity 保证每个进程至少跑 0.75 ms」:它只在「按比例算出的切片太小」时兜底,任务数极多时总延迟会远超 sched_latency。
  5. 「调小 sched_latency 能降低延迟」:它同时也降低吞吐(切换变多),而且 6.6 之后这个参数已被 base_slice_ns 取代,旧调优手册失效。

3.5.4 EEVDF:从「最小 vruntime」到「最早虚拟截止时间」

CFS 的公平性有一个结构性缺陷:它只看「谁欠得最多」,不看「谁要得急」。一个刚醒来的交互式任务和一个跑了很久的批处理任务,vruntime 可能差不多,CFS 无法区分「该让它立刻跑」和「可以再等等」。CFS 用一堆启发式补丁(sched_wakeup_granularity、GENTLE_FAIR_SLEEPERS、last_buddy)来缓解,结果是行为难以预测,调参空间诡异。

6.6 起,fair_sched_class 换成 EEVDF(Earliest Eligible Virtual Deadline First,Spuri 1996 的算法)。它给每个实体增加两个字段:

字段含义
slice这次请求的时间片(默认 base_slice,约 0.75 ms)
deadline虚拟截止时间 = vruntime + slice 折算到虚拟时间
vlaglag(滞后量):vruntime - min_vruntime 的修正项,用于判断「是否有资格」

选择规则变成两步:

  1. 资格(eligible):只考虑 lag <= 0 的实体,即「还没有拿到超过自己公平份额」的。这保证公平——已经多拿的没资格再抢。
  2. 最早截止(earliest deadline):在合格者里选 deadline 最小的。这让「请求了小 slice、想尽快完成」的任务优先,天然支持延迟敏感负载。

update_deadline() 在实体用完自己的 slice 后把 deadline 往前推:

/* kernel/sched/fair.c: update_deadline() —— 6.8 节选 */
static void update_deadline(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
	if ((s64)(se->vruntime - se->deadline) < 0)
		return;

	/*
	 * 虚拟截止时间 = vruntime + slice 的加权虚拟时间。
	 * calc_delta_fair() 把真实 slice 按权重折算,权重越大 deadline 推得越慢。
	 */
	se->deadline = se->vruntime + calc_delta_fair(se->slice, se);
}

pick_eevdf() 是选择器,骨架是「先看 rb_leftmost 是否合格,不合格就在树里找 deadline 最小的合格者」:

/* kernel/sched/fair.c: pick_eevdf() —— 6.8 节选,已简化 */
static struct sched_entity *pick_eevdf(struct cfs_rq *cfs_rq)
{
	struct rb_node *node = cfs_rq->tasks_timeline.rb_root.rb_node;
	struct sched_entity *se = __pick_first_entity(cfs_rq);
	struct sched_entity *curr = cfs_rq->curr;
	struct sched_entity *best = NULL;

	if (se && entity_eligible(cfs_rq, se)) {
		best = se;
		goto found;
	}
	...
	while (node) {
		struct sched_entity *se = __node_2_se(node);
		if (entity_eligible(cfs_rq, se) &&
		    (!best || deadline_gt(deadline, best, se)))
			best = se;
		...
	}
found:
	return best;
}
为什么「最早截止」比「最小 vruntime」更好

把 CPU 想成一个按虚拟时间计费的服务台。CFS 的规则是「谁欠账多谁先上」,这很公平,但没法表达「我这个请求只要 0.5 ms,能不能插个队」。EEVDF 给每个请求一个「承诺完成时间」= 当前欠账 + 这次请求量。服务台先服务「欠账已还清(eligible)」且「承诺时间最早」的请求。公平性由 eligibility 保证,延迟敏感性由 deadline 表达——两个目标被拆成两个判据,不再靠启发式打补丁。

对使用者来说,EEVDF 带来的可见变化:

  • /proc/sys/kernel/sched_latency_ns 与 min_granularity_ns 消失,换成 /sys/kernel/debug/sched/base_slice_ns(需 CONFIG_SCHED_DEBUG)。6.8 上 base_slice 默认 0.75 ms。
  • sched_wakeup_granularity_ns 仍在,用于限制唤醒抢占的抖动。
  • nice 的语义没变,权重表也没变。原先按权重算份额的经验继续有效。
  • se->deadline 出现在 /proc/sched_debug 里,可以直接看下一个该跑谁。
维度CFS(≤6.5)EEVDF(6.6+)
排序键vruntime合格者中 deadline 最小
时间片按 sched_latency 与权重动态算按 slice(base_slice)请求
新任务放置place_entity() 补偿到 min_vruntimeplace_entity() 设 vlag,可正可负
唤醒抢占启发式 + wakeup_granularity比较 deadline + wakeup_granularity
调优参数latency_ns、min_granularity_nsbase_slice_ns
主要问题唤醒延迟不可预测参数少,但 slice 语义需要重新理解
4.x 读者请注意

奔跑吧第 3 章 3.2 节讲的是 CFS(4.x 代码):struct sched_entity 里只有 vruntime,没有 deadline/slice/vlag;pick_next_entity() 里有一堆 cfs_rq->next/last 的启发式。6.8 的 fair.c 里这些已被 pick_eevdf() 取代。读旧代码时先看 git log -S'pick_eevdf' -- kernel/sched/fair.c 确认分界点。

3.5.5 组调度与 cgroup:把权重给一组进程

如果按 task 分权重,一个开了 100 个线程的进程会拿到 100 份,而单线程进程只有 1 份——这不是用户想要的「公平」。组调度(CONFIG_FAIR_GROUP_SCHED)把「组」也变成一个 sched_entity,组内再按权重分:

        root cfs_rq(weight 1024)
        ├── A 组 sched_entity(cpu.weight=300)──► A 的 cfs_rq
        │      ├── task a1
        │      └── task a2
        └── B 组 sched_entity(cpu.weight=700)──► B 的 cfs_rq
               └── task b1
   结果:A 组整体拿 30%,组内 a1/a2 各 15%;B 组拿 70%,b1 独享 70%。
   b1 的线程数再多,也不会超过 B 组的份额。

cgroup v2 的接口:

# 建两个组,设权重(1..10000,默认 100)
mkdir -p /sys/fs/cgroup/demo/{a,b}
echo 300 > /sys/fs/cgroup/demo/a/cpu.weight
echo 700 > /sys/fs/cgroup/demo/b/cpu.weight

# 把进程放进去(PID 写进去即生效)
echo $$ > /sys/fs/cgroup/demo/a/cgroup.procs

# 限制绝对带宽:每 100 ms 最多用 50 ms CPU(需要 CONFIG_CFS_BANDWIDTH)
echo "50000 100000" > /sys/fs/cgroup/demo/a/cpu.max

# 看被限流的次数与时间
cat /sys/fs/cgroup/demo/a/cpu.stat

cpu.weight 是相对份额,只在同层有竞争时生效;cpu.max 是绝对上限,超出后整个组被 throttle 到下一个周期。两者可以叠加:权重决定「抢多少」,上限决定「最多多少」。cpu.stat 里的 nr_throttled 与 throttled_usec 是判断「是不是被限流拖慢」的直接证据。

3.5.6 实时调度:FIFO、RR 与 DEADLINE

实时类的规则完全不同:不看权重,只看优先级;只要可运行,就抢占所有普通进程。

策略优先级范围时间片语义
SCHED_FIFO1–99无(跑到阻塞或主动让)同优先级按 FIFO,先来先跑
SCHED_RR1–99100 ms(sched_rr_timeslice_ms)同优先级轮转
SCHED_DEADLINE无(用 runtime/deadline/period)由 CBS 控制必须在 deadline 前跑完 runtime

SCHED_DEADLINE 是另一套模型:用 sched_setattr(2) 传 runtime、deadline、period,内核做准入控制(admission control),保证所有已接纳的 deadline 任务理论上可调度。它内部用 CBS(Constant Bandwidth Server)加 EDF 排序,与 CFS/RT 的队列完全分开。

实时类有一个必须知道的安全阀:RT throttling。默认每 1 秒周期里,实时任务最多用 950 ms,剩下 50 ms 留给普通任务:

cat /proc/sys/kernel/sched_rt_runtime_us    # 950000
cat /proc/sys/kernel/sched_rt_period_us     # 1000000
# 关掉限流(危险:一个死循环的 FIFO 任务能锁死整台机器)
echo -1 > /proc/sys/kernel/sched_rt_runtime_us

设置实时策略需要 CAP_SYS_NICE(或 root)。普通用户会得到 EPERM。另外 RLIMIT_RTPRIO 限制可设的最高优先级,RLIMIT_RTTIME 限制连续占用时间(超了收 SIGXCPU)。

下面这个程序把当前线程设成 SCHED_FIFO,测 1000 次 clock_nanosleep 的唤醒抖动——这就是 cyclictest 的最小版本:

/* rt_jitter.c — 测实时线程的唤醒抖动
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 -o rt_jitter rt_jitter.c
 * 运行: sudo ./rt_jitter [优先级,默认 50]
 */
#define _GNU_SOURCE
#include <errno.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>

#define NS_PER_MS 1000000LL

static long long now_ns(void)
{
	struct timespec ts;
	clock_gettime(CLOCK_MONOTONIC, &ts);
	return (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
}

int main(int argc, char *argv[])
{
	int prio = argc > 1 ? atoi(argv[1]) : 50;
	struct sched_param sp = { .sched_priority = prio };

	if (sched_setscheduler(0, SCHED_FIFO, &sp) == -1) {
		fprintf(stderr, "sched_setscheduler: %s(需要 root 或 CAP_SYS_NICE)\n",
			strerror(errno));
		return 1;
	}
	printf("policy=SCHED_FIFO prio=%d\n", prio);

	long long max = 0, sum = 0;
	const int N = 1000;
	struct timespec next;
	clock_gettime(CLOCK_MONOTONIC, &next);

	for (int i = 0; i < N; i++) {
		next.tv_nsec += 1 * NS_PER_MS;          /* 周期 1 ms */
		while (next.tv_nsec >= 1000000000L) {
			next.tv_nsec -= 1000000000L;
			next.tv_sec++;
		}
		if (clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, NULL) == -1) {
			if (errno == EINTR) continue;
			perror("clock_nanosleep");
			return 1;
		}
		long long late = now_ns() - ((long long)next.tv_sec * 1000000000LL + next.tv_nsec);
		if (late > max) max = late;
		sum += late;
	}
	printf("wakeup latency: avg=%lld us max=%lld us over %d samples\n",
	       sum / N / 1000, max / 1000, N);
	return 0;
}

在 QEMU 里跑,max 通常在几十到几百微秒;宿主机上关掉其他负载能到 10 µs 量级。把策略改成 SCHED_OTHER(注释掉 sched_setscheduler),max 会明显变大——这就是「实时」买到的东西。

实时调度的五个坑
  1. 「实时进程一定比普通进程快」:它只保证「优先获得 CPU」,不保证「跑得更快」。如果它被锁或等 I/O,照样阻塞。
  2. 「SCHED_FIFO 会让同优先级轮转」:不会。同优先级 FIFO 是「先来先跑,除非它主动让出或阻塞」,其他同优先级任务会饿死。要轮转用 SCHED_RR。
  3. 「sched_rt_runtime_us=-1 只是关掉限流」:关掉后一个死循环的实时任务能让系统完全无响应,连 ssh 都进不来。生产环境别关。
  4. 「SCHED_DEADLINE 只要设了就能跑」:有准入控制,runtime/deadline/period 之和超过 CPU 带宽会返回 EBUSY;参数不合法返回 EINVAL。
  5. 「优先级 99 最高」:对 RT 类是;但 stop_sched_class 与 dl_sched_class 仍在它之上,SCHED_DEADLINE 可以压过 SCHED_FIFO。

3.5.7 观测:从 /proc 到 bpftrace

工具回答什么前提
/proc/sched_debug每个 CPU/组的队列深度、vruntime、deadlineCONFIG_SCHED_DEBUG
/proc/PID/sched单个任务的策略、优先级、运行时间、迁移次数无
/proc/PID/schedstat运行时间、等待时间、时间片数(ns)CONFIG_SCHEDSTATS
/proc/pressure/cpuCPU 争用(some/full 的累计微秒)CONFIG_PSI
perf sched latency每个任务的调度延迟(平均/最大)perf
perf sched record/report时间轴上的切换事件perf
bpftrace自定义直方图(runqueue 延迟等)BTF + BPF

/proc/sched_debug 的一个片段:

cpu#0, 2899.998 MHz
  .nr_running                    : 3
  .nr_switches                   : 123456
  .clock                         : 987654321.123
cfs_rq[0]:/
  .nr_running                    : 3
  .load                          : 3072
  .min_vruntime                  : 12345.678901
  .exec_clock                    : 9876.543210
  se.sum_exec_runtime            : 4567.890123
  se.vruntime                    : 12345.678901
  se.deadline                    : 12346.428901     ← 6.6+ 才有

perf sched latency 的输出直接给出「谁等得最久」:

 Task                  | Runtime ms | Switches | Avg delay ms | Max delay ms |
 bash                  |    12.345  |       45 |        0.123 |        2.345
 kworker/u8:2          |     3.210  |       12 |        1.234 |       15.678

bpftrace 版本的 runqueue 延迟直方图(经典 runqlat):

bpftrace -e '
tracepoint:sched:sched_wakeup
{
	@q[args->pid] = nsecs;
}
tracepoint:sched:sched_switch
/@q[args->prev_pid]/
{
	@us = hist((nsecs - @q[args->prev_pid]) / 1000);
	delete(@q[args->prev_pid]);
}'

它测的是「任务被唤醒到真正上 CPU」的时间,单位微秒,输出是对数分桶的直方图。@q 是哈希 map,delete() 防止同一个 pid 复用导致的数据错乱。第 3.15 章会把这类脚本讲透。

调度问题的排查顺序
  1. 先分清「不被调度」还是「调度了但慢」:/proc/PID/schedstat 的第二列(等待时间)大,是排队;第一列(运行时间)大但进度慢,是自身或 I/O 问题。
  2. 看 CPU 是否饱和:top 的 %Cpu(s)、mpstat -P ALL 1、/proc/pressure/cpu。
  3. 看谁在抢:pidstat -u 1、perf sched latency 的前几名。
  4. 看是否被限流:cgroup 的 cpu.stat(nr_throttled)、cpu.max。
  5. 看是否被绑核:taskset -pc PID、/proc/PID/status 的 Cpus_allowed_list。
  6. 最后才调参数:base_slice_ns、sched_wakeup_granularity_ns、sched_rt_runtime_us。调参之前先确认瓶颈是调度,不是锁或 I/O。

3.5.8 调度实体与运行队列:字段级解剖

3.5.1 只画了对象链的名字。真正读 sched_debug、crash dump 或 ftrace 时,异常值落在哪个字段、由谁写、什么时候写,决定了你该怀疑哪段代码。这一节把 task_struct、sched_entity、cfs_rq、rq 四层拆到字段。

task_struct
├── prio / static_prio / normal_prio / rt_priority   四个优先级,各有各的读者
├── policy                                           策略号(0/1/2/3/5/6)
├── sched_class ─────────────► fair_sched_class / rt_sched_class / dl_sched_class
├── se  (struct sched_entity)   ─┐
├── rt  (struct sched_rt_entity)  ├─ 内嵌,不是指针
└── dl  (struct sched_dl_entity) ─┘
                                 │ se 挂进
                                 ▼
                    cfs_rq(每 CPU 一个;组调度时每组每 CPU 一个)
                    ├── tasks_timeline   rb_root_cached,按 vruntime 排序
                    ├── curr / next / last / skip
                    ├── nr_running / h_nr_running
                    ├── min_vruntime / avg_vruntime / avg_load
                    └── throttled / runtime_remaining(CFS 带宽)
                                 ▲
                                 │ 被包含
                    rq(每 CPU 一个)
                    ├── curr / idle / clock / clock_task
                    ├── cfs_rq / rt_rq / dl_rq
                    ├── nr_switches / nr_running
                    └── sd ──► sched_domain 树(3.6 节)

task_struct 里的调度字段

字段类型谁写含义与边界
static_priointset_user_nice()、sched_setscheduler()、sched_fork()120 + nice,范围 100–139。名字叫静态,但 nice 改它就改
normal_priointeffective_prio()由策略与 static_prio/rt_priority 推出。普通进程等于 static_prio;RT 为 MAX_RT_PRIO-1-rt_priority;DEADLINE 为 −1
priointeffective_prio()、rt_mutex_setprio()调度类实际比较的动态优先级。被 PI 临时提升时,prio != normal_prio
rt_priorityunsigned intsched_setscheduler()、sched_setattr()只对 RT 类有意义,1–99;普通进程恒为 0
policyunsigned int__sched_setscheduler()基础策略号,不含 SCHED_FLAG_*;标志在 sched_flags
sched_classconst struct sched_class *sched_fork()、策略切换决定入哪个子队列。切换策略时必须先 dequeue 再改
se / rt / dl内嵌结构体初始化时清零,运行中由类维护调度实体是内嵌的,不是指针;container_of 从 se 反推 task_struct
cpus_ptrconst struct cpumask *set_cpus_allowed_ptr()指向 cpus_mask;迁移与选核都读它
nr_cpus_allowedint同上掩码的 popcount 缓存,避免频繁数位
sched_task_groupstruct task_group *sched_change_group()组调度归属;决定 se->my_q 指向哪个组队列

这张表的用法:看到 /proc/PID/sched 里 prio 与 nice 对不上,先查是不是 rt_mutex 的优先级继承;看到策略切换后份额没变,先确认 sched_class 真的换了。

sched_entity:一个可被调度的“份额”

/* include/linux/sched.h —— Linux 6.8,删去条件编译与统计字段 */
struct sched_entity {
	struct load_weight	load;		/* 权重:weight + inv_weight */
	struct rb_node		run_node;	/* 挂进 cfs_rq->tasks_timeline */
	struct list_head	group_node;	/* 挂进 cfs_rq->groups */
	unsigned int		on_rq;		/* 0=不在队列,1=在 */

	u64			exec_start;	/* 本次上 CPU 的起始时刻 */
	u64			sum_exec_runtime;/* 累计真实运行时间 */
	u64			vruntime;	/* 加权虚拟时间,红黑树键 */
	u64			prev_sum_exec_runtime;
	u64			nr_migrations;	/* 迁移次数,/proc/PID/sched 可见 */

	u64			deadline;	/* EEVDF:虚拟截止时间 */
	u64			slice;		/* EEVDF:本次请求的片长 */
	s64			vlag;		/* EEVDF:滞后量,决定 eligibility */

#ifdef CONFIG_FAIR_GROUP_SCHED
	int			depth;		/* 组层级深度 */
	struct sched_entity	*parent;	/* 父实体(组) */
	struct cfs_rq		*cfs_rq;	/* 我挂在哪个队列 */
	struct cfs_rq		*my_q;		/* 我作为组拥有的队列 */
	unsigned long		runnable_weight;
#endif
#ifdef CONFIG_SMP
	struct sched_avg	avg;		/* PELT:load_avg/runnable_avg/util_avg */
#endif
};

字段的所有权:

  • vruntime 只由 update_curr() 推进,place_entity() 会在入队时修正它。任何“手工调 vruntime”的补丁都要同时处理 min_vruntime,否则树序与水位脱节。
  • on_rq 是“是否在队列里”的唯一权威。se->on_rq == 1 但不在红黑树里是合法状态——curr 正在跑,它已出树但仍在队列。
  • load.weight 是调度权重(份额),load.inv_weight 是 2^32/weight 的预计算,供 __calc_delta() 把除法变乘法。
  • deadline/slice/vlag 只在 EEVDF(6.6+)存在;4.x/5.x 的 sched_entity 没有这三个字段,pahole 一看便知。
  • avg 只在 CONFIG_SMP 下存在,PELT 用它估负载;UP 内核里没有。

cfs_rq:一个队列的全部状态

字段谁维护含义异常时说明什么
loadaccount_entity_enqueue/dequeue()队列总权重与 nr_running 比例失真 → 权重记账漏了
nr_running入队/出队本队列实体数与 /proc/sched_debug 的 nr_running 对照
h_nr_running层级传播含子组的任务数组调度下 nr_running 只是本层
min_vruntimeupdate_min_vruntime()已服务水位,单调不减回退 → 有人直接改了它
tasks_timeline__enqueue_entity()按 vruntime 排序的树树空但 nr_running>0 → 实体在 curr
currset_next_task_fair()正在跑的实体curr 不在树里是正常的
next / last唤醒放置与 buddy 启发下次候选/上次运行EEVDF 下 next 语义弱化
avg_vruntime / avg_loadavg_vruntime() 更新EEVDF 的加权平均水位用于 entity_eligible()
throttledCFS 带宽本队列是否被限流cpu.stat 的 nr_throttled 对应它
一处常见笔误:EEVDF 的红黑树仍按 vruntime 排序

不少二手资料说“6.6 之后 tasks_timeline 改按 deadline 排序”。6.8 的 fair.c 里 __entity_less() 比较的仍是 vruntime;deadline 只用于在合格者中挑最早的那个,pick_eevdf() 可能需要在树里回退扫描。把树键改成 deadline 会破坏 min_vruntime 与 place_entity() 的前提。判断依据:grep -n "__entity_less" kernel/sched/fair.c。

rq:每 CPU 的调度器根对象

struct rq 是 per-CPU 的,this_rq() 用 %gs 段基址取。关键字段:

字段含义读它能看到什么
curr当前任务perf sched 的切换对象
idle本 CPU 的 idle 任务swapper/N
clock / clock_task队列时钟(纳秒)clock_task 扣除了 IRQ 时间
cfs_rq / rt_rq / dl_rq三个子队列各自有独立锁与统计
nr_running所有类的可运行任务数sched_debug 顶部
nr_switches切换次数判断抖动
cpu_capacity算力(1024 基准)HMP/EAS 的输入
sd最内层调度域3.6 节展开

rq->clock 与 rq->clock_task 的差值就是 IRQ/ steal time 的扣除量。看 sched_debug 时如果 clock 与 clock_task 差得很大,说明这个 CPU 大量时间在处理中断,调度统计会显得“时间变慢”。

sched_class:6.8 的回调表

调度类是一张函数表。6.8 与 4.x 的签名有实质变化,读旧代码时不能想当然:

回调6.8 签名4.x 差异作用
enqueue_task(rq, p, flags)同入队,flags 含 ENQUEUE_WAKEUP/ENQUEUE_MIGRATED
dequeue_task(rq, p, flags)同出队
wakeup_preempt(rq, p, flags)旧名 check_preempt_curr新任务是否应立即抢占当前
pick_next_task(rq)旧版多 prev/rf 参数选下一个
put_prev_task(rq, p)旧版多 prev 参数把当前放回
set_next_task(rq, p, first)6.x 新增设置 curr 并做类内切换动作
task_tick(rq, p, queued)同tick 驱动的时间片/抢占检查
task_fork(p)同继承/初始化 vruntime
select_task_rq(p, task_cpu, flags)同唤醒/新任务选核
switched_to / switched_from(rq, p)同策略切换后的收尾

pick_next_task() 在 6.8 的主循环仍然是“从高到低问”,但每个类内部可以选择“这次没有就返回 NULL”。核心代码:

/* kernel/sched/core.c: __schedule() —— 6.8 节选,已简化 */
	prev = rq->curr;
	update_rq_clock(rq);
	if (prev->sched_class != &idle_sched_class)
		put_prev_task(rq, prev);          /* 先把 prev 放回它的队列 */
	next = pick_next_task(rq, prev, &rf); /* stop→dl→rt→fair→idle */
	if (likely(prev != next)) {
		rq->curr = next;
		prepare_task_switch(rq, prev, next);
		context_switch(rq, prev, next, &rf);
	}

put_prev_task() 与 pick_next_task() 的顺序很关键:prev 必须先回到队列,才可能被 next 选中(否则单任务队列会选不出东西)。这也是“为什么 curr 不在树里”的原因。

定义 3.5.3 · 调度实体与调度类的分工

调度实体(sched_entity)是“可被调度的一个份额”,保存权重、虚拟时间、截止时间等状态;调度类(sched_class)是“如何比较与选择实体”的算法实现。一个 task 在 fair 类里对应一个 se,在 rt 类里对应一个 rt,在 dl 类里对应一个 dl;三者同时存在,但只有 sched_class 指向的那个参与调度。组调度把 se 变成树:一个组的 se 挂在父队列,组的 my_q 里再挂子实体。

用 pahole / crash 核对字段
# 有调试符号的 vmlinux(CONFIG_DEBUG_INFO_BTF 或带 -g 编译)
pahole -C sched_entity vmlinux
pahole -C cfs_rq vmlinux | head -40
# 运行中的 crash:读 per-CPU rq 的偏移
crash> p nr_cpu_ids
crash> p ((struct rq *)__per_cpu_offset[0])->nr_running

pahole 输出的是编译进内核的布局;/proc/kallsyms 只能给地址。两者对照,才能确认你读的源码与跑的内核是同一版本。

3.5.9 从唤醒到切换:调度时机的完整路径

“任务被唤醒”和“任务开始跑”之间隔着选核、入队、抢占检查、上下文切换四道门。延迟毛刺多半卡在其中一道。这一节把路径拆开,并给出每一道门的失败形态。

wake_up_process(p)
  └─ try_to_wake_up(p, TASK_NORMAL, 0)
       ├─ raw_spin_lock_irqsave(&p->pi_lock)
       ├─ p->__state = TASK_WAKING
       ├─ cpu = select_task_rq(p, p->wake_cpu, WF_TTWU)   ← 选核(3.6 节)
       ├─ ttwu_queue(p, cpu, flags)
       │    └─ ttwu_do_activate(rq, p, WF_TTWU)
       │         ├─ activate_task(rq, p, ENQUEUE_WAKEUP)
       │         │    └─ enqueue_task() → p->sched_class->enqueue_task()
       │         └─ check_preempt_curr(rq, p, WF_TTWU)
       │              └─ wakeup_preempt() → 该类判断是否 set_tsk_need_resched()
       └─ ttwu_stat() / schedstat 记账

唤醒侧:try_to_wake_up 做了什么

/* kernel/sched/core.c: try_to_wake_up() —— 6.8 节选,已简化 */
static int
try_to_wake_up(struct task_struct *p, unsigned int state, int wake_flags)
{
	unsigned long flags;
	int cpu, success = 0;

	preempt_disable();
	if (p == current) {
		/* 唤醒自己:只改状态,不需要迁移 */
		success = 1;
		goto out;
	}
	if (!(p->__state & state))
		goto unlock;                 /* 状态不匹配:不是我要等的事件 */

	if (p->on_rq && ttwu_remote(p, wake_flags))
		goto unlock;                 /* 已在某个队列里,只需让它可跑 */

	raw_spin_lock_irqsave(&p->pi_lock, flags);
	if (!(p->__state & state))
		goto unlock;                 /* 双重检查:拿锁期间可能已运行 */
	p->__state = TASK_WAKING;
	cpu = select_task_rq(p, p->wake_cpu, WF_TTWU);
	if (cpu != task_cpu(p))
		set_task_cpu(p, cpu);
	ttwu_queue(p, cpu, wake_flags);
	success = 1;
unlock:
	raw_spin_unlock_irqrestore(&p->pi_lock, flags);
out:
	preempt_enable();
	return success;
}

两个容易忽略的点:

  1. p->on_rq 的快路径:任务已经在队列里(比如被唤醒后又没跑),只需清掉阻塞状态,不必重新入队。ttwu_remote() 会尝试在目标 rq 上完成,避免拿本地锁。
  2. TASK_WAKING 是过渡态:它在 ttwu_do_activate() 里被改成 TASK_RUNNING。如果 crash 时看到大量 TASK_WAKING,说明有 CPU 卡在唤醒路径里(常见于 rq 锁争用或 stop 机器)。

新任务侧:wake_up_new_task

fork() 返回前,子进程由 wake_up_new_task() 放入队列:

/* kernel/sched/core.c: wake_up_new_task() —— 6.8 节选 */
void wake_up_new_task(struct task_struct *p)
{
	struct rq_flags rf;
	struct rq *rq;

	raw_spin_lock_irqsave(&p->pi_lock, rf.flags);
	p->__state = TASK_RUNNING;
	...
	rq = __task_rq_lock(p, &rf);
	update_rq_clock(rq);
	activate_task(rq, p, ENQUEUE_INITIAL);   /* 注意 flags 不是 WAKEUP */
	check_preempt_curr(rq, p, WF_FORK);
	task_rq_unlock(rq, p, &rf);
}

ENQUEUE_INITIAL 让 fair 类走 place_entity(..., initial=1):新任务的 vlag 被设成 0 附近,而不是继承父进程的负 lag。否则一个频繁 fork 的进程可以让子进程带着“欠账”出生,瞬间抢占。

tick 侧:时间片用完了吗

周期 tick 由 scheduler_tick() 分发:

/* kernel/sched/core.c: scheduler_tick() —— 6.8 节选 */
void scheduler_tick(void)
{
	int cpu = smp_processor_id();
	struct rq *rq = cpu_rq(cpu);
	struct task_struct *curr = rq->curr;

	sched_clock_tick();
	rq_lock(rq, &rf);
	update_rq_clock(rq);
	curr->sched_class->task_tick(rq, curr, 0);
	...
}

fair 类的 task_tick_fair() 最终调用 check_preempt_tick():如果当前实体已跑的虚拟时间超过它的 slice(EEVDF)或按权重算出的理想片长(CFS),就 resched_curr(rq)。RT 类的 task_tick_rt() 则递减 time_slice,RR 用完重置并轮转,FIFO 不动。

resched_curr() 只做两件事:置 TASK_NEED_RESCHED,必要时向其他 CPU 发 IPI。真正的切换发生在下一个抢占点:

抢占点触发条件典型位置
返回用户态TIF_NEED_RESCHEDexit_to_user_mode_loop()
中断返回同上irqentry_exit()
系统调用返回同上syscall_exit_to_user_mode()
preempt_enable()PREEMPT 内核且计数归零preempt_schedule()
显式 cond_resched()需要重调度且不在原子上下文长循环、拷贝、分配
显式 schedule()调用者主动让出等锁、等 I/O
为什么“置了 need_resched 却不立刻切”

内核代码可能正持有 spinlock、正在中断上下文、或正处于 preempt_disable() 区段。此时切走会让锁永远没人释放,或让 per-CPU 状态错乱。所以“要求重调度”与“执行重调度”必须分开:前者任何上下文都能做,后者只在安全点做。preempt_count() 就是这把安全门闩。

一次切换的完整路径

/* kernel/sched/core.c: __schedule() —— 6.8 节选,删去锁与统计 */
static void __sched notrace __schedule(unsigned int sched_mode)
{
	struct task_struct *prev, *next;
	struct rq *rq = this_rq();
	struct rq_flags rf;

	rq_lock(rq, &rf);
	prev = rq->curr;
	update_rq_clock(rq);
	clear_tsk_need_resched(prev);
	...
	if (prev->sched_class != &idle_sched_class)
		put_prev_task(rq, prev);
	next = pick_next_task(rq, prev, &rf);
	if (likely(prev != next)) {
		rq->nr_switches++;
		rq->curr = next;
		prepare_task_switch(rq, prev, next);
		context_switch(rq, prev, next, &rf);
	} else {
		rq->clock_update_flags &= ~RQCF_REQ_SKIP;
	}
	rq_unlock_irq(rq, &rf);
}

context_switch() 里,若 prev->mm != next->mm 则 switch_mm_irqs_off() 换页表;随后 switch_to(prev, next, prev) 保存 callee-saved 寄存器与栈指针。新任务从它上次调用 schedule() 的下一行继续,可能是几毫秒前,也可能是几小时前。

失败路径:调度不会“失败”,但会卡住

调度本身没有 errno,它的故障形态是卡住或延迟:

现象根因证据
BUG: scheduling while atomic在 preempt_count != 0 时调 schedule()dmesg + preempt_count
任务永远不跑唤醒丢失:先检查条件再设状态,条件在中间被满足TASK_UNINTERRUPTIBLE + 无 wakeup 事件
唤醒后很久才跑目标 CPU 忙、wakeup_granularity 阻止抢占、选核选错perf sched latency、bpftrace runqlat
切换风暴频繁唤醒/睡眠、锁竞争perf sched 的 switch 次数、/proc/PID/sched 的 nr_switches
RT 任务不抢占sched_rt_runtime_us 限流、preempt_count、中断关闭/proc/sys/kernel/sched_rt_runtime_us、sched_debug

丢失唤醒的经典写法错误:

/* 错误:检查与睡眠之间没有原子性 */
if (!condition)
	schedule();          /* 如果 condition 在这行之前变真,wakeup 已经发生,睡死 */

/* 正确:先设状态,再检查;用 smp_mb() 保证顺序 */
set_current_state(TASK_INTERRUPTIBLE);
if (!condition)
	schedule();
__set_current_state(TASK_RUNNING);

sleep_on() 这类老接口之所以被删除,就是因为它把“设状态”和“检查条件”拆成了两个不可原子组合的步骤。

唤醒路径的五个坑
  1. “wake_up_process() 返回 0 就是失败”:它返回“是否真的唤醒了”(状态匹配且入队成功)。返回 0 常见于任务已经在跑,不是错误。
  2. “wake_up() 叫不醒可中断睡眠”:TASK_NORMAL 就是 TASK_INTERRUPTIBLE | TASK_UNINTERRUPTIBLE,wake_up() 两者都匹配;区别在 wake_up_interruptible() 只匹配可中断睡眠者。反过来,TASK_KILLABLE 的任务只有信号或 wake_up() 才能叫醒。
  3. “sched_setscheduler() 成功就立刻生效”:策略切换要拿 rq 锁、可能触发迁移,返回时新策略已写入,但任务可能还在旧 CPU 上跑一个时间片。
  4. “cond_resched() 会强制切换”:它只在 need_resched 已置位且不在原子上下文时让出;没有待调度任务时是空操作。
  5. “need_resched 是 per-task 的”:6.x 里它是 task_struct->thread_info.flags 的一位(TIF_NEED_RESCHED),per-task,不是全局。

3.5.10 优先级、策略与用户态接口:五条路与各自的 errno

改“谁先跑”在用户态有五条路:nice()、setpriority()、sched_setscheduler()、sched_setattr()、sched_setaffinity()。它们改的是不同字段,失败语义也完全不同。这一节把接口、参数边界与 errno 对齐。

五组接口

接口头文件改什么成功返回失败与 errno
nice(int inc)<unistd.h>static_prio(相对增减)新的 nice 值失败 −1,errno=EPERM(超出 RLIMIT_NICE)
setpriority(int which, id_t who, int prio)<sys/resource.h>指定进程/组/用户的 nice0−1:EINVAL(which 非法或 prio 越界)、EACCES/EPERM(无权降低 nice)、ESRCH
getpriority(int which, id_t who)同上读 nice20−nice(不是 nice)失败 −1;必须靠 errno 区分“合法值 −1”
sched_setscheduler(pid_t pid, int policy, const struct sched_param *p)<sched.h>策略 + RT 优先级0−1:EINVAL(策略未知/优先级越界)、EPERM、ESRCH、EFAULT
sched_setattr(pid_t pid, struct sched_attr *attr, unsigned int flags)<linux/sched.h> + syscall策略、nice、RT、DEADLINE、flags0−1:E2BIG(size 小于内核期望)、EINVAL、EBUSY(DEADLINE 准入失败)、EPERM、ESRCH

getpriority() 的返回值是全书最容易被误用的接口之一:它返回 20 - nice,所以合法返回值范围是 1–40(nice −20..19),只有 −1 表示出错。判断必须先 errno = 0,调用后若返回 −1 且 errno != 0 才是错。

策略与“优先级”的对应关系

策略值用哪个优先级范围时间片
SCHED_NORMAL0static_prio = 120 + nice100–139动态(EEVDF slice)
SCHED_BATCH3同上100–139同 fair,唤醒抢占更保守
SCHED_IDLE5权重极低(WEIGHT_IDLEPRIO)—只在无其他任务时跑
SCHED_FIFO1rt_priority1–99无,跑到阻塞/让出
SCHED_RR2rt_priority1–99sched_rr_timeslice_ms(默认 100 ms)
SCHED_DEADLINE6不用优先级—runtime / deadline / period

SCHED_DEADLINE 的 sched_priority 必须为 0;内核会把 prio 设为 −1,normal_prio 也随之改变。用 sched_getscheduler() 读回 6,用 sched_getparam() 读到的 sched_priority 是 0——这不是失败。

DEADLINE 的准入控制

sched_setattr() 设置 DEADLINE 时,内核做三层检查:

1. 参数合法性:runtime > 0 且 runtime <= deadline <= period
   → 违反返回 EINVAL
2. 单任务可调度:runtime/period 不超过 1,且 deadline 不小于 runtime
   → 违反返回 EINVAL
3. 全局准入:所有 DEADLINE 任务的 runtime/period 之和 <= 根域带宽(默认 95%)
   → 违反返回 EBUSY

三层检查的顺序很重要:先 EINVAL 后 EBUSY。所以看到 EBUSY,说明参数本身合法,只是“这台机器已经被实时任务订满了”;看到 EINVAL,先检查 runtime <= deadline <= period 是否写反。

/* 参数合法的三个不等式,按这个顺序检查 */
if (attr->sched_runtime == 0 ||
    attr->sched_deadline < attr->sched_runtime ||
    attr->sched_period < attr->sched_deadline)
	return -EINVAL;

一个 4 核机器上,已经接纳了 3 个 runtime=1ms, period=5ms 的任务(每个占 20%),总带宽 60%。第四个同样参数的任务会被接纳(80% < 95%);第五个会被 EBUSY 拒绝(100% > 95%)。这里的百分比是单 CPU 带宽的占比;多核机器上每个 CPU 有独立的根域带宽。

RLIMIT:为什么普通用户连 nice 都降不了

限制默认作用超限后果
RLIMIT_NICE0允许的最高 nice:20 - rlim_cursetpriority() 返回 EACCES/EPERM
RLIMIT_RTPRIO0允许的最高 RT 优先级sched_setscheduler() 返回 EPERM
RLIMIT_RTTIME无限制连续运行不阻塞的最长时间(µs)收到 SIGXCPU,默认终止

RLIMIT_NICE=0 的含义不是“nice 必须为 0”,而是“不能把 nice 调低到比当前更低”。普通用户可以把 nice 从 0 调到 10(让出 CPU),但不能调到 −5(抢 CPU)。RLIMIT_RTPRIO=0 时,即使有 CAP_SYS_NICE 也不看这个限制;两者是“或”的关系。

autogroup:nice 实验的隐形搅局者

CONFIG_SCHED_AUTOGROUP 打开时(多数桌面发行版默认开),每个 session/tty 会被放进一个 task group,组内再按 nice 分。效果是:你在一个终端里把编译进程 nice 调到 19,它只影响这个组内的份额,不会把整个桌面的 CPU 让给另一个终端的视频播放器。

cat /proc/sys/kernel/sched_autogroup_enabled    # 1 = 开
cat /proc/self/autogroup                         # /autogroup-123 nice=0

做 3.5.14 的 nice 份额实验时,如果结果偏离理论值,先确认 autogroup 是否在起作用:echo 0 > /proc/sys/kernel/sched_autogroup_enabled 后再测一遍。它不是 bug,是“nice 的作用域被组隔离了”。

一个可运行的接口探针

下面这个程序把五条路里的三条走一遍:查询、切 FIFO、切 DEADLINE(含错误参数),并把 errno 原样打印。

/* sched_api.c — 查询/切换调度策略,观察 EPERM/EINVAL/EBUSY
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o sched_api sched_api.c
 * 运行: ./sched_api show | fifo 50 | deadline 1000000 5000000 10000000 | reset
 */
#define _GNU_SOURCE
#include <errno.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <linux/sched.h>

static const char *policy_name(int p)
{
	switch (p) {
	case SCHED_NORMAL:   return "SCHED_NORMAL";
	case SCHED_FIFO:     return "SCHED_FIFO";
	case SCHED_RR:       return "SCHED_RR";
	case SCHED_BATCH:    return "SCHED_BATCH";
	case SCHED_IDLE:     return "SCHED_IDLE";
	case SCHED_DEADLINE: return "SCHED_DEADLINE";
	default:             return "?";
	}
}

static void show(void)
{
	struct sched_param sp = {0};
	int pol = sched_getscheduler(0);
	int rc = sched_getparam(0, &sp);

	errno = 0;
	int nice = getpriority(PRIO_PROCESS, 0);
	if (nice == -1 && errno != 0) {
		fprintf(stderr, "getpriority: %s\n", strerror(errno));
		return;
	}
	printf("policy=%s(%d) rt_prio=%d nice=%d getparam_rc=%d\n",
	       policy_name(pol), pol, sp.sched_priority, nice, rc);
}

static int set_fifo(int prio)
{
	struct sched_param sp = {.sched_priority = prio};

	if (sched_setscheduler(0, SCHED_FIFO, &sp) == -1) {
		fprintf(stderr, "sched_setscheduler(FIFO,%d): %s\n", prio, strerror(errno));
		return -1;
	}
	return 0;
}

static int set_deadline(unsigned long long runtime, unsigned long long deadline,
			unsigned long long period)
{
	struct sched_attr attr;

	memset(&attr, 0, sizeof attr);
	attr.size = sizeof attr;
	attr.sched_policy = SCHED_DEADLINE;
	attr.sched_runtime = runtime;
	attr.sched_deadline = deadline;
	attr.sched_period = period;

	/* glibc 旧版本没有 sched_setattr() 包装,直接走系统调用 */
	if (syscall(SYS_sched_setattr, 0, &attr, 0) == -1) {
		fprintf(stderr, "sched_setattr(DEADLINE %llu/%llu/%llu): %s\n",
			runtime, deadline, period, strerror(errno));
		return -1;
	}
	return 0;
}

static int set_reset_on_fork(void)
{
	struct sched_attr attr;

	memset(&attr, 0, sizeof attr);
	attr.size = sizeof attr;
	attr.sched_policy = SCHED_FIFO;
	attr.sched_priority = 10;
	attr.sched_flags = SCHED_FLAG_RESET_ON_FORK;

	if (syscall(SYS_sched_setattr, 0, &attr, 0) == -1) {
		fprintf(stderr, "sched_setattr(RESET_ON_FORK): %s\n", strerror(errno));
		return -1;
	}
	return 0;
}

int main(int argc, char *argv[])
{
	if (argc < 2 || strcmp(argv[1], "show") == 0) {
		show();
		return 0;
	}
	if (strcmp(argv[1], "fifo") == 0) {
		int prio = argc > 2 ? atoi(argv[2]) : 50;
		if (set_fifo(prio) == -1)
			return 1;
		show();
		return 0;
	}
	if (strcmp(argv[1], "deadline") == 0) {
		unsigned long long rt = argc > 2 ? strtoull(argv[2], NULL, 0) : 1000000ULL;
		unsigned long long dl = argc > 3 ? strtoull(argv[3], NULL, 0) : 5000000ULL;
		unsigned long long pe = argc > 4 ? strtoull(argv[4], NULL, 0) : 10000000ULL;
		if (set_deadline(rt, dl, pe) == -1)
			return 1;
		show();
		return 0;
	}
	if (strcmp(argv[1], "reset") == 0) {
		if (set_reset_on_fork() == -1)
			return 1;
		show();
		return 0;
	}
	fprintf(stderr, "usage: %s show|fifo PRIO|deadline RT DL PERIOD|reset\n", argv[0]);
	return 2;
}

预期输出(Ubuntu 24.04,普通用户):

$ ./sched_api show
policy=SCHED_NORMAL(0) rt_prio=0 nice=0 getparam_rc=0
$ ./sched_api fifo 50
sched_setscheduler(FIFO,50): Operation not permitted
$ sudo ./sched_api fifo 50
policy=SCHED_FIFO(1) rt_prio=50 nice=0 getparam_rc=0
$ sudo ./sched_api deadline 6000000 5000000 10000000
sched_setattr(DEADLINE 6000000/5000000/10000000): Invalid argument

最后一条把 runtime 写得比 deadline 大,触发 EINVAL。把参数改成 1000000 5000000 10000000 就会成功并打印 policy=SCHED_DEADLINE(6)。

优先级接口的六个坑
  1. “getpriority() 返回 −1 就是出错”:nice −1 是合法值(对应返回 21)。必须先清 errno 再判断。
  2. “nice() 能随便降”:没 CAP_SYS_NICE 时只能升高 nice(让出 CPU),降低受 RLIMIT_NICE 限制,失败 EPERM。
  3. “sched_setscheduler() 对线程和进程一样”:pid 是线程 ID 时只改那个线程;进程 ID 只改主线程。多线程程序要逐个 pthread_setaffinity_np()/sched_setscheduler()。
  4. “DEADLINE 的 sched_priority 写 50 也行”:必须为 0,否则 EINVAL。DEADLINE 不看 RT 优先级。
  5. “SCHED_RESET_ON_FORK 是策略位”:它是 sched_flags 里的一位,不是 policy 的值;fork 后子进程回到 SCHED_NORMAL 且 nice 归 0,是实时任务防“子孙继承”的标准做法。
  6. “sched_setattr() 的 flags 参数随便传”:目前必须为 0,非 0 返回 EINVAL。size 小于 SCHED_ATTR_SIZE_VER0 返回 E2BIG;大于内核已知大小时,多出的尾部必须全零,否则同样失败。

3.5.11 选核与亲和:wake_affine、select_idle_sibling 与掩码的硬约束

唤醒时“选哪个 CPU”是放置策略的核心。它不是“找最闲的核”这么简单:缓存亲和、NUMA 本地性、算力不对称、亲和掩码都在约束这个选择。这一节走一遍 select_task_rq_fair() 的决策,并把亲和掩码的失败路径钉死。

选核的决策顺序

select_task_rq(p, prev_cpu, WF_TTWU)
 └─ select_task_rq_fair(p, prev_cpu, sd_flag, wake_flags)
     ├─ 1. 快速路径:p 的亲和掩码只有一个 CPU → 直接返回它
     ├─ 2. wake_affine:唤醒者与被唤醒者共享缓存?
     │      WF_SYNC(唤醒者马上要睡)+ 同一 LLC → 拉到唤醒者所在 CPU
     ├─ 3. select_idle_sibling(p, prev_cpu, target)
     │      ├─ target 空闲 → 就用 target
     │      ├─ recent_used_cpu 空闲 → 用它(缓存还热)
     │      ├─ select_idle_cpu:在 LLC 内找空闲核
     │      └─ 都不空闲 → 返回 target(负载均衡稍后处理)
     └─ 4. 慢路径:find_idlest_group_cpu() / EAS 的 find_energy_efficient_cpu()

WF_SYNC 是理解“为什么管道/套接字唤醒延迟低”的关键:生产者写完管道后通常立刻睡眠,唤醒者与被唤醒者在同一核上交替跑,缓存全热,比“搬到空闲核”更快。反之,如果唤醒者还要继续跑,把它拉到同一核只会互相抢 CPU,wake_affine() 会拒绝。

代码路径

/* kernel/sched/fair.c: select_task_rq_fair() —— 6.8 节选,已简化 */
static int select_task_rq_fair(struct task_struct *p, int prev_cpu, int wake_flags)
{
	int sync = (wake_flags & WF_SYNC) && !(current->flags & PF_EXITING);
	struct sched_domain *tmp, *sd = NULL;
	int cpu = smp_processor_id();
	int new_cpu = prev_cpu;
	int want_affine = 0;

	if (wake_flags & WF_TTWU) {
		/* 只有唤醒路径才考虑亲和与空闲选核 */
		if (sched_energy_enabled()) {
			new_cpu = find_energy_efficient_cpu(p, prev_cpu);
			if (new_cpu >= 0)
				return new_cpu;
		}
		want_affine = !wake_wide(p) && cpumask_test_cpu(cpu, p->cpus_ptr);
	}
	...
	if (want_affine && (tmp = fetch_curr_sched_domain(cpu)) &&
	    cpumask_test_cpu(prev_cpu, sched_domain_span(tmp))) {
		if (wake_affine_idle(cpu, prev_cpu, sync))
			new_cpu = cpu;
		/* 其余情况由 wake_affine() 比较两侧负载,划算才拉过来 */
	}
	return select_idle_sibling(p, prev_cpu, new_cpu);
}

两个细节:

  • wake_wide(p) 检查“这个任务是否在多个 CPU 上频繁唤醒”。如果是(比如多线程服务器),把它拉向唤醒者会造成惊群,want_affine 被关掉。
  • sched_energy_enabled() 为真时先试 EAS;EAS 返回 −1 表示“没有更省电的合适核”,回落到常规路径。服务器上通常没有 energy model,直接跳过。

亲和掩码是硬约束

sched_setaffinity() 最终调用 set_cpus_allowed_ptr():

/* kernel/sched/core.c: __set_cpus_allowed_ptr() —— 6.8 节选,已简化 */
int __set_cpus_allowed_ptr(struct task_struct *p,
			   struct affinity_context *ctx)
{
	...
	if (cpumask_equal(p->cpus_ptr, ctx->new_mask))
		return 0;                 /* 没变,直接成功 */
	...
	if (!cpumask_intersects(ctx->new_mask, cpu_active_mask)) {
		ret = -EINVAL;            /* 掩码里没有在线 CPU */
		goto out;
	}
	...
	set_cpus_allowed_ptr_locked(p, ctx, rq, &rf);
	if (!cpumask_test_cpu(task_cpu(p), ctx->new_mask)) {
		/* 当前 CPU 不在新掩码里:强制迁移 */
		if (task_is_running(p))
			set_tsk_need_resched(p);
		...
	}
}

“强制迁移”不是立刻把正在跑的任务拽走,而是置 migration_pending,在下一个调度点通过 stop 类完成。所以 sched_setaffinity() 返回 0 时,任务可能还在旧 CPU 上执行最后几条指令。

失败矩阵

/* affinity_errno.c — 亲和性 API 的成功/失败矩阵
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o affinity_errno affinity_errno.c
 * 运行: ./affinity_errno
 */
#define _GNU_SOURCE
#include <errno.h>
#include <sched.h>
#include <stdio.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>

static void report(const char *what, long rc, int saved)
{
	printf("%-38s rc=%2ld errno=%2d (%s)\n", what, rc,
	       saved, saved ? strerror(saved) : "-");
}

int main(void)
{
	cpu_set_t orig, set;
	long ncpu = sysconf(_SC_NPROCESSORS_CONF);
	int rc, saved;

	if (ncpu < 1)
		ncpu = 1;
	if (sched_getaffinity(0, sizeof orig, &orig) == -1) {
		perror("sched_getaffinity");
		return 1;
	}

	/* 1. 空掩码:内核拒绝 */
	CPU_ZERO(&set);
	errno = 0;
	rc = sched_setaffinity(0, sizeof set, &set);
	saved = errno;
	report("empty mask", rc, saved);

	/* 2. 不存在的 pid:ESRCH */
	CPU_ZERO(&set);
	CPU_SET(0, &set);
	errno = 0;
	rc = sched_setaffinity(999999, sizeof set, &set);
	saved = errno;
	report("pid 999999", rc, saved);

	/* 3. 长度不足:内核对 len < cpumask_size 返回 EINVAL */
	errno = 0;
	rc = syscall(SYS_sched_setaffinity, 0, 1, &set);
	saved = errno;
	report("len=1 (raw syscall)", rc, saved);

	/* 4. CPU 编号超出 nr_cpu_ids:EINVAL */
	if (ncpu < CPU_SETSIZE) {
		CPU_ZERO(&set);
		CPU_SET((int)ncpu, &set);
		errno = 0;
		rc = sched_setaffinity(0, sizeof set, &set);
		saved = errno;
		report("cpu == nr_cpu_ids", rc, saved);
	}

	/* 5. 合法集合:读回来确认内核没有“四舍五入” */
	CPU_ZERO(&set);
	CPU_SET(0, &set);
	errno = 0;
	rc = sched_setaffinity(0, sizeof set, &set);
	saved = errno;
	report("only cpu0", rc, saved);
	if (rc == 0) {
		cpu_set_t got;

		if (sched_getaffinity(0, sizeof got, &got) == 0)
			printf("readback: cpu0=%d count=%d\n",
			       CPU_ISSET(0, &got), CPU_COUNT(&got));
	}

	/* 恢复原掩码,避免影响后续 shell 子进程 */
	if (sched_setaffinity(0, sizeof orig, &orig) == -1)
		perror("restore affinity");
	return 0;
}

在 8 核 Ubuntu 上预期:

empty mask                             rc=-1 errno=22 (Invalid argument)
pid 999999                             rc=-1 errno= 3 (No such process)
len=1 (raw syscall)                    rc=-1 errno=22 (Invalid argument)
cpu == nr_cpu_ids                      rc=-1 errno=22 (Invalid argument)
only cpu0                              rc= 0 errno= 0 (-)
readback: cpu0=1 count=1

如果第 5 行返回 EINVAL,先查容器/工作负载的 cpuset:CPU 0 可能不在允许集合里。sched_setaffinity() 的掩码必须与 cgroup cpuset.cpus 有交集,否则内核拒绝——这不是“亲和性建议”,是“允许集合”。

亲和性的五个坑
  1. “掩码可以写任意 CPU 编号”:必须 < nr_cpu_ids 且属于当前 cpuset;否则 EINVAL。容器里尤其容易踩到。
  2. “sched_getaffinity() 用 sizeof(cpu_set_t) 一定够”:内核的 cpumask 可能大于 1024 位;CPU_SETSIZE 是 glibc 的编译期上限。超大机器要用 CPU_ALLOC() 动态分配。
  3. “绑定后立刻生效”:当前 CPU 不在新掩码里时,内核置 migration_pending,在下一个调度点迁移;返回 0 不代表已经在目标核上。
  4. “亲和性包含 I/O 线程”:cpus_ptr 只约束 CPU 放置,不约束中断、workqueue、io_uring 的 worker。它们有各自的掩码(/proc/irq/N/smp_affinity、/sys/devices/virtual/workqueue/*/cpumask)。
  5. “isolcpus 等于禁止使用”:它只是把 CPU 从调度域摘掉,显式 sched_setaffinity() 仍可用。真正的“禁止其他任务进入”要用 cpuset.cpus.partition=isolated,并保证兄弟组里没有任务(否则返回 EBUSY)。

3.5.12 负载、利用率与 PELT:调度器怎么“看见”过去

/proc/PID/sched 里有一组 se.avg.* 字段:load_avg、runnable_avg、util_avg。它们不是瞬时值,而是指数衰减的平均。负载均衡、选核、调频都靠这组数判断“这个任务有多重”,所以必须先弄清它们测的是什么、滞后多久。

三个平均值的区别

字段统计对象含排队时间带权重典型读者
runnable_avg任务处于可运行(running + runnable)的时间占比是否组调度的 runnable_weight
util_avg任务真正占用 CPU 的时间占比否否EAS、选核、cpu.util
load_avgrunnable_avg × weight / 1024是是负载均衡的 group_load
util_est唤醒时对 util_avg 的修正(6.0+)—否选核,避免“刚醒的任务看起来很轻”

一句话:util_avg 回答“占了多少 CPU”,load_avg 回答“按权重折算后有多重”,runnable_avg 回答“有多少时间想跑但没跑上”。三者只在任务持续运行时接近;任务频繁睡眠时,runnable_avg 会明显高于 util_avg。

PELT 的衰减模型

PELT(Per-Entity Load Tracking)把时间切成 1024 µs 的 period,每个 period 把旧值乘一个衰减因子:

y^32 = 0.5        →  y ≈ 0.97857206
decayed = old × y^n + new × (1 - y^n)

半衰期是 32 个 period,约 32.8 ms。含义:

  • 一个任务持续跑满 32 ms,util_avg 接近 1024;停下 32 ms 后掉到约 512;停下 200 ms 后接近 0。
  • 突发的短任务(跑 1 ms 睡 100 ms)在 util_avg 上几乎看不见,这就是 util_est 存在的原因。
  • 衰减用 decay_load(v, n) 查表 runnable_avg_yN_inv[] 做乘移,避免浮点。

内核路径:

阶段函数(6.8)文件
运行时间记账update_curr() → update_load_avg(cfs_rq, se, UPDATE_TG)kernel/sched/fair.c
入队/出队enqueue_entity() / dequeue_entity() 调 update_load_avg()同上
周期更新update_blocked_averages() → __update_blocked_others()同上
求和___update_load_sum()kernel/sched/pelt.c
求平均___update_load_avg()同上
衰减decay_load() / accumulate_sum()同上

update_load_avg() 的骨架(删去组调度与 util_est):

/* kernel/sched/fair.c: update_load_avg() —— 6.8 节选,已简化 */
static inline void update_load_avg(struct cfs_rq *cfs_rq,
				   struct sched_entity *se, int flags)
{
	u64 now = cfs_rq_clock_pelt(cfs_rq);

	if (se->avg.last_update_time && (flags & DO_ATTACH)) {
		/* 换了 CPU/队列:把旧队列的贡献搬过来 */
		update_load_avg_se(now, cfs_rq, se);
		return;
	}
	if (update_load_sum(se, now, flags))
		update_load_avg_se(now, cfs_rq, se);
}

cfs_rq_clock_pelt() 不是 rq_clock_task():它按 CPU 频率缩放(arch_scale_freq_capacity()),并在 steal time 上打折。所以同一段真实时间,在降频的核上“PELT 时间”更短——这是为了避免把“频率低”误判成“任务轻”。

从 /proc/PID/sched 读证据

$ cat /proc/self/sched
bash (1234, #threads: 1)
---------------------------------------------------------
se.exec_start                      :     987654.321000
se.vruntime                        :      1234.567890
se.sum_exec_runtime                :       456.789000
se.nr_migrations                   :                    7
se.avg.load_avg                    :                  512
se.avg.util_avg                    :                  256
se.avg.util_est.enqueued           :                  300
nr_switches                        :                 1234
nr_voluntary_switches              :                 1200
nr_involuntary_switches            :                   34
clock-delta                        :                   12
字段怎么读异常意味着什么
se.sum_exec_runtime累计真实运行时间(ns)与 se.exec_start 结合算本次已跑多久
se.nr_migrations迁移次数高 = 亲和性差或负载均衡频繁搬
se.avg.load_avg加权负载与 se.load.weight 对照,判断是否被组权重放大
se.avg.util_avg利用率(0–1024)远低于实际 CPU 占用 → 统计窗口内有大量睡眠
nr_voluntary_switches主动睡眠(等 I/O、等锁)高 = I/O 密集或锁竞争
nr_involuntary_switches被抢占/时间片耗尽高 = CPU 竞争或 RT 任务干扰
clock-delta本次 tick 与上次的差(ns)异常大 = tick 被延迟(关中断、虚拟化 steal)

capacity:利用率的分母

util_avg 的满分是 1024,但“1024 的利用率”在不同核上代表不同算力。内核用 capacity_of(cpu) 折算:

capacity = 静态算力(arch_scale_cpu_capacity)
         × 当前频率(arch_scale_freq_capacity)
         × 热压/降频修正

x86 同构机器上静态算力通常全是 1024;ARM big.LITTLE 上大核 1024、小核 512 之类。capacity_of() 的结果直接进入 group->sgc->capacity,负载均衡比较的是 load / capacity,不是绝对 load——这就是 3.6 节“小核簇与大核簇可比”的来源。

定义 3.5.4 · PELT 与 WALT 的区别

PELT 是时间衰减模型:每个 period 乘固定衰减因子,半衰期固定约 32 ms。WALT(Window Assisted Load Tracking,Android 常用)是窗口计数模型:把固定长度窗口(默认 20 ms)内的运行时间累加,窗口切换时整体滚动,不做逐 period 衰减。WALT 对突发负载更敏感,PELT 更平滑;主线 Linux 用 PELT,Android 部分设备用 WALT。两者都输出 0–1024 的“利用率”,但不可直接比较。

PELT 的四个误解
  1. “util_avg=1024 就是 CPU 满载”:它只说明“这个实体在过去一段时间几乎一直可运行/在运行”,还要除以 capacity_of(cpu) 才是该核的饱和度;跨核比较必须先归一化。
  2. “load_avg 可以跨进程比较大小”:它带权重。nice 0 的 50% 与 nice 10 的 50%,load_avg 相差近 10 倍。
  3. “PELT 是瞬时值”:半衰期 32 ms,一个持续 1 ms 的突发在 util_avg 上只留下约 3% 的痕迹;要抓突发用 util_est 或 schedstat。
  4. “clock-delta 大就是调度器有问题”:它可能只是 tick 被关中断/虚拟化延迟;对照 /proc/stat 的 steal 与 cyclictest 再下结论。

3.5.13 用 perf sched 与 bpftrace 把调度摊开

perf sched 与 bpftrace 都能测“等 CPU 的时间”,但口径不同。先明确口径,再谈数字,否则两个工具会给出互相矛盾的结果。

三种“延迟”不是一回事

指标起点终点工具依赖
runqueue latencysched_wakeup该任务 sched_switch 上 CPUbpftrace只需 tracepoint
wait delay任务进入可运行第一次上 CPUperf sched latencyCONFIG_SCHEDSTATS
scheduling delay被抢占/唤醒再次被调度perf sched timehistsched_switch

perf sched latency 的 Avg delay 用的是 sched_stat_wait,它统计“在 runqueue 里等的时间”,不含“被抢占后等的时间”;timehist 的 sch delay 则是从 sched_switch 事件推出来的。两者在任务频繁被抢占时会分叉。

perf sched 的四个子命令

# 1. 采集:-a 全系统,duration 秒
sudo perf sched record -a -- sleep 5

# 2. 每个任务的等待统计,按最大延迟排序
sudo perf sched latency --sort max

# 3. 时间轴:每次切换的 wait/sch delay/run time
sudo perf sched timehist --summary

# 4. ASCII 的 CPU×时间占用图(看迁移与空转)
sudo perf sched map

perf sched latency 的输出:

 Task                  | Runtime ms | Switches | Avg delay ms | Max delay ms | Max delay start     |
 bash                  |    12.345  |       45 |        0.123 |        2.345 | 1234.567890 secs    |
 kworker/u8:2          |     3.210  |       12 |        1.234 |       15.678 | 1234.901234 secs    |

读法:Runtime 小但 Max delay 大,说明任务“想跑但排不上”;Runtime 大且 Switches 少,说明它一直占着 CPU(可能是 CPU 密集或 RT)。Max delay start 是绝对时间戳,拿它去 dmesg/trace 里对齐其他事件。

perf sched timehist 的输出:

           time    cpu  task name                       wait time  sch delay   run time
        [sec]   [cpu]                                          [ms]       [ms]       [ms]
  1234.567890   [000]  bash                             0.000      0.000      0.123
  1234.568013   [002]  kworker/u8:2                    15.678      0.012      0.045

wait time 是从唤醒到上 CPU;sch delay 是从“上一次让出”到“这一次上 CPU”的调度器延迟;run time 是本次运行时长。三者相加不等于墙钟,因为任务可能多次睡眠。

bpftrace:按优先级/按 CPU 分桶

runqlat 的经典写法只按任务名聚合。下面这个版本把优先级也带上,用来区分“RT 抢了 CPU”还是“同优先级排队”:

sudo bpftrace -e '
tracepoint:sched:sched_wakeup
{
	@q[args->pid] = nsecs;
	@prio[args->pid] = args->prio;
}
tracepoint:sched:sched_switch
/@q[args->prev_pid]/
{
	$lat = (nsecs - @q[args->prev_pid]) / 1000;
	@us[comm, @prio[args->prev_pid]] = hist($lat);
	delete(@q[args->prev_pid]);
	delete(@prio[args->prev_pid]);
}
interval:s:10 { exit(); }'

注意 sched_switch 的 args->prev_pid 才是“刚被换下的任务”,args->next_pid 是“即将上 CPU 的任务”。用错字段会把延迟记到别人头上。

再看一眼迁移原因:

sudo bpftrace -e '
tracepoint:sched:sched_migrate_task
{
	@[args->comm, args->orig_cpu, args->dest_cpu] = count();
}
interval:s:10 { exit(); }'

输出里如果某个进程在 0→1、1→0 之间反复横跳,说明负载均衡或亲和性在抖;如果迁移集中在 numa 层,说明本地内存不足。

实验一:同一个核上的乒乓延迟

这个程序把两个线程绑到同一个 CPU,用两个管道来回传一个字节,测“写→对方醒→回写→自己醒”的往返时间。它测的是唤醒+切换的总开销,不是纯 runqueue 延迟。

/* sched_latency.c — 两个线程在同一个核上乒乓,测唤醒往返延迟
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -pthread -o sched_latency sched_latency.c
 * 运行: sudo ./sched_latency [fifo 优先级]   # 不带参数用 SCHED_OTHER
 */
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>

#define ROUNDS 20000

static int p2c[2];              /* pong -> ping */
static int c2p[2];              /* ping -> pong */
static int use_fifo, fifo_prio = 50;

static int cmp_ll(const void *a, const void *b)
{
	long long x = *(const long long *)a;
	long long y = *(const long long *)b;

	return (x > y) - (x < y);
}

static long long now_ns(void)
{
	struct timespec ts;

	clock_gettime(CLOCK_MONOTONIC, &ts);
	return (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
}

static void pin_cpu0(void)
{
	cpu_set_t set;

	CPU_ZERO(&set);
	CPU_SET(0, &set);
	if (sched_setaffinity(0, sizeof set, &set) == -1)
		fprintf(stderr, "sched_setaffinity: %s\n", strerror(errno));
}

static void maybe_fifo(void)
{
	struct sched_param sp = {.sched_priority = fifo_prio};

	if (use_fifo && sched_setscheduler(0, SCHED_FIFO, &sp) == -1)
		fprintf(stderr, "sched_setscheduler(FIFO): %s(继续用 SCHED_OTHER)\n",
			strerror(errno));
}

static void *pong(void *arg)
{
	char c;

	(void)arg;
	pin_cpu0();
	maybe_fifo();
	for (int i = 0; i < ROUNDS; i++) {
		if (read(c2p[0], &c, 1) != 1)
			break;
		if (write(p2c[1], &c, 1) != 1)
			break;
	}
	return NULL;
}

int main(int argc, char *argv[])
{
	pthread_t th;
	long long *samples;
	long long sum = 0, max = 0;
	int done = 0;
	char c = 'x';

	if (argc > 1) {
		use_fifo = 1;
		fifo_prio = atoi(argv[1]);
	}
	if (pipe(p2c) == -1 || pipe(c2p) == -1) {
		perror("pipe");
		return 1;
	}
	samples = calloc(ROUNDS, sizeof *samples);
	if (!samples) {
		perror("calloc");
		return 1;
	}
	if (pthread_create(&th, NULL, pong, NULL) != 0) {
		perror("pthread_create");
		return 1;
	}
	pin_cpu0();
	maybe_fifo();

	for (int i = 0; i < ROUNDS; i++) {
		long long t0, t1, dt;

		t0 = now_ns();
		if (write(c2p[1], &c, 1) != 1)
			break;
		if (read(p2c[0], &c, 1) != 1)
			break;
		t1 = now_ns();
		dt = t1 - t0;
		samples[i] = dt;
		sum += dt;
		if (dt > max)
			max = dt;
		done++;
	}
	pthread_join(th, NULL);

	/* 中位数:排序后取中间,避免个别调度抖动拉偏均值 */
	if (done == 0) {
		fprintf(stderr, "no samples\n");
		free(samples);
		return 1;
	}
	qsort(samples, (size_t)done, sizeof *samples, cmp_ll);
	printf("policy=%s rounds=%d avg=%lld us p50=%lld us p99=%lld us max=%lld us\n",
	       use_fifo ? "SCHED_FIFO" : "SCHED_OTHER", done,
	       sum / done / 1000, samples[done / 2] / 1000,
	       samples[done * 99 / 100] / 1000, max / 1000);
	free(samples);
	return 0;
}

预期量级(QEMU 双核,taskset -c 0):

$ taskset -c 0 ./sched_latency
policy=SCHED_OTHER rounds=20000 avg=8 us p50=7 us p99=21 us max=180 us
$ sudo taskset -c 0 ./sched_latency 50
policy=SCHED_FIFO rounds=20000 avg=4 us p50=3 us p99=9 us max=45 us

具体数字依赖机器。要看的是分布形状:SCHED_OTHER 的 p99 与 max 明显更长,因为每次唤醒都要经过 wakeup_preempt 的粒度检查;SCHED_FIFO 下 ping 写完立刻阻塞,pong 马上被选中,不再经过 CFS 的 wakeup_granularity 检查(除非 sched_rt_runtime_us 限流)。如果两者差不多,先确认是否真的绑在同一个核上、是否跑在 nohz_full 核上。

实验二:把 perf sched 与 bpftrace 对准同一段负载

#!/bin/bash
# sched_probe.sh — 采集 5 秒,输出三个视角
set -euo pipefail

DUR=${1:-5}
sudo perf sched record -a -- sleep "$DUR"
echo "===== 1. 谁等得最久 ====="
sudo perf sched latency --sort max | head -20
echo "===== 2. 时间轴摘要 ====="
sudo perf sched timehist --summary | head -20
echo "===== 3. runqueue 延迟分布(按优先级) ====="
sudo bpftrace -e '
tracepoint:sched:sched_wakeup { @q[args->pid] = nsecs; @pr[args->pid] = args->prio; }
tracepoint:sched:sched_switch
/@q[args->prev_pid]/
{
	@us[@pr[args->prev_pid]] = hist((nsecs - @q[args->prev_pid]) / 1000);
	delete(@q[args->prev_pid]);
	delete(@pr[args->prev_pid]);
}
interval:s:5 { exit(); }'

三个视角应当能对上:perf sched latency 里 Max delay 最大的任务,在 bpftrace 直方图里应有明显长尾;如果对不上,先检查 CONFIG_SCHEDSTATS 是否打开(没开时 perf sched latency 可能为空或全 0)。

读 perf sched 的顺序
  1. 先看 timehist --summary 的全局分布:wait time 的 p99 是否异常。
  2. 再看 latency --sort max 的前 10 名:找出具体任务。
  3. 用 map 看 CPU 空转:某 CPU 一整段空着,说明选核/亲和性有问题,不是“负载太高”。
  4. 用 bpftrace 分优先级看直方图:RT 任务的长尾通常来自限流或关中断,CFS 的长尾来自排队。
  5. 最后才调参数:base_slice_ns、sched_wakeup_granularity_ns、sched_rt_runtime_us。
例 3.5.2 · “CPU 空闲但延迟高”的一次排查

某 RPC 服务 16 核,mpstat 显示平均利用率 35%,但 P99 延迟周期性到 80 ms。perf sched latency --sort max 显示一个 rpc-worker 线程 Max delay 76 ms,Runtime 只有 0.4 ms;perf sched map 显示每次毛刺时 8 个 CPU 同时空转。

根因不是 CPU 不够,是唤醒选核:worker 被 select_idle_sibling() 放到一个刚被 isolcpus 隔离的核上,而该核上没有可运行的 buddy,任务在 runqueue 里等到下一个负载均衡周期。修法是把 cpuset 的允许集合与 isolcpus 对齐,而不是调 sched_latency。这个例子说明:perf sched 的 Max delay 要配合 map 的空转一起看,才能区分“排队”与“放错核”。

3.5.14 陷阱清单:调度篇

下面 14 条按“现象 → 原因 → 判断/修复”组织。它们覆盖版本差异、观测口径与工程误用,和前面各节的 errata 不重复。

#现象原因判断/修复
1按老文档调 sched_latency_ns,文件不存在6.6 起 EEVDF 用 base_slice_ns,旧 sysctl 移除去 /sys/kernel/debug/sched/base_slice_ns;先确认内核 ≥ 6.6
2/proc/sched_debug 打开是空的CONFIG_SCHED_DEBUG 未开,或 debugfs 没挂zcat /proc/config.gz | grep SCHED_DEBUG;mount -t debugfs none /sys/kernel/debug
3perf sched latency 全 0CONFIG_SCHEDSTATS 未开,sched_stat_wait 事件不存在改用 perf sched timehist 或 bpftrace runqlat
4两个 nice 进程份额不是 50:50autogroup 把会话分组,或 cgroup cpu.weight 在起作用关 autogroup 复测;cat /proc/PID/cgroup 看归属
5容器里 nproc 是 64,但只能跑 4 核的活nproc 读亲和掩码;cpu.max 是带宽配额两者分别看 Cpus_allowed_list 与 cpu.max
6绑核后延迟反而变大目标核被中断/kworker 占用,或 isolcpus 核没有本地内存mpstat -P ALL、/proc/interrupts、numastat
7SCHED_FIFO 进程把机器锁死FIFO 无时间片,优先级高于所有普通任务保留 sched_rt_runtime_us=950000;用 chrt -f 50 而非 99
8SCHED_DEADLINE 设置失败返回 EBUSY全局带宽已被占满(默认 95%)减小 runtime 或增大 period;cat /proc/sys/kernel/sched_rt_runtime_us
9sched_setaffinity 返回 EINVAL掩码为空、含不存在 CPU、或与 cpuset 无交集先 sched_getaffinity 读允许集合,再在交集内设置
10改了 nice 但 CPU 份额没变进程在 cgroup 里,组权重才是分配单位;或它大部分时间在睡眠看 cpu.weight 与 cpu.stat 的 usage_usec
116.x 内核模块编译报 p->state 不存在5.14 起字段改名为 __state用 p->__state;兼容写法用 READ_ONCE(p->__state)
12PREEMPT_RT 上 spin_lock 里睡眠没报错RT 把 spinlock_t 换成可睡眠的 rtmutex不要依赖“spinlock 里不能睡眠”的断言;显式用 raw_spinlock_t
13升级到 6.12 后多出 sched_ext 类6.12 引入 SCHED_EXT(7),可加载 BPF 调度器6.8 没有;grep CONFIG_SCHED_CLASS_EXT 确认
14perf sched map 里 CPU 频繁 0→1→0负载均衡抖动或 wake_affine 反复迁移看 sched_migrate_task 的调用栈;必要时用 cpuset 缩小范围

第 11 条值得单独强调:这不是“内核 API 不稳定”,而是 task_struct 的字段在 5.14 的改名。写跨版本模块时用 #if LINUX_VERSION_CODE 包一层,或者干脆通过 get_task_comm()/task_cpu() 这类稳定 helper 访问。

第 12 条是 RT 迁移中最常见的误解:PREEMPT_RT 下 spinlock_t 变成 sleeping lock,spin_lock() 内部可能调度;只有 raw_spinlock_t 保持“关抢占、忙等”。所以“spinlock 里不能睡眠”在 RT 上不再是硬约束,但“raw_spinlock 里不能睡眠”仍是。

3.5.15 实验:nice 的份额是不是线性的

写一个程序,fork 三个 CPU 密集子进程,nice 分别为 0、5、10,同时跑 2 秒,各自数循环次数。按权重表预测份额约 69.7% : 22.8% : 7.5%。

/* nice_demo.c — 验证 nice 权重对 CPU 份额的影响
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 -o nice_demo nice_demo.c
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

static double now_sec(void)
{
	struct timespec ts;
	clock_gettime(CLOCK_MONOTONIC, &ts);
	return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}

static unsigned long spin(double seconds)
{
	double end = now_sec() + seconds;
	unsigned long iters = 0;
	volatile unsigned long x = 1;
	while (now_sec() < end) {
		for (int i = 0; i < 10000; i++)
			x = x * 1103515245UL + 12345UL;
		iters += 10000;
	}
	return iters;
}

int main(void)
{
	const int nices[3] = { 0, 5, 10 };
	pid_t pids[3];

	for (int i = 0; i < 3; i++) {
		pids[i] = fork();
		if (pids[i] == 0) {
			setpriority(PRIO_PROCESS, 0, nices[i]);
			unsigned long it = spin(2.0);
			printf("nice=%2d iters=%lu\n", nices[i], it);
			_exit(0);
		}
	}
	for (int i = 0; i < 3; i++)
		waitpid(pids[i], NULL, 0);
	return 0;
}

在双核以上的机器上跑,三个进程会分到不同 CPU,测不出份额;用 taskset -c 0 ./nice_demo 绑到同一个核。预期输出接近:

nice= 0 iters=...
nice= 5 iters=...(约为 nice 0 的 30%)
nice=10 iters=...(约为 nice 0 的 10%)

实际比例会偏离理论值,原因有三:spin() 里的 clock_gettime 是系统调用(走 vDSO,但仍计入用户时间)、调度器有唤醒粒度、setpriority 在 fork 之后才生效(有一小段以 nice 0 运行)。这些偏差正好说明:份额是长期平均,不是瞬时保证。

例 3.5.1 · 一个「CPU 100% 但业务很慢」的案例

某服务 8 核机器,top 显示 %Cpu 接近 800%,但 QPS 只有预期的一半。pidstat 显示一个日志线程占了一个整核,其余 7 核被业务线程均分。perf sched latency 里业务线程平均等待 30 ms。

根因不是 CPU 不够,是日志线程与业务线程同权重,它按 1/8 的份额稳定占满一个核,而业务线程因为锁竞争频繁睡眠/唤醒,每次唤醒都要排队。处理:把日志线程的 nice 调到 10,并把它绑到单独的核(taskset),业务线程的等待降到 2 ms。这个例子说明 nice 是「比例工具」,配合绑核才能做「隔离」。

3.5.16 本章出口检查

检查项达标线没达标去哪
画出 rq/cfs_rq/sched_entity 的层级能说出 tasks_timeline 的排序键3.5.1、3.5.3
用权重表算份额给出两个 nice 值的百分比3.5.2
说清 vruntime 的加权权重越大涨得越慢3.5.3
说清 EEVDF 的两步选择eligible + earliest deadline3.5.4
解释组调度解决什么问题线程数不该放大份额3.5.5
说出 RT throttling 的默认值950 ms / 1 s,关掉的风险3.5.6
用 perf sched latency 找排队能读 Avg/Max delay3.5.7
说出四个优先级字段的分工static_prio/normal_prio/prio/rt_priority3.5.8
画出一次唤醒到切换的路径try_to_wake_up → enqueue_task → wakeup_preempt3.5.9
说清 DEADLINE 的 EINVAL 与 EBUSY参数非法 vs 带宽不足3.5.10
解释 select_idle_sibling 的放置顺序target → recent → idle → 回落3.5.11
区分 util_avg 与 load_avg一个不含权重、一个含权重3.5.12
对齐 perf sched 与 runqlat 的口径三个“延迟”定义不同3.5.13
即时自测 · 3.5

Q1 两个 CPU 密集进程,nice 都是 0,在单核上各占 50%。如果把其中一个改成 nice 1,它的份额变成多少?

Q2 vruntime 与真实运行时间的关系是什么?nice 10 的进程跑 1 ms,vruntime 增加多少(以 nice 0 为基准)?

Q3 CFS 里新创建的进程为什么不会因为「vruntime 从 0 开始」而抢跑?EEVDF 下对应的机制是什么?

Q4 SCHED_FIFO 优先级 50 的进程和 SCHED_DEADLINE 的任务同时可运行,谁先跑?为什么?

Q5 cgroup 里设了 cpu.weight=100 和 cpu.max=50000 100000,实际会怎样?两个参数各自管什么?

Q6 一个任务每秒只跑 2 ms,/proc/PID/sched 的 se.avg.util_avg 为什么远低于 2/1000×1024?util_est 又如何弥补?

Q7 perf sched latency 显示某任务 Max delay 50 ms,但同时间 bpftrace 的 runqlat 直方图里没有长尾。可能是什么原因?

答案

A1 权重从 1024 变成 820,份额 = 820/(1024+820) ≈ 44.5%。不是 40%,也不是 45%——比例是 w1/(w1+w2)。

A2 vruntime 是按权重折算的虚拟时间:Δvruntime = Δexec × 1024 / weight。nice 10 的权重是 110,跑 1 ms 的真实时间,vruntime 增加约 1 ms × 1024/110 ≈ 9.3 ms。

A3 CFS 在 place_entity() 里把新任务的 vruntime 抬到 cfs_rq->min_vruntime 附近(可能再加一个 slice 的补偿),使它不会凭 0 值插到最左边。EEVDF 用 vlag 表达同样的补偿:新任务的 lag 被设成一个受控值,entity_eligible() 会判断它是否「已经多拿」,避免抢占。

A4 SCHED_DEADLINE 先跑。调度类顺序是 stop → deadline → rt → fair → idle,pick_next_task() 从高到低问,deadline 类在 rt 类之上。RT 优先级 99 也压不过 deadline 任务。

A5 cpu.weight 决定同层竞争时的相对份额(A 组与兄弟组按 100 : 其他组权重分配);cpu.max 决定绝对上限(每 100 ms 最多 50 ms,超出被 throttle 到下一周期)。如果系统空闲,cpu.max 是唯一生效的约束;如果同层有竞争,cpu.weight 决定能抢到多少,但再抢也不超过 cpu.max。

A6 util_avg 是 PELT 的指数衰减平均,半衰期约 32 ms。每秒 2 ms 的占空比只有 0.2%,经过 32 ms 衰减后,单次 2 ms 的贡献几乎归零,稳定值远低于按占空比线性折算的 2。util_est 在唤醒时把“上一次实际用量”与历史 EWMA 取大者,暂时抬高预估,避免这种间歇任务被选到小核或低功耗状态。

A7 口径不同:perf sched latency 的 wait delay 来自 sched_stat_wait,只统计“在 runqueue 里等”的时间;bpftrace 的 runqlat 从 sched_wakeup 到 sched_switch 计时。若任务中途被抢占、或 sched_stat_wait 的起止点包含了迁移/唤醒竞争,两者就会分叉。先用 perf sched timehist 的 sch delay 做第三方对照,再决定信哪个。

母本对应

母本章节本书落点程度
奔跑吧 Linux 内核第 3 章 3.2 CFS 调度器3.5.2–3.5.3、3.5.8完整(vruntime、红黑树、权重、调度实体)
奔跑吧 Linux 内核第 3 章 3.2.6 PELT 算法改进3.5.12完整(负载跟踪与衰减)
奔跑吧 Linux 内核第 3 章 3.3.3 唤醒进程3.5.9、3.5.11完整(try_to_wake_up 与选核)
奔跑吧 Linux 内核第 3 章 3.7 实时调度3.5.6、3.5.10完整(策略与接口)
奔跑吧 Linux 内核第 3 章 3.8 最新更新与展望3.5.4补充(EEVDF 为 6.8 新增,原书未覆盖)
OSTEP第 7–9 章 调度:介绍/MLFQ/比例份额3.5.2、3.5.3对照(理论模型与 Linux 实现)
OSTEP第 10 章 多处理器调度3.5.5、3.5.11对照(多队列、缓存亲和、工作窃取)
李治军第 4 章 线程切换与调度3.5.1对照(0.11 的 schedule() 与 TSS 切换)
Kerrisk第 35 章 进程优先级与调度3.5.2、3.5.6、3.5.10完整(用户态接口、RLIMIT_*、sched_setattr)

下一站:3.6 多核、NUMA 与负载均衡 把视角从「一个队列」抬到「多个队列」:负载怎么在 CPU 之间搬、NUMA 的距离矩阵怎么影响放置、isolcpus 与 cgroup 的 CPU 控制器怎么配合。