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_class | SCHED_DEADLINE | 实时音视频、工业控制 |
| ↓ | rt_sched_class | SCHED_FIFO/SCHED_RR | 中断线程、实时任务 |
| ↓ | fair_sched_class | SCHED_NORMAL/BATCH/IDLE | 绝大多数进程 |
| 最低 | idle_sched_class | idle | 每 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() 的地方继续。
内核用 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 值的真实含义
每个 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 的份额 | 两个这样的进程各占 |
|---|---|---|---|
| −20 | 88761 | 86.7× | 97.7% |
| −10 | 9548 | 9.32× | 90.3% |
| −5 | 3121 | 3.05× | 75.3% |
| 0 | 1024 | 1.00× | 50.0% |
| 5 | 335 | 0.327× | 24.6% |
| 10 | 110 | 0.107× | 9.7% |
| 19 | 15 | 0.0146× | 1.4% |
这张表说明两件事:
- nice 是比例,不是绝对优先级。两个 nice 0 的进程各 50%;一个 nice 0 加一个 nice 10,比例是 1024:110,前者拿 90.3%。
- 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 第 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 是完全公平的」:名字里的 completely 指「按权重公平」,不指「每个进程时间相同」。权重不同,份额就不同。
- 「
vruntime就是运行时间」:它是加权后的虚拟时间。nice 0 的任务跑 1 ms,vruntime加 1 ms;nice 10 的任务跑 1 ms,vruntime加约 9.3 ms。 - 「新进程会立刻抢占」:
place_entity()会把新进程的vruntime补偿到min_vruntime附近,避免它凭「从 0 开始」抢跑。唤醒抢占还受sched_wakeup_granularity限制。 - 「
min_granularity保证每个进程至少跑 0.75 ms」:它只在「按比例算出的切片太小」时兜底,任务数极多时总延迟会远超sched_latency。 - 「调小
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 折算到虚拟时间 |
vlag | lag(滞后量):vruntime - min_vruntime 的修正项,用于判断「是否有资格」 |
选择规则变成两步:
- 资格(eligible):只考虑
lag <= 0的实体,即「还没有拿到超过自己公平份额」的。这保证公平——已经多拿的没资格再抢。 - 最早截止(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;
}
把 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_vruntime | place_entity() 设 vlag,可正可负 |
| 唤醒抢占 | 启发式 + wakeup_granularity | 比较 deadline + wakeup_granularity |
| 调优参数 | latency_ns、min_granularity_ns | base_slice_ns |
| 主要问题 | 唤醒延迟不可预测 | 参数少,但 slice 语义需要重新理解 |
奔跑吧第 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_FIFO | 1–99 | 无(跑到阻塞或主动让) | 同优先级按 FIFO,先来先跑 |
SCHED_RR | 1–99 | 100 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 会明显变大——这就是「实时」买到的东西。
- 「实时进程一定比普通进程快」:它只保证「优先获得 CPU」,不保证「跑得更快」。如果它被锁或等 I/O,照样阻塞。
- 「
SCHED_FIFO会让同优先级轮转」:不会。同优先级 FIFO 是「先来先跑,除非它主动让出或阻塞」,其他同优先级任务会饿死。要轮转用SCHED_RR。 - 「
sched_rt_runtime_us=-1只是关掉限流」:关掉后一个死循环的实时任务能让系统完全无响应,连ssh都进不来。生产环境别关。 - 「
SCHED_DEADLINE只要设了就能跑」:有准入控制,runtime/deadline/period之和超过 CPU 带宽会返回EBUSY;参数不合法返回EINVAL。 - 「优先级 99 最高」:对 RT 类是;但
stop_sched_class与dl_sched_class仍在它之上,SCHED_DEADLINE可以压过SCHED_FIFO。
3.5.7 观测:从 /proc 到 bpftrace
| 工具 | 回答什么 | 前提 |
|---|---|---|
/proc/sched_debug | 每个 CPU/组的队列深度、vruntime、deadline | CONFIG_SCHED_DEBUG |
/proc/PID/sched | 单个任务的策略、优先级、运行时间、迁移次数 | 无 |
/proc/PID/schedstat | 运行时间、等待时间、时间片数(ns) | CONFIG_SCHEDSTATS |
/proc/pressure/cpu | CPU 争用(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 章会把这类脚本讲透。
- 先分清「不被调度」还是「调度了但慢」:
/proc/PID/schedstat的第二列(等待时间)大,是排队;第一列(运行时间)大但进度慢,是自身或 I/O 问题。 - 看 CPU 是否饱和:
top的%Cpu(s)、mpstat -P ALL 1、/proc/pressure/cpu。 - 看谁在抢:
pidstat -u 1、perf sched latency的前几名。 - 看是否被限流:cgroup 的
cpu.stat(nr_throttled)、cpu.max。 - 看是否被绑核:
taskset -pc PID、/proc/PID/status的Cpus_allowed_list。 - 最后才调参数:
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_prio | int | set_user_nice()、sched_setscheduler()、sched_fork() | 120 + nice,范围 100–139。名字叫静态,但 nice 改它就改 |
normal_prio | int | effective_prio() | 由策略与 static_prio/rt_priority 推出。普通进程等于 static_prio;RT 为 MAX_RT_PRIO-1-rt_priority;DEADLINE 为 −1 |
prio | int | effective_prio()、rt_mutex_setprio() | 调度类实际比较的动态优先级。被 PI 临时提升时,prio != normal_prio |
rt_priority | unsigned int | sched_setscheduler()、sched_setattr() | 只对 RT 类有意义,1–99;普通进程恒为 0 |
policy | unsigned int | __sched_setscheduler() | 基础策略号,不含 SCHED_FLAG_*;标志在 sched_flags |
sched_class | const struct sched_class * | sched_fork()、策略切换 | 决定入哪个子队列。切换策略时必须先 dequeue 再改 |
se / rt / dl | 内嵌结构体 | 初始化时清零,运行中由类维护 | 调度实体是内嵌的,不是指针;container_of 从 se 反推 task_struct |
cpus_ptr | const struct cpumask * | set_cpus_allowed_ptr() | 指向 cpus_mask;迁移与选核都读它 |
nr_cpus_allowed | int | 同上 | 掩码的 popcount 缓存,避免频繁数位 |
sched_task_group | struct 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:一个队列的全部状态
| 字段 | 谁维护 | 含义 | 异常时说明什么 |
|---|---|---|---|
load | account_entity_enqueue/dequeue() | 队列总权重 | 与 nr_running 比例失真 → 权重记账漏了 |
nr_running | 入队/出队 | 本队列实体数 | 与 /proc/sched_debug 的 nr_running 对照 |
h_nr_running | 层级传播 | 含子组的任务数 | 组调度下 nr_running 只是本层 |
min_vruntime | update_min_vruntime() | 已服务水位,单调不减 | 回退 → 有人直接改了它 |
tasks_timeline | __enqueue_entity() | 按 vruntime 排序的树 | 树空但 nr_running>0 → 实体在 curr |
curr | set_next_task_fair() | 正在跑的实体 | curr 不在树里是正常的 |
next / last | 唤醒放置与 buddy 启发 | 下次候选/上次运行 | EEVDF 下 next 语义弱化 |
avg_vruntime / avg_load | avg_vruntime() 更新 | EEVDF 的加权平均水位 | 用于 entity_eligible() |
throttled | CFS 带宽 | 本队列是否被限流 | cpu.stat 的 nr_throttled 对应它 |
不少二手资料说“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 不在树里”的原因。
调度实体(sched_entity)是“可被调度的一个份额”,保存权重、虚拟时间、截止时间等状态;调度类(sched_class)是“如何比较与选择实体”的算法实现。一个 task 在 fair 类里对应一个 se,在 rt 类里对应一个 rt,在 dl 类里对应一个 dl;三者同时存在,但只有 sched_class 指向的那个参与调度。组调度把 se 变成树:一个组的 se 挂在父队列,组的 my_q 里再挂子实体。
# 有调试符号的 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;
}
两个容易忽略的点:
p->on_rq的快路径:任务已经在队列里(比如被唤醒后又没跑),只需清掉阻塞状态,不必重新入队。ttwu_remote()会尝试在目标 rq 上完成,避免拿本地锁。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_RESCHED | exit_to_user_mode_loop() |
| 中断返回 | 同上 | irqentry_exit() |
| 系统调用返回 | 同上 | syscall_exit_to_user_mode() |
preempt_enable() | PREEMPT 内核且计数归零 | preempt_schedule() |
显式 cond_resched() | 需要重调度且不在原子上下文 | 长循环、拷贝、分配 |
显式 schedule() | 调用者主动让出 | 等锁、等 I/O |
内核代码可能正持有 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() 这类老接口之所以被删除,就是因为它把“设状态”和“检查条件”拆成了两个不可原子组合的步骤。
- “
wake_up_process()返回 0 就是失败”:它返回“是否真的唤醒了”(状态匹配且入队成功)。返回 0 常见于任务已经在跑,不是错误。 - “
wake_up()叫不醒可中断睡眠”:TASK_NORMAL就是TASK_INTERRUPTIBLE | TASK_UNINTERRUPTIBLE,wake_up()两者都匹配;区别在wake_up_interruptible()只匹配可中断睡眠者。反过来,TASK_KILLABLE的任务只有信号或wake_up()才能叫醒。 - “
sched_setscheduler()成功就立刻生效”:策略切换要拿 rq 锁、可能触发迁移,返回时新策略已写入,但任务可能还在旧 CPU 上跑一个时间片。 - “
cond_resched()会强制切换”:它只在need_resched已置位且不在原子上下文时让出;没有待调度任务时是空操作。 - “
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> | 指定进程/组/用户的 nice | 0 | −1:EINVAL(which 非法或 prio 越界)、EACCES/EPERM(无权降低 nice)、ESRCH |
getpriority(int which, id_t who) | 同上 | 读 nice | 20−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、flags | 0 | −1:E2BIG(size 小于内核期望)、EINVAL、EBUSY(DEADLINE 准入失败)、EPERM、ESRCH |
getpriority() 的返回值是全书最容易被误用的接口之一:它返回 20 - nice,所以合法返回值范围是 1–40(nice −20..19),只有 −1 表示出错。判断必须先 errno = 0,调用后若返回 −1 且 errno != 0 才是错。
策略与“优先级”的对应关系
| 策略 | 值 | 用哪个优先级 | 范围 | 时间片 |
|---|---|---|---|---|
SCHED_NORMAL | 0 | static_prio = 120 + nice | 100–139 | 动态(EEVDF slice) |
SCHED_BATCH | 3 | 同上 | 100–139 | 同 fair,唤醒抢占更保守 |
SCHED_IDLE | 5 | 权重极低(WEIGHT_IDLEPRIO) | — | 只在无其他任务时跑 |
SCHED_FIFO | 1 | rt_priority | 1–99 | 无,跑到阻塞/让出 |
SCHED_RR | 2 | rt_priority | 1–99 | sched_rr_timeslice_ms(默认 100 ms) |
SCHED_DEADLINE | 6 | 不用优先级 | — | 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_NICE | 0 | 允许的最高 nice:20 - rlim_cur | setpriority() 返回 EACCES/EPERM |
RLIMIT_RTPRIO | 0 | 允许的最高 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)。
- “
getpriority()返回 −1 就是出错”:nice −1 是合法值(对应返回 21)。必须先清errno再判断。 - “
nice()能随便降”:没CAP_SYS_NICE时只能升高 nice(让出 CPU),降低受RLIMIT_NICE限制,失败EPERM。 - “
sched_setscheduler()对线程和进程一样”:pid是线程 ID 时只改那个线程;进程 ID 只改主线程。多线程程序要逐个pthread_setaffinity_np()/sched_setscheduler()。 - “DEADLINE 的
sched_priority写 50 也行”:必须为 0,否则EINVAL。DEADLINE 不看 RT 优先级。 - “
SCHED_RESET_ON_FORK是策略位”:它是sched_flags里的一位,不是policy的值;fork 后子进程回到SCHED_NORMAL且 nice 归 0,是实时任务防“子孙继承”的标准做法。 - “
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 有交集,否则内核拒绝——这不是“亲和性建议”,是“允许集合”。
- “掩码可以写任意 CPU 编号”:必须
< nr_cpu_ids且属于当前 cpuset;否则EINVAL。容器里尤其容易踩到。 - “
sched_getaffinity()用sizeof(cpu_set_t)一定够”:内核的cpumask可能大于 1024 位;CPU_SETSIZE是 glibc 的编译期上限。超大机器要用CPU_ALLOC()动态分配。 - “绑定后立刻生效”:当前 CPU 不在新掩码里时,内核置
migration_pending,在下一个调度点迁移;返回 0 不代表已经在目标核上。 - “亲和性包含 I/O 线程”:
cpus_ptr只约束 CPU 放置,不约束中断、workqueue、io_uring的 worker。它们有各自的掩码(/proc/irq/N/smp_affinity、/sys/devices/virtual/workqueue/*/cpumask)。 - “
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_avg | runnable_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 节“小核簇与大核簇可比”的来源。
PELT 是时间衰减模型:每个 period 乘固定衰减因子,半衰期固定约 32 ms。WALT(Window Assisted Load Tracking,Android 常用)是窗口计数模型:把固定长度窗口(默认 20 ms)内的运行时间累加,窗口切换时整体滚动,不做逐 period 衰减。WALT 对突发负载更敏感,PELT 更平滑;主线 Linux 用 PELT,Android 部分设备用 WALT。两者都输出 0–1024 的“利用率”,但不可直接比较。
- “
util_avg=1024就是 CPU 满载”:它只说明“这个实体在过去一段时间几乎一直可运行/在运行”,还要除以capacity_of(cpu)才是该核的饱和度;跨核比较必须先归一化。 - “
load_avg可以跨进程比较大小”:它带权重。nice 0 的 50% 与 nice 10 的 50%,load_avg相差近 10 倍。 - “PELT 是瞬时值”:半衰期 32 ms,一个持续 1 ms 的突发在
util_avg上只留下约 3% 的痕迹;要抓突发用util_est或 schedstat。 - “
clock-delta大就是调度器有问题”:它可能只是 tick 被关中断/虚拟化延迟;对照/proc/stat的 steal 与cyclictest再下结论。
3.5.13 用 perf sched 与 bpftrace 把调度摊开
perf sched 与 bpftrace 都能测“等 CPU 的时间”,但口径不同。先明确口径,再谈数字,否则两个工具会给出互相矛盾的结果。
三种“延迟”不是一回事
| 指标 | 起点 | 终点 | 工具 | 依赖 |
|---|---|---|---|---|
| runqueue latency | sched_wakeup | 该任务 sched_switch 上 CPU | bpftrace | 只需 tracepoint |
| wait delay | 任务进入可运行 | 第一次上 CPU | perf sched latency | CONFIG_SCHEDSTATS |
| scheduling delay | 被抢占/唤醒 | 再次被调度 | perf sched timehist | sched_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)。
- 先看
timehist --summary的全局分布:wait time的 p99 是否异常。 - 再看
latency --sort max的前 10 名:找出具体任务。 - 用
map看 CPU 空转:某 CPU 一整段空着,说明选核/亲和性有问题,不是“负载太高”。 - 用 bpftrace 分优先级看直方图:RT 任务的长尾通常来自限流或关中断,CFS 的长尾来自排队。
- 最后才调参数:
base_slice_ns、sched_wakeup_granularity_ns、sched_rt_runtime_us。
某 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 |
| 3 | perf sched latency 全 0 | CONFIG_SCHEDSTATS 未开,sched_stat_wait 事件不存在 | 改用 perf sched timehist 或 bpftrace runqlat |
| 4 | 两个 nice 进程份额不是 50:50 | autogroup 把会话分组,或 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 |
| 7 | SCHED_FIFO 进程把机器锁死 | FIFO 无时间片,优先级高于所有普通任务 | 保留 sched_rt_runtime_us=950000;用 chrt -f 50 而非 99 |
| 8 | SCHED_DEADLINE 设置失败返回 EBUSY | 全局带宽已被占满(默认 95%) | 减小 runtime 或增大 period;cat /proc/sys/kernel/sched_rt_runtime_us |
| 9 | sched_setaffinity 返回 EINVAL | 掩码为空、含不存在 CPU、或与 cpuset 无交集 | 先 sched_getaffinity 读允许集合,再在交集内设置 |
| 10 | 改了 nice 但 CPU 份额没变 | 进程在 cgroup 里,组权重才是分配单位;或它大部分时间在睡眠 | 看 cpu.weight 与 cpu.stat 的 usage_usec |
| 11 | 6.x 内核模块编译报 p->state 不存在 | 5.14 起字段改名为 __state | 用 p->__state;兼容写法用 READ_ONCE(p->__state) |
| 12 | PREEMPT_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 确认 |
| 14 | perf 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 运行)。这些偏差正好说明:份额是长期平均,不是瞬时保证。
某服务 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 deadline | 3.5.4 |
| 解释组调度解决什么问题 | 线程数不该放大份额 | 3.5.5 |
| 说出 RT throttling 的默认值 | 950 ms / 1 s,关掉的风险 | 3.5.6 |
用 perf sched latency 找排队 | 能读 Avg/Max delay | 3.5.7 |
| 说出四个优先级字段的分工 | static_prio/normal_prio/prio/rt_priority | 3.5.8 |
| 画出一次唤醒到切换的路径 | try_to_wake_up → enqueue_task → wakeup_preempt | 3.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 |
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 控制器怎么配合。