概述
进程调度器是操作系统内核的核心组件,它决定了哪个进程在哪个 CPU 上运行、运行多长时间。Linux 自 2.6.23 起采用 CFS(Completely Fair Scheduler)作为默认调度器,经过多年演进,在 6.6 内核中又引入了 EEVDF(Earliest Eligible Virtual Deadline First)替代 CFS。本文深入理解 CFS 的工作原理、nice/cgroup CPU 控制、实时调度、CPU 亲和性等核心主题,并分享生产环境的调优经验。
CFS 调度器原理
设计哲学
CFS 的核心目标是"完全公平"——每个进程按照其权重比例获得 CPU 时间。与传统调度器基于时间片不同,CFS 使用"虚拟运行时间"(vruntime)来跟踪每个进程的 CPU 消耗:
vruntime = 实际运行时间 × (NICE_0_LOAD / 进程权重)
- nice 值为 0 的进程权重为 1024(
NICE_0_LOAD) - nice 值越低,权重越大,vruntime 增长越慢,获得更多 CPU 时间
- CFS 始终选择 vruntime 最小的进程运行
红黑树与调度队列
CFS 使用红黑树维护运行队列,按 vruntime 排序:
[vruntime=50]
/ \
[vruntime=20] [vruntime=80]
/ \
[vruntime=10] [vruntime=35]
- 最左节点(vruntime 最小)是下一个被调度的进程
- 进程运行后 vruntime 增加,被重新插入红黑树
- 查找、插入、删除均为 O(log N)
调度周期与最小粒度
# 调度周期(目标延迟):所有进程在这段时间内至少运行一次
$ sysctl kernel.sched_latency_ns
kernel.sched_latency_ns = 6000000 # 6ms(6.0×10⁶ ns)
# 最小运行粒度:单个进程一次最少运行的时间
$ sysctl kernel.sched_min_granularity_ns
kernel.sched_min_granularity_ns = 1000000 # 1ms
# 唤醒抢占粒度
$ sysctl kernel.sched_wakeup_granularity_ns
kernel.sched_wakeup_granularity_ns = 1000000 # 1ms
调度周期内的分配逻辑:
进程数 <= sched_latency_ns / sched_min_granularity_ns:
每个进程的时间片 = sched_latency_ns / 进程数
进程数 > 阈值:
每个进程的时间片 = sched_min_granularity_ns
调度周期自动扩展 = sched_min_granularity_ns × 进程数
nice 值与权重
nice 值范围 -20 到 19,默认 0。每差 1 级,CPU 占比约差 10%:
| nice 值 | 权重 | 相对 CPU 占比 | 典型用途 |
|---|---|---|---|
| -20 | 88761 | 88x | 实时关键任务 |
| -10 | 9548 | 9.3x | 高优先级服务 |
| 0 | 1024 | 1x | 默认 |
| 10 | 110 | 0.107x | 后台任务 |
| 19 | 15 | 0.015x | 批处理任务 |
# 查看进程 nice 值
$ ps -o pid,ni,comm -p $PID
# 修改进程 nice 值
$ renice -n -5 -p $PID
# 以指定 nice 值启动
$ nice -n -5 /path/to/program
注意:nice 值是相对的。两个进程 nice 值分别为 0 和 1,CPU 占比约为 1.25:1,而非 2:1。权重计算公式为
1024 × 1.25^(-nice)。
cgroup v2 CPU 控制
CPU 权重(cpu.weight)
# 创建 cgroup
$ mkdir /sys/fs/cgroup/app_a
$ mkdir /sys/fs/cgroup/app_b
# 设置 CPU 权重(默认 100,范围 1-10000)
$ echo 200 > /sys/fs/cgroup/app_a/cpu.weight
$ echo 100 > /sys/fs/cgroup/app_b/cpu.weight
# app_a : app_b = 2:1
# 将进程加入 cgroup
$ echo $PID_A > /sys/fs/cgroup/app_a/cgroup.procs
$ echo $PID_B > /sys/fs/cgroup/app_b/cgroup.procs
CPU 带宽限制(cpu.max)
# 格式: $MAX $PERIOD
# 限制为 1.5 个 CPU 核
$ echo "150000 100000" > /sys/fs/cgroup/app_a/cpu.max
# 含义: 每 100ms 周期内最多使用 150ms CPU 时间
# 查看当前配置
$ cat /sys/fs/cgroup/app_a/cpu.max
150000 100000
# 查看 CPU 使用统计
$ cat /sys/fs/cgroup/app_a/cpu.stat
usage_usec 12345678
user_usec 9876543
system_usec 2469135
nr_periods 12345
nr_throttled 0 # 被限流次数
throttled_usec 0 # 被限流的总时间
cpu.weight vs cpu.max 对比
| 特性 | cpu.weight | cpu.max |
|---|---|---|
| 类型 | 相对权重 | 绝对限制 |
| CPU 空闲时 | 可突破限制使用全部 CPU | 严格不超过限制 |
| 适用场景 | 优先级控制 | 硬性配额 |
| 类比 | nice 值 | CPU 配额 |
| Docker 对应 | --cpu-shares | --cpus |
生产环境 CPU 限制策略
# 数据库服务:高优先级 + 硬限制防超卖
$ echo 500 > /sys/fs/cgroup/postgres/cpu.weight
$ echo "800000 100000" > /sys/fs/cgroup/postgres/cpu.max # 8 核
# Web 服务:中等优先级
$ echo 200 > /sys/fs/cgroup/nginx/cpu.weight
$ echo "400000 100000" > /sys/fs/cgroup/nginx/cpu.max # 4 核
# 日志收集:低优先级
$ echo 50 > /sys/fs/cgroup/filebeat/cpu.weight
$ echo "50000 100000" > /sys/fs/cgroup/filebeat/cpu.max # 0.5 核
实时进程与 RT 调度
调度策略
| 策略 | 说明 | 适用场景 |
|---|---|---|
| SCHED_NORMAL (0) | CFS 调度 | 普通进程 |
| SCHED_BATCH (3) | CFS 批处理模式 | CPU 密集型批处理 |
| SCHED_IDLE (5) | 极低优先级 | 后台任务 |
| SCHED_FIFO (1) | 实时先进先出 | 实时任务 |
| SCHED_RR (2) | 实时轮转 | 实时任务 |
| SCHED_DEADLINE (6) | 基于 deadline | 严格实时需求 |
SCHED_FIFO 与 SCHED_RR
# 查看进程调度策略
$ chrt -p $PID
# 设置为 SCHED_FIFO,优先级 80
$ chrt -f -p 80 $PID
# 设置为 SCHED_RR,优先级 80
$ chrt -r -p 80 $PID
SCHED_FIFO 规则:
- 同优先级进程按 FIFO 顺序运行
- 高优先级进程始终抢占低优先级
- 进程主动让出 CPU 后才切换(无时间片限制)
SCHED_RR 规则:
- 同 SCHED_FIFO,但每个进程有时间片(默认 100ms)
- 时间片耗尽后轮转到同优先级下一个进程
RT 调度参数调优
# RT 进程可用的 CPU 带宽比例(默认 95%)
$ sysctl kernel.sched_rt_runtime_us
kernel.sched_rt_runtime_us = 950000
$ sysctl kernel.sched_rt_period_us
kernel.sched_rt_period_us = 1000000
# 含义: 每 1 秒中 RT 进程最多用 950ms,留 50ms 给普通进程
# RT throttling 统计
$ grep -H . /proc/sys/kernel/sched_rt_*
警告:生产环境不要将
sched_rt_runtime_us设为 -1(禁用 throttling),否则一个失控的 RT 进程可能锁死整个系统。
DEADLINE 调度器
struct sched_attr attr = {
.size = sizeof(attr),
.sched_policy = SCHED_DEADLINE,
.sched_runtime = 10 * 1000 * 1000, // 10ms 运行时间
.sched_deadline = 30 * 1000 * 1000, // 30ms deadline
.sched_period = 30 * 1000 * 1000, // 30ms 周期
};
syscall(SYS_sched_setattr, pid, &attr, 0);
CPU 亲和性
亲和性原理
CPU 亲和性(affinity)控制进程可以在哪些 CPU 核心上运行。绑定进程到特定 CPU 可以:
- 减少 cache miss(CPU cache 热度保持)
- 降低 NUMA 跨节点访存延迟
- 隔离关键服务
# 查看进程亲和性
$ taskset -p $PID
pid 12345's current affinity mask: ff # CPU 0-7
# 设置亲和性(绑定到 CPU 0 和 1)
$ taskset -cp 0,1 $PID
# 以指定亲和性启动
$ taskset -c 0,1 /path/to/program
# 查看所有 CPU 的亲和性掩码
$ taskset -cp $PID
NUMA 亲和性
# numactl 绑定进程到 NUMA 节点
$ numactl --cpunodebind=0 --membind=0 /path/to/program
# interleave 模式(内存均匀分布到所有 NUMA 节点)
$ numactl --interleave=all /path/to/program
# 查看进程的 NUMA 内存分布
$ numastat -p $PID
isolcpus:隔离 CPU 核心
# GRUB 配置:隔离 CPU 4-7
# /etc/default/grub
GRUB_CMDLINE_LINUX="isolcpus=4-7 nohz_full=4-7 rcu_nocbs=4-7"
# 更新 GRUB
$ grub2-mkconfig -o /boot/grub2/grub.cfg
$ reboot
# 隔离后,使用 taskset 手动绑定进程到隔离核
$ taskset -c 4-7 /path/to/critical_service
| 参数 | 说明 |
|---|---|
isolcpus=4-7 | 将 CPU 4-7 从调度器中移除,不参与负载均衡 |
nohz_full=4-7 | 在这些 CPU 上禁用周期性 tick(减少中断) |
rcu_nocbs=4-7 | RCU 回调迁移到其他 CPU |
适用于低延迟交易系统、电信级 VoIP、实时控制系统等场景。
IRQ 亲和性
# 查看 IRQ 分配
$ cat /proc/interrupts | head -30
# 将网卡中断绑定到特定 CPU
# 假设网卡中断号为 32
$ echo 0f > /proc/irq/32/smp_affinity # 绑定到 CPU 0-3
# 使用 hex 掩码
# CPU 0-3: 0x0f (00001111)
# CPU 4-7: 0xf0 (11110000)
# CPU 0-7: 0xff (11111111)
# 使用 irqbalance 服务(默认开启)
$ systemctl status irqbalance
# 生产环境建议:关闭 irqbalance,手动绑定
$ systemctl stop irqbalance
$ systemctl disable irqbalance
调度延迟排查
延迟来源分析
进程唤醒 → [调度延迟] → 进程开始运行 → [执行延迟] → 完成
↑
可能原因:
1. CPU 被高优先级进程占用
2. CPU 被实时进程占满
3. cgroup CPU 限流
4. NUMA 跨节点
5. 中断风暴
排查工具
1. schedstat
# 查看进程调度统计
$ cat /proc/$PID/schedstat
12345678 9876543 5678
# 字段: 等待 CPU 时间(ns) 运行时间(ns) 时间片切换次数
# 计算平均调度延迟
$ awk '{printf "avg schedule delay: %.2f ms\n", $1/$3/1000000}' /proc/$PID/schedstat
2. perf sched
# 记录调度事件(10 秒)
$ perf sched record -- sleep 10
# 分析调度延迟
$ perf sched latency --sort max
# 输出示例:
# Task | Runtime ms | Switches | Average delay ms | Maximum delay ms |
# nginx:worker | 120.345 | 234 | 0.456 | 5.678 |
3. eBPF 追踪
# 追踪进程唤醒延迟
$ /usr/share/bcc/tools/runqlat -p $PID 5
# 追踪运行队列长度
$ /usr/share/bcc/tools/runqlen 5
# 追踪 CPU off-CPU 时间(等待调度的时间)
$ /usr/share/bcc/tools/offcputime -p $PID 5
# 追踪调度器性能
$ /usr/share/bcc/tools/schedstats 5
4. histly / flamegraph
# 生成 off-CPU 火焰图
$ /usr/share/bcc/tools/offcputime -p $PID -f 10 > offcpu.svg
常见延迟问题与解决
| 问题 | 现象 | 解决方案 |
|---|---|---|
| CPU 限流 | cgroup nr_throttled 持续增长 | 提高 cpu.max 或优化代码 |
| RT 进程抢占 | 普通进程延迟尖峰 | 限制 RT 带宽或改用 SCHED_DEADLINE |
| NUMA 跨节点 | 周期性延迟 | 使用 numactl 绑定 |
| 中断风暴 | 特定 CPU 100% sys | 分散 IRQ 到多核 |
| 过载 | 运行队列长度 > 4 | 扩容或降低负载 |
| 上下文切换过多 | cs/sec > 50000 | 减少线程数、使用 IO 多路复用 |
案例:API 服务 P99 延迟优化
现象:Go API 服务 P99 延迟从 50ms 升至 200ms,CPU 使用率 60%。
排查:
# 1. 检查 CPU 限流
$ cat /sys/fs/cgroup/app/cpu.stat
nr_throttled 8765 # 被限流 8765 次
throttled_usec 4567890 # 累计被限流 4.5 秒
# 2. 检查 cpu.max 配置
$ cat /sys/fs/cgroup/app/cpu.max
200000 100000 # 限制 2 核
# 3. 使用 runqlat 确认
$ /usr/share/bcc/tools/runqlat -p $PID 10
# 显示大量 10ms+ 的调度延迟
根因:cgroup CPU 限制为 2 核,但服务有突发 CPU 需求,频繁触发限流。
解决方案:
# 方案 1:提高限制(如果资源允许)
$ echo "400000 100000" > /sys/fs/cgroup/app/cpu.max
# 方案 2:使用 cpu.weight 替代硬限制(集群环境)
$ echo 500 > /sys/fs/cgroup/app/cpu.weight
$ echo "max" > /sys/fs/cgroup/app/cpu.max # 不限制
# 方案 3:优化 GOMAXPROCS(Go 1.19+ 会自动感知 cgroup)
$ GOMAXPROCS=2 ./app # 显式设置匹配 cpu.max
EEVDF 调度器(内核 6.6+)
Linux 6.6 引入 EEVDF(Earliest Eligible Virtual Deadline First)替代 CFS:
EEVDF 核心改进
| 特性 | CFS | EEVDF |
|---|---|---|
| 调度依据 | vruntime 最小 | eligibility + virtual deadline |
| 延迟保证 | 无精确保证 | 基于 deadline 的精确延迟 |
| nice 值 | 影响 vruntime 增速 | 影响 weight 和 lag |
| 公平性 | 渐进公平 | 精确公平(lag-based) |
| 实时性 | 无保证 | 有 deadline 保障 |
关键概念
- Eligibility:进程是否"有资格"被调度(基于 lag 计算)
- Virtual Deadline:每个进程有一个虚拟截止时间,优先调度最早截止的进程
- Lag:实际获得 CPU 时间与应得 CPU 时间的差值,用于精确补偿
# 查看内核版本
$ uname -r
6.6.0-1234-generic
# EEVDF 相关 sysctl(6.6+)
$ sysctl kernel.sched_base_slice
kernel.sched_base_slice = 750000 # 基础时间片 0.75ms(取代 sched_min_granularity_ns)
EEVDF 对延迟敏感型应用有显著改善,特别是音视频处理、游戏服务器等场景。
多核负载均衡
负载均衡层级
[ sched domain hierarchy ]
┌─────────┐
| NUMA | (跨节点)
| domain |
└────┬────┘
┌────────┼────────┐
┌────┴────┐ ... ┌────┴────┐
| CPU | | CPU | (物理核)
| socket | | socket |
└────┬────┘ └─────────┘
┌──────┼──────┐
┌────┴───┐ ... ┌────┴───┐
| LLC | | LLC | (共享 L3 cache)
| domain | | domain |
└────┬───┘ └────────┘
┌────┼────┐
┌────┴──┐ ... ┌──┴────┐
| SMT | | SMT | (超线程)
| domain| | domain|
└───────┘ └───────┘
负载均衡参数
# 负载均衡间隔(忙/闲)
$ sysctl kernel.sched_migration_cost_ns
kernel.sched_migration_cost_ns = 500000 # 0.5ms
# 任务在迁移前必须 idle 的最短时间
# 启用/禁用 SMT 调度
$ sysctl kernel.sched_smt_sibling_cost
手动控制负载均衡
# 为特定 CPU 集创建独立的调度域(CPU 隔离)
# 见 isolcpus 部分
# 使用 cgroup v2 的 cpuset
$ mkdir /sys/fs/cgroup/dedicated
$ echo "4-7" > /sys/fs/cgroup/dedicated/cpuset.cpus
$ echo "0" > /sys/fs/cgroup/dedicated/cpuset.mems
总结
Linux 进程调度器是系统性能的关键决定因素,理解其工作原理有助于在以下场景做出正确决策:
- CFS/EEVDF 的公平性基于权重而非时间片:nice 值每差 1 级约 10% CPU 差异,cgroup
cpu.weight遵循相同机制。 - cgroup v2 的
cpu.max是硬限制:适合容器配额管理,但要注意 throttling 对延迟的影响;cpu.weight是相对优先级,CPU 空闲时可突破。 - 实时调度需要谨慎使用:SCHED_FIFO/RR 会抢占普通进程,务必配置
sched_rt_runtime_us留出安全余量。 - CPU 亲和性是延迟优化的利器:
isolcpus+nohz_full+ IRQ 绑定可实现微秒级延迟保证。 - 调度延迟排查需工具链配合:从
schedstat/runqlat定位延迟分布,到offcputime追踪根因,到perf sched分析全局调度行为。 - EEVDF 是调度器的未来:6.6+ 内核已默认启用,对延迟敏感型应用有显著改善。
调度器调优的黄金法则:默认配置已经足够好。只有在有明确的延迟或吞吐量问题、且有基线数据支撑时,才修改调度参数。