Linux进程调度与负载:从CFS到EEVDF与load average
Linux进程调度与负载:从CFS到EEVDF与load average
虚拟内存解决"能不能装下",调度解决"谁在什么时候拿到 CPU、拿多久"。本文讲三件事:调度器如何决定下一个运行者(CFS 与 6.6 之后的 EEVDF)、cgroup 的权重与配额为什么会让容器莫名被卡、以及 load average 为什么和 CPU 使用率经常给出相反结论。
调度器要同时满足的四件事
公平 每个任务按权重获得大致成比例的 CPU 时间
响应 交互式任务(键盘、渲染)不能被批处理任务拖住
吞吐 上下文切换本身是纯开销,切得越勤吞吐越低
实时 硬deadline 任务必须有上界保证
这四项互相冲突,Linux 的取舍是:默认完全公平,实时靠优先级抢占,响应性靠睡眠惩罚补偿。理解了这一点,下面所有机制都是它的推论。
调度类与优先级层次
内核不是用一个算法管所有任务,而是按"调度类"分层,优先级高的类有任务时低类直接没份:
stop → deadline → realtime → fair → idle
(迁移/暂停用) (SCHED_DEADLINE) (SCHED_FIFO/RR) (普通进程) (SCHED_IDLE)
| 策略 | 属于哪类 | 时间片 | 抢占 | 典型用途 |
|---|---|---|---|---|
SCHED_OTHER |
fair | 由 CFS/EEVDF 动态决定 | 可被更高类抢 | 绝大多数进程 |
SCHED_BATCH |
fair | 视为"不交互",不唤醒优化 | 同上 | 编译、离线任务 |
SCHED_IDLE |
idle | 只有机器全空才跑 | 被任何人抢 | 后台清理、低优先守护 |
SCHED_FIFO |
realtime | 不用完不切(除非被抢/让出) | 同级不轮转 | 音频、控制回路 |
SCHED_RR |
realtime | 同级按片轮转 | 同上 | 实时计算密集型 |
SCHED_DEADLINE |
deadline | 声明 runtime/period/deadline | 准入控制 | 需要带宽保证的任务 |
nice 值到权重的映射是指数的:
weight = approx_constant / exp2(nice) (6.6 前)
每 +1 nice → 权重约降到 0.8 倍(约 82%)
nice 0 与 nice 19 的 CPU 份额相差约 60 倍
这意味着用 nice 压制一个进程比想象的强得多,而差 1 个 nice 几乎感觉不到。
CFS:用vruntime 把"公平"变成一个可比较的数
CFS 没有固定时间片,它维护一个虚拟运行时间:
vruntime += 实际运行时间 × (NICE_0_LOAD / 当前权重)
- 权重高(nice 小)→ 同样跑 10ms,vruntime 增长少 → 还能继续跑
- 权重低(nice 大)→ vruntime 涨得快 → 很快就"落后于"别人,被换下
调度决策就一句话:红黑树上 vruntime 最小的可运行任务上位。
runqueue(每个 CPU 一个)
红黑树按 vruntime 排序
├─ 最左节点 = 下一个运行的任务(缓存起来,O(1) 取)
└─ 新任务插入位置:max_vruntime 附近(避免新任务霸占 CPU)
睡眠唤醒:vruntime 拉到 min_vruntime 附近
→ 短暂睡眠的任务获得补偿,能立刻再跑(交互性来源)
→ 长时间睡眠的任务补偿更多(可能立刻抢占当前任务,是延迟毛刺的一个来源)
CFS 的痛点:vruntime 只保证累积公平,不保证单轮的响应上界。系统里可运行任务数波动时,某个任务的等待时间没有可预测的界,音视频、游戏这类任务会感到偶发卡顿。这是 EEVDF 要解决的问题。
EEVDF(6.6 起替代 CFS):给每个任务一个到期时间
EEVDF 的核心是"负延迟"概念:任务不应得的时间(lag)为负时才有资格运行。
每个任务两个量:
request 这一轮想要多长(默认按历史 slice 推算)
deadline 希望什么时候完成 = 现在 + 按权重折算的等待
两步决策:
① 先筛出 eligible 的任务(累计 lag ≤ 0,即它确实"欠"CPU)
② 在 eligible 里挑 deadline 最早的运行
效果对比:
| 维度 | CFS | EEVDF |
|---|---|---|
| 选择依据 | vruntime 最小 | eligible 中 deadline 最早 |
| 单次等待上界 | 无明确保证 | 与权重、request 挂钩,可预测 |
| 大时间片任务 | 一旦开跑长时间不让出 | 可被要求让出,响应更好 |
| 调优入口 | sched_min_granularity_ns 等 sysctl |
主要通过 sched_start_period、request 机制 |
升级内核后调度延迟分布变好、但某些 CPU 密集型基准吞吐略降,是这个换代的正常表现。
时间片不是常数
传统观念:默认 100ms 时间片
实际:CFS 按可运行任务数动态推导
sched_latency_ns(默认随 CPU 数增长,目标:一轮内让所有任务轮转一次)
每个任务应得 ≈ latency × 权重占比
下限 sched_min_granularity_ns(防止任务太多时单轮太碎)
超过 sched_nr_latency 个任务后不再延长一轮,避免单轮总时长失控
所以任务越多,单个任务每轮分到的时间越少,但不会小于最小粒度。历史上把 sched_min_granularity_ns 调大能提高吞吐(少切换),代价是交互延迟——这个权衡在 EEVDF 里由 request 机制自动处理。
负载均衡:任务怎么跨 CPU 迁移
runqueue 是 per-CPU 的 → 必须周期性平衡
调度域按拓扑分层:SMT(同核超线程)→ MC(同 socket)→ NUMA → 全机
越往下层平衡频率越低(跨 NUMA 迁移要搬内存页,成本极高)
触发方式:
① 周期性:tick 里做 idle balance / 全局 balance(CONFIG_SCHED_HRTICK 关掉后靠 tick)
② 主动拉取:CPU 进入 idle 前,去相邻 busiest CPU 偷任务(newidle balance)
③ 唤醒选点:select_task_rq_fair —— 找空闲 CPU,考虑缓存热度与 NUMA 距离
(wake_affine:刚睡眠的任务倾向于回到原来的 CPU,因为它的缓存还热)
NUMA 副作用:任务被拉过 NUMA 边界 → 内存还在远端节点 → 每次访问跨互联
内核会做 AutoNUMA 页迁移(代价:额外缺中断),NUMA 敏感服务要留意
实践提示:“CPU 使用率整体不高但服务延迟高”,很常见的原因是任务全挤在几个核上,而其他核空闲。这时该看的是 per-CPU 分布(mpstat -P ALL),不是总使用率。
上下文切换的真实成本
进程切换 切页表基址(CR3)→ TLB 失效 → 后续大量 TLB miss + 缓存冷启动
线程切换(同进程)页表不变,只换寄存器/栈 → 便宜一档
内核线程/软中断 不离开内核态 → 更便宜
看指标:
vmstat 1 # cs 列:每秒切换次数
pidstat -w 1 # 按进程分自愿(cswch/s)和非自愿(nvcswch/s)
判读规则:
- 非自愿切换高 → 任务被抢占,说明 CPU 真的不够(或权重被压制、cgroup 配额被限),要加 CPU 或降并发。
- 自愿切换极高(数十万/s)→ 不是 CPU 问题,是任务频繁阻塞(锁、IO、过度同步),去查锁竞争和线程模型。
- 切换次数本身没有"绝对超标值",要看它带来的 cache miss 是否体现在 IPC / 延迟上。
cgroup v2:容器里的调度真相
容器不是独立机器,它的 CPU 配额由 cgroup 决定,两个完全不同性质的参数:
cpu.weight(旧 v1 是 cpu.shares,0-10000,默认 100)
性质:相对权重,只在**机器过载**时生效
机器空闲时,低权重容器照样能跑满
→ 只影响"抢不到 CPU 时怎么分"
cpu.max "quota period"(如 "200000 100000" = 2 核)
性质:绝对上限,任何时刻生效
配额在每个周期开始时发放,用完 → 整个 cgroup 内所有线程一起 throttled,
直到下一个周期(默认 100ms)才恢复
throttling 是延迟毛刺的头号来源:
周期 100ms,配额 2 核 = 200ms CPU 时间
应用有 8 个线程同时活跃 → 25ms 内把 200ms 配额烧光
剩下 75ms:整个进程被冻结,包括正在处理请求的线程
外部观测:P99 出现 100ms 量级的整数台阶
确认方法:
cat /sys/fs/cgroup/<容器>/cpu.stat
nr_throttled / throttled_time # 只要非零就说明配额成了瓶颈
应对:调大 cpu.max、减小线程池规模以匹配配额,或(不推荐)把 period 调小让毛刺变密变短。注意 Kubernetes 里 limits.cpu 直接映射到这个配额,requests.cpu 只映射权重——只设 requests 不设 limits 才不会被 throttle。
load average 到底在数什么
这是最常被误读的指标。它不是"CPU 使用率",而是平均活跃任务数:
load = 处于 R(可运行)+ D(不可中断睡眠)状态的任务数的指数滑动平均
三个数:1 分钟 / 5 分钟 / 15 分钟
关键:D 状态也计入!
→ 磁盘 IO 阻塞、NFS 卡住、内核锁等待,都会把 load 抬高
→ 而这段时间 CPU 可能完全空闲
正确用法:
1. 除以核数:load 8 / 16 核 = 一半负载;load 8 / 4 核 = 排队中
2. 看三个数的趋势关系:
1min > 5min > 15min → 正在上升
1min < 5min < 15min → 正在回落
3. 与 CPU 使用率交叉判断:
load 高 + CPU 高 → 计算密集过载
load 高 + CPU 低 → D 状态堆积,去查 IO / 存储 / 内核阻塞
load 低 + 延迟高 → 不是排队问题,看调度延迟、throttle、锁
4. 容器里 /proc/loadavg 是宿主机的(未做 lxcfs 隔离时),别拿来当容器指标
观测与调优入口
perf sched record -a -- sleep 5; perf sched latency # 每个任务的等待时长分布(最直接)
cat /proc/schedstat # runqueue 等待时间累计
cat /proc/sys/kernel/sched_latency_ns / ..._min_granularity_ns
cat /sys/kernel/debug/sched/debug # 每个 CPU 的调度器内部状态
taskset -c 0-3 ./app # 绑核
chrt -f 50 ./app # 设实时优先级
参数调整的边界:
| 目标 | 手段 | 风险 |
|---|---|---|
| 减少切换、提吞吐 | 提 min_granularity(旧内核)/ 降线程数 |
交互延迟上升 |
| 保延迟 | SCHED_DEADLINE、isolcpus + 绑核 |
隔离核吞吐浪费,配置复杂 |
| 容器稳定 | 去掉 cpu limits 或放大配额 | 宿主过载时无保护 |
| 降低唤醒抖动 | sched_wakeup_granularity_ns(旧) |
迁移决策改变,可能加重不均 |
真正低延迟的服务(交易、音频)走的是"绑核 + 隔离 CPU + 关 irqbalance + 实时优先级"这套组合,而不是调 CFS 常数。
常见坑
- 拿 load average 当 CPU 使用率:D 状态会污染它,必须结合 CPU% 和 IO 等待一起看。
nice调 1 档期望见效:权重是指数关系,1 档几乎无感;要压就要 5 档以上。- 给 Java/Go 进程设实时优先级:GC 线程、运行时后台线程会一起变实时,反过来饿死整个系统。
- 只看总 CPU 不看 per-CPU:负载全堆在 2 个核上,总量 20% 也照样排队。
- 容器设了 CPU limit 又不限制线程数:线程数远超配额 → 必然 throttle → P99 出现 100ms 台阶。
- 认为
cpu.weight能保证拿到 CPU:它是相对权重,机器空闲才看得出差别,过载时才起作用。 - 用
top的 CPU% 判断调度好坏:top给的是采样窗口内的执行时间占比,不含等待时间,排队严重时使用率反而可能很高。 - 绑核后忽略中断与 NUMA:网卡中断默认在 CPU0,绑到 0 号核的关键线程会和中断抢;跨 NUMA 绑核导致内存访问在远端。
总结
| 问题 | 表面现象 | 机制根源 | 第一步查什么 |
|---|---|---|---|
| 吞吐低、切换多 | cs 数十万/s | 任务频繁阻塞或被抢 | pidstat -w,先分自愿/非自愿 |
| P99 整台阶 | 延迟有 100ms 倍数 | cgroup CPU 配额 throttle | cpu.stat 的 nr_throttled |
| load 高但 CPU 空闲 | 告警说负载高 | D 状态堆积(IO/存储) | vmstat 的 b 列 + IO 等待 |
| 个别任务莫名慢 | 等待时间抖动 | 唤醒选点/负载均衡迁移 | perf sched latency |
| 关键线程抢不到 CPU | 实时性不达标 | 处于 fair 类被批任务压 | 提升到 RT/deadline 或隔离核 |
调度的核心不是"调哪个参数",而是先分清瓶颈属于哪一类:排队不够(缺 CPU)、切得太碎(开销)、被配额冻结(throttle)、还是根本没在等 CPU(阻塞在 IO)。四类问题的处置完全相反,判断错方向就会越调越糟。
- 点赞
- 收藏
- 关注作者
评论(0)