1
2
3
4
5
6
7
作者:李晓辉

联系方式:

1. 微信:Lxh_Chat

2. 邮箱:939958092@qq.com

前面我们已经学习了:

  • 硬件画像
  • Linux 监控工具
  • 内核可调参数
  • TuneD
  • cgroup
  • perf
  • strace
  • SystemTap
  • eBPF

这些工具解决的是不同层面的问题。比如:

1
top

可以告诉我们:

CPU 到底忙不忙?

1
perf

可以进一步告诉我们:

CPU 时间主要消耗在哪里?

1
runqlat

又可以帮助我们观察:

任务是不是因为没有 CPU 可用,一直在运行队列里排队?

但是到这里,一个更加底层的问题就出现了:

CPU 忙起来以后,Linux 内核到底是怎么决定“下一个让谁运行”的?

这就是今天要学习的:

Linux CPU 调度器(CPU Scheduler)

而如果你正在使用 RHEL 10,还需要特别注意一个变化:

RHEL 10 已经使用 EEVDF 调度器替代传统的 CFS。

EEVDF 全称:

Earliest Eligible Virtual Deadline First

中文通常可以理解为:

最早可运行虚拟截止时间优先

所以这一篇,我们就从最基础的:

1
2
3
4
5
6
7
任务
↓
运行队列
↓
调度器
↓
选择下一个任务

一路讲到:

1
2
3
4
5
6
7
8
9
CFS
↓
EEVDF
↓
nice / renice
↓
chrt
↓
上下文切换

什么是 CPU 调度?

先想象一个非常简单的场景。假设服务器只有 1 个 CPU 核心。现在同时有:

1
2
3
4
5
nginx
mysql
bash
java
python

5 个任务都想使用 CPU。但是:

一个 CPU 核心在同一时刻只能真正执行一个线程。

那么问题来了:

其他任务怎么办?

它们不能全部同时运行,只能等待。于是 Linux 内核需要维护一个非常重要的东西:

运行队列(run queue)

可以简单理解成:

flowchart TD
    CPU[CPU]
    Scheduler[Linux Scheduler<br/>调度器]
    RunQueue[Run Queue<br/>运行队列]

    Nginx[nginx 线程<br/>Runnable]
    MySQL[MySQL 线程<br/>Runnable]
    Java[Java 线程<br/>Runnable]

    CPU --> Scheduler
    Scheduler -->|选择下一个任务| RunQueue

    RunQueue --> Nginx
    RunQueue --> MySQL
    RunQueue --> Java

运行队列里面放的,是:

当前处于可运行状态、正在等待 CPU 的任务。

调度器要做的事情非常核心:

从这些可运行任务中,选择下一个应该运行的任务。


Linux 调度的最小单位:线程

这里有一个非常容易搞错的概念。很多人会说:

“Linux 调度的是进程。”

严格来说,这个说法并不准确。Linux 内核真正调度的是:

线程(thread)

也就是内核中的 task_struct 对应的调度实体。一个进程可以拥有多个线程:

1
2
3
4
5
6
进程 PID 1000
│
├── TID 1000
├── TID 1001
├── TID 1002
└── TID 1003

这些线程共享进程的大部分资源,例如:

1
2
3
虚拟地址空间
文件描述符
信号处理等

但是:

每一个线程都可以独立进入运行队列并被调度。

所以以后看到:

1
2
PID
TID

不要简单地把它们理解成两个不同的“进程编号”。可以简单记:

1
2
3
4
5
进程
│
├── 线程1 ──→ 可以被调度
├── 线程2 ──→ 可以被调度
└── 线程3 ──→ 可以被调度

也可以使用:

1
2
3
4
[root@localhost ~]# ps -L
PID LWP TTY TIME CMD
1214 1214 pts/0 00:00:00 bash
7825 7825 pts/0 00:00:00 ps

查看进程中的线程。


每个 CPU 都有自己的运行队列

如果服务器只有一个 CPU,理解起来还比较简单。但现在服务器可能有:

1
2
3
4
5
CPU0
CPU1
CPU2
CPU3
...

这时候就不能简单理解成:

所有任务都排在一个巨大的队列里。

Linux 调度器会为每个 CPU 维护自己的运行队列。可以简单理解成:

1
2
3
4
5
6
7
8
9
10
11
CPU0                         CPU1
┌──────────────┐ ┌──────────────┐
│ run queue │ │ run queue │
│ │ │ │
│ nginx │ │ java │
│ bash │ │ mysql │
│ python │ │ redis │
└──────┬───────┘ └──────┬───────┘
│ │
▼ ▼
CPU0 CPU1

所以 CPU 调度实际上还涉及一个重要问题:

任务应该在哪一个 CPU 上运行?

不过这个问题涉及 CPU 亲和性、任务迁移、中断等内容,我们后面的文章会单独展开。这一篇先把注意力放在:

一个 CPU 上,调度器到底怎么决定“谁先运行”。


Linux 调度器到底在做什么?

可以把调度器理解成一个非常忙碌的“CPU 排班员”。假设现在有:

1
2
3
4
任务A
任务B
任务C
任务D

全部都处于 Runnable 状态。调度器需要不断回答:

下一个运行谁?

而且这个决定不是只做一次。CPU 不断发生:

1
2
3
4
5
6
7
8
9
10
11
运行
↓
任务阻塞
↓
新任务唤醒
↓
任务被抢占
↓
重新选择
↓
继续运行

所以调度器实际上一直在工作。可以把整个过程理解成:

flowchart LR
    A[任务进入 Runnable] --> B[进入运行队列]
    B --> C[调度器选择任务]
    C --> D[CPU 执行]
    D --> E{任务状态变化}
    E -->|继续运行| C
    E -->|等待 I/O| F[阻塞]
    E -->|主动让出 CPU| B
    E -->|被抢占| B
    E -->|退出| G[任务结束]

这就是 CPU 调度最基本的工作流程。


Linux 有哪些调度类别?

Linux 并不是所有任务都使用同一种调度算法。内核定义了多个 Scheduling Class(调度类别)。可以先把它理解成:

不同类型的任务,进入不同的“排班体系”。

从调度类别的优先关系来看,可以粗略理解成:

1
2
3
4
5
6
7
8
9
Stop
↓
Deadline
↓
Real-time
↓
Fair
↓
Idle

也就是说:

高优先级调度类别中的可运行任务,会优先于低优先级调度类别中的任务。

对应关系可以画成:

flowchart TD
    A[Linux Scheduler] --> B[Stop]
    A --> C[Deadline]
    A --> D[Real-time]
    A --> E[Fair]
    A --> F[Idle]

    B --> B1[Stop 调度类]
    C --> C1[SCHED_DEADLINE]
    D --> D1[SCHED_FIFO]
    D --> D2[SCHED_RR]
    E --> E1[SCHED_OTHER]
    E --> E2[SCHED_BATCH]
    E --> E3[SCHED_IDLE]
    F --> F1[CPU 空闲线程]

这里先建立整体概念即可。后面的实时任务调度文章,我们会专门讲:

1
2
3
4
SCHED_FIFO
SCHED_RR
SCHED_DEADLINE
RT 内核

Fair 调度类:普通程序主要就在这里

我们平时启动的大多数程序,例如:

1
2
3
4
5
6
bash
nginx
java
python
mysql
redis

通常都属于普通的公平调度范畴。Fair 调度类包含:

1
2
3
SCHED_OTHER
SCHED_BATCH
SCHED_IDLE

其中:

1
SCHED_OTHER

是最常见的默认策略。Linux 内核源码中,SCHED_OTHER 实际对应的名称是:

1
SCHED_NORMAL

因此看到:

1
2
SCHED_OTHER
SCHED_NORMAL

不用把它们当成两种不同的调度策略。它们是同一个普通调度策略的不同名称。


三种常见的 Fair 调度策略

SCHED_OTHER

这是普通任务最常见的调度策略。

例如:

1
sleep 10
1
bash
1
python app.py

默认通常就是:

1
SCHED_OTHER

它会根据任务的调度属性和 nice 值参与公平调度。


SCHED_BATCH

SCHED_BATCH 面向的是:

CPU 密集型、非交互式的批处理任务。

例如:

1
2
3
后台数据处理
批量计算
离线任务

它和 SCHED_OTHER 一样属于普通调度范围,但是调度器会把它视为 CPU-intensive 任务,在唤醒行为上给予一定的调度惩罚。所以不要简单理解成:

“SCHED_BATCH 就是让任务运行更长时间片。”

更准确地说:

它告诉调度器:这个任务是批处理、非交互式任务,不需要特别照顾交互响应。

Linux sched(7) 对它的定义也是基于这种语义。


SCHED_IDLE

这个名字特别容易把人搞晕。因为 Linux 里面同时存在:

1
SCHED_IDLE

和:

1
Idle scheduling class

它们不是一回事。

SCHED_IDLE

这是一个可以用于任务的调度策略。它的优先级:

甚至低于 nice=19 的普通任务。

非常适合:

1
低优先级后台任务

Linux 文档也明确指出,SCHED_IDLE 的 nice 值不参与这种策略的调度决策,而且其优先级低于 SCHED_OTHER/SCHED_BATCH 下的 nice +19 任务。

Idle 调度类别

则是调度器本身用于:

CPU 没有其他可运行任务时运行的空闲线程。

所以一定要记住:

1
2
3
SCHED_IDLE
≠
Idle 调度类别

SCHED_OTHER、SCHED_BATCH、SCHED_IDLE 的关系

可以用一张图理解:

flowchart TD
    A[Fair 调度类] --> B[SCHED_OTHER]
    A --> C[SCHED_BATCH]
    A --> D[SCHED_IDLE]

    B --> B1[普通任务]
    B --> B2[受 nice 影响]

    C --> C1[批处理任务]
    C --> C2[CPU 密集型]
    C --> C3[降低交互式唤醒优先级]

    D --> D1[极低优先级]
    D --> D2[低于 nice +19]
    D --> D3[nice 对其无影响]

这一点记住以后,后面的 nice 和 chrt 就比较容易理解了。


Linux 内部的优先级到底是什么?

Linux 内核内部有一套静态优先级体系。普通任务和实时任务使用的优先级范围并不一样。对于实时任务:

1
1 ~ 99

数字越大,实时优先级越高。对于普通调度策略:

1
static priority = 100 ~ 139

对应:

1
nice = -20 ~ +19

可以粗略理解:

1
2
3
4
5
6
7
实时任务
1 ───────────────→ 99
低 高

普通任务
100 ──────────────→ 139
高 低

这里不要把:

1
2
3
4
nice
pri
priority
rtprio

混成一个东西。它们是不同层面的表示方式。


nice 到底是什么?

如果你使用 Linux 很久,一定见过:

1
nice

它并不是简单意义上的:

“CPU 优先级。”

更准确地说:

nice 是影响普通调度任务 CPU 调度权重的一个属性。

范围:

1
-20 ~ +19

其中:

1
-20

表示更有利于获得 CPU。而:

1
+19

表示更不利于获得 CPU。默认一般是:

1
nice = 0

Linux sched(7) 也明确规定了这个范围。可以简单记:

1
2
3
4
5
6
7
nice 越小
↓
越“希望”获得 CPU

nice 越大
↓
越“愿意”让 CPU 给别人

为什么叫 nice?

这个名字其实很好理解。假设服务器上正在运行:

1
MySQL

同时你想跑一个:

1
大规模压缩任务

如果压缩任务把 CPU 吃得非常厉害,就可能影响业务。这时候可以:

1
nice -n 19 tar ...

意思就是:

“这个任务我没那么着急,你先把 CPU 让给其他任务。”


使用 nice 启动程序

例如:

1
nice -n 19 ./long_task

就是让程序以:

1
nice = 19

启动。

如果使用负数:

1
nice -n -10 ./long_task

则表示希望提高该任务的调度优先程度。不过普通用户通常没有权限随意降低 nice 值,也就是不能随意设置负 nice。权限由相关资源限制和能力机制控制;具有适当权限的进程才可以提高自身调度优先程度。


renice:修改已经运行的任务

nice 主要用于:

启动程序时设置 nice。

而:

1
renice

用于:

修改已经运行任务的 nice 值。

例如:

1
renice 10 -p 372097

把 PID 372097 的 nice 设置为:

1
10

如果有足够权限,也可以降低 nice:

1
renice -20 -p 372097

一个非常重要的细节:Linux 的 nice 是线程属性

这里再补充一个很容易被忽略的地方。在 Linux 中:

nice 实际上是线程级属性。

也就是说,同一个进程中的不同线程,在 Linux 上可以拥有不同的 nice 值。例如:

1
2
3
4
5
PID 5000
│
├── TID 5000 nice=0
├── TID 5001 nice=5
└── TID 5002 nice=10

所以排查多线程程序时,不要只盯着 PID。这也是为什么我们前面强调:

Linux 调度的核心对象是线程。


systemd 服务怎么设置 nice?

如果程序是 systemd 管理的服务,可以通过 unit 配置:

1
2
[Service]
Nice=10

例如:

1
/etc/systemd/system/sshd.service.d/10-nice.conf

内容:

1
2
[Service]
Nice=10

然后:

1
2
systemctl daemon-reload
systemctl restart sshd

systemd 的 Nice= 就是用来设置服务进程的 nice 值;CPUSchedulingPolicy= 则可以指定 CPU 调度策略。这里有一个实用原则:

修改 unit 配置文件以后,通常需要 reload,然后重启服务,新的进程才能使用新的配置。


EEVDF:RHEL 10 的重要变化

终于到了今天最重要的部分:

EEVDF。

如果你以前学习 Linux 性能分析,经常会看到:

1
2
3
4
5
CFS
vruntime
sched_latency_ns
sched_min_granularity_ns
sched_wakeup_granularity_ns

但是在 RHEL 10 中:

CFS 已经被 EEVDF 替代。

Red Hat 官方文档明确说明:

1
2
3
CFS
↓
EEVDF

并且:

1
2
3
sched_min_granularity
↓
sched_base_slice

而:

1
sched_wakeup_granularity

在 EEVDF 中已经不再使用,因此被移除。所以以前很多针对 CFS 的调优文章,不能直接照搬到 RHEL 10。


EEVDF 到底是什么?

EEVDF 的全称是:

Earliest Eligible Virtual Deadline First

可以拆成三个关键词:

1
2
3
Eligible
Virtual
Deadline

不用第一次看到就被名字吓到。我们只需要先理解三个核心概念:

1
2
3
4
5
虚拟运行时间
↓
lag
↓
虚拟截止时间

然后调度器根据这些信息决定:

哪个任务现在更应该获得 CPU。


EEVDF 的核心思想

假设现在有两个任务:

1
2
Task A
Task B

它们都需要 CPU。调度器并不是简单地:

“你运行 10ms,我运行 10ms。”

而是会根据任务的:

1
2
3
权重
实际运行情况
应该获得的 CPU 时间

计算它们的调度状态。其中一个非常重要的概念就是:

lag

可以把它简单理解成:

任务实际获得的 CPU 时间,与按照自身权重应该获得的 CPU 时间之间的差距。

如果任务获得的 CPU 少于它应该获得的:

1
正 lag

它就处于“欠 CPU”的状态。如果已经获得了超过自身应得份额:

1
负 lag

则说明它暂时获得了更多 CPU。


什么是虚拟截止时间?

对于处于可调度状态的任务,EEVDF 会计算一个:

虚拟截止时间(virtual deadline)

调度器会重点考虑:

当前哪些任务已经具备运行资格,并且谁的虚拟截止时间更早。

于是可以把它简化成:

flowchart TD
    A[Runnable 任务] --> B[计算调度状态]
    B --> C{任务是否 Eligible?}

    C -->|否| D[暂时不选择]
    C -->|是| E[比较 Virtual Deadline]

    E --> F[选择更早的任务]
    F --> G[CPU 执行]

    G --> H[任务运行状态变化]
    H --> B

所以千万不要把 EEVDF 简单理解成:

“谁 deadline 最近谁运行。”

真正的机制还涉及:

1
2
3
4
eligible
lag
virtual deadline
weight

只是对于入门和性能排查来说,可以先抓住一个核心:

EEVDF 会综合任务的 CPU 份额与调度状态,为可运行任务计算虚拟截止时间,并据此选择任务。


为什么 EEVDF 对交互式任务比较友好?

举一个生活中的例子。

假设:

1
2
任务 A:CPU 密集型计算
任务 B:交互式程序

A 一直在计算:

1
2
3
4
计算
计算
计算
计算

而 B 经常:

1
2
3
4
5
6
7
8
9
运行一点
↓
等待 I/O
↓
再次唤醒
↓
运行一点
↓
再次等待

B 经常主动让出 CPU。当它再次变成 Runnable 时,调度器会考虑它之前的 CPU 获得情况。所以对于这种:

1
2
3
4
5
短时间运行
+
频繁睡眠
+
再次唤醒

的任务,EEVDF 的设计能够更好地处理其调度需求。这也是 EEVDF 相比传统 CFS 调度逻辑的重要变化之一。


EEVDF 的调优参数发生了什么变化?

这是 RHEL 10 管理员特别需要注意的地方。

以前经常看到:

1
2
3
kernel.sched_latency_ns
kernel.sched_min_granularity_ns
kernel.sched_wakeup_granularity_ns

但在新的内核中,部分旧参数已经发生变化。

其中:

1
2
3
sched_min_granularity
↓
sched_base_slice

Red Hat 官方明确说明:

sched_base_slice 定义了任务可以被延迟运行的最小时间。

在支持 debugfs 的环境中,可以看到:

1
cat /sys/kernel/debug/sched/base_slice_ns

也可以看到:

1
ls /sys/kernel/debug/sched/

需要注意:

不要简单把 base_slice_ns 理解成“任务固定运行这么长时间”。

它更准确的含义是:

调度器用于控制任务调度延迟/切片行为的基础参数。


migration_cost_ns 还存在吗?

这里也需要纠正一个很容易被旧资料误导的地方。很多旧文章会说:

1
kernel.sched_migration_cost_ns

现在在较新的内核中,这类调度参数可能已经从:

1
/proc/sys/kernel/

迁移到:

1
/sys/kernel/debug/sched/

例如:

1
cat /sys/kernel/debug/sched/migration_cost_ns

当前 Linux 内核源码仍然提供这个 debugfs 调度参数。

因此不能简单写成:

“EEVDF 已经完全没有 migration_cost_ns。”

更准确的说法是:

部分传统 scheduler 参数的管理位置和语义发生了变化,需要根据实际内核版本确认。

这也是为什么生产环境调优不能直接复制十年前的:

1
sysctl -w kernel.sched_xxx=...

debugfs 中查看调度器参数

可以先确认 debugfs 是否挂载:

1
2
[root@localhost ~]# mount | grep debugfs
debugfs on /sys/kernel/debug type debugfs (rw,nosuid,nodev,noexec,relatime,seclabel)

然后查看:

1
2
3
4
[root@localhost ~]# ls /sys/kernel/debug/sched/
base_slice_ns features migration_cost_ns preempt
debug latency_warn_ms nr_migrate tunable_scaling
fair_server latency_warn_once numa_balancing verbose

例如:

1
2
[root@localhost ~]# cat /sys/kernel/debug/sched/base_slice_ns
2100000

以及:

1
2
[root@localhost ~]# cat /sys/kernel/debug/sched/migration_cost_ns
500000

如果你的系统没有这些文件,不要直接认为:

“我的 EEVDF 坏了。”

因为具体可用接口还取决于:

1
2
3
内核版本
内核配置
debugfs

所以实际环境中:

以当前系统实际暴露出来的接口为准。


为什么 debugfs 参数重启后会丢?

因为这些参数位于:

1
/sys/kernel/debug/

这个目录属于:

debugfs

它不是传统意义上的持久化配置文件。因此:

1
echo 3000000 > /sys/kernel/debug/sched/base_slice_ns

这种修改通常只是:

当前运行内核中的临时修改。

重启之后不会自动保留。如果确实需要在启动时设置,可以通过 systemd 的 oneshot 服务执行。

例如:

1
2
3
4
5
6
7
8
9
10
[Unit]
Description=Apply Scheduler Tunables
After=sys-kernel-debug.mount

[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo 3000000 > /sys/kernel/debug/sched/base_slice_ns'

[Install]
WantedBy=multi-user.target

然后:

1
2
systemctl daemon-reload
systemctl enable --now eevdf-tuning.service

不过这里强烈建议:

不要为了“学习 EEVDF”就随便修改生产服务器的调度参数。

调度器属于系统核心路径。除非你已经明确知道:

1
2
3
4
5
为什么修改
修改什么
预期改善什么
如何验证
出现问题如何恢复

否则不要为了追求某个所谓的“最佳值”而调整。


怎么看 Linux 调度器统计信息?

可以查看:

1
cat /proc/schedstat

它提供的是调度器相关的统计数据。不过:

/proc/schedstat 的具体字段会随着内核版本变化。

因此不要直接死记某一个字段。如果需要深入分析调度器行为,更应该结合:

1
2
3
4
5
6
7
/proc/schedstat
+
/proc/[pid]/sched
+
perf
+
eBPF

一起分析。


chrt:查看和修改调度策略

如果:

1
nice

主要用于调整普通任务的 nice,那么:

1
chrt

则可以直接查看和修改线程的调度策略。例如:

1
chrt -p 5467

查看某个线程的调度策略。常见参数:

chrt 参数调度策略
-oSCHED_OTHER
-bSCHED_BATCH
-iSCHED_IDLE
-fSCHED_FIFO
-rSCHED_RR
-dSCHED_DEADLINE

Linux 支持的普通调度策略包括:

1
2
3
SCHED_OTHER
SCHED_BATCH
SCHED_IDLE

而实时/截止时间相关策略包括:

1
2
3
SCHED_FIFO
SCHED_RR
SCHED_DEADLINE

普通策略的静态调度优先级必须为:

1
0

而 SCHED_FIFO 和 SCHED_RR 使用:

1
1 ~ 99

的实时优先级。


使用 chrt 修改普通任务

例如把线程设置为:

1
SCHED_BATCH

可以:

1
chrt -b -p 0 5890

查看:

1
chrt -p 5890

设置:

1
SCHED_IDLE

可以:

1
chrt -i -p 0 5890

恢复成普通:

1
SCHED_OTHER

可以:

1
chrt -o -p 0 5890

这里再次强调:

SCHED_IDLE 是一种极低优先级的调度策略,不等于 CPU 的 Idle 调度类别。


systemd 也可以设置调度策略

对于 systemd 管理的服务,可以使用:

1
2
[Service]
CPUSchedulingPolicy=idle

例如:

1
/etc/systemd/system/plocate-updatedb.service.d/10-idle.conf

写入:

1
2
[Service]
CPUSchedulingPolicy=idle

然后:

1
2
systemctl daemon-reload
systemctl restart plocate-updatedb.service

systemd 的 CPUSchedulingPolicy= 可以用于设置 CPU 调度策略。这里同样提醒:

不要把 idle 理解成“让服务停止运行”。

它表示的是:

让该服务采用 SCHED_IDLE 调度策略。


上下文切换是什么?

现在我们再来看一个 CPU 调度里非常重要的指标:

Context Switch(上下文切换)

假设:

1
任务 A 正在 CPU 上运行

突然调度器决定:

1
任务 B 应该运行

于是 CPU 就需要从:

1
任务 A

切换到:

1
任务 B

这个过程就涉及:

上下文切换。

可以简单理解为:

sequenceDiagram
    participant CPU
    participant A as 任务 A
    participant S as Scheduler
    participant B as 任务 B

    CPU->>A: 执行任务 A
    A->>S: 状态发生变化
    S->>A: 保存/切出
    S->>B: 选择任务 B
    B->>CPU: 开始执行

上下文切换本身不是错误。

Linux 系统中:

上下文切换是非常正常的事情。

真正需要关注的是:

上下文切换是不是异常频繁。


自愿上下文切换和非自愿上下文切换

Linux 会区分两类上下文切换。

1. voluntary context switch

也就是:

1
voluntary_ctxt_switches

任务主动放弃 CPU。例如:

1
2
3
4
等待 I/O
sleep
等待锁
等待其他资源

2. nonvoluntary context switch

也就是:

1
nonvoluntary_ctxt_switches

任务并不是主动让出 CPU,而是:

被调度器切走。

例如:

1
被其他任务抢占

可以查看:

1
grep -E 'voluntary|nonvoluntary' /proc/4350/status

输出类似:

1
2
voluntary_ctxt_switches:        123
nonvoluntary_ctxt_switches: 45

这里不要简单理解成:

“nonvoluntary 越多系统越差。”

上下文切换是否异常,需要结合:

1
2
3
4
5
6
7
CPU 使用率
任务数量
运行队列
系统调用
I/O
调度延迟
业务负载

一起判断。


把整个 CPU 调度过程串起来

到这里,我们可以把今天的知识串起来了。假设:

1
服务器上有 100 个线程

但是:

1
CPU 只有 8 个核心

于是大量线程可能处于:

1
Runnable

状态。它们进入不同 CPU 的运行队列。然后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Linux Scheduler
│
▼
判断哪些任务可以运行
│
▼
按照调度类别选择
│
▼
Fair / RT / Deadline ...
│
▼
对于 Fair 任务进一步考虑调度状态
│
▼
EEVDF
│
▼
选择下一个任务
│
▼
CPU 执行

可以用一张图总结:

flowchart TD
    A[大量 Runnable 线程] --> B[进入 CPU 运行队列]

    B --> C[Linux Scheduler]

    C --> D{调度类别}

    D -->|Stop| E[Stop 调度类]
    D -->|Deadline| F[SCHED_DEADLINE]
    D -->|Real-time| G[SCHED_FIFO / SCHED_RR]
    D -->|Fair| H[公平调度类]
    D -->|Idle| I[Idle 调度类]

    H --> J[SCHED_OTHER]
    H --> K[SCHED_BATCH]
    H --> L[SCHED_IDLE]

    J --> M[EEVDF]
    K --> M
    L --> M

    M --> N[选择下一个可运行任务]
    N --> O[CPU 执行]

CPU 调度问题应该怎么排查?

现在回到最开始的问题:

“服务器 CPU 很高,到底发生了什么?”

以前我们可能只会:

1
top

看到:

1
%CPU = 98%

然后说:

“CPU 很高。”

但是现在应该进一步问:

这些 CPU 时间到底被谁使用?

以及:

有没有任务因为 CPU 不够而排队?

这时候就可以结合我们前面已经学过的工具。

例如:

1
top

先看整体情况。

然后:

1
vmstat 1

观察系统整体运行状态。

再结合:

1
perf

分析 CPU 时间主要消耗在哪里。

如果怀疑:

大量任务正在等待 CPU。

就可以使用前面 eBPF 文章学过的:

1
/usr/share/bcc/tools/runqlat

观察:

任务在运行队列中等待 CPU 的时间。

所以整个思路可以变成:

flowchart TD
    A["CPU / Load 异常"] --> B["top / vmstat"]
    B --> C{"进一步观察"}

    C -->|"CPU 时间被程序大量消耗"| D["perf"]
    C -->|"任务排队等待 CPU"| E["runqlat"]
    C -->|"调度策略 / nice 异常"| F["chrt / ps"]
    C -->|"上下文切换异常"| G["/proc/PID/status"]

    D --> H["继续定位具体问题"]
    E --> H
    F --> H
    G --> H

这时候你会发现:

CPU 调度并不是一个孤立的知识点。

它正好把我们前面学过的很多性能工具串了起来。


几个非常容易混淆的概念

最后把这一篇最容易混淆的几个概念集中放在这里。

1. 进程 ≠ 调度单位

Linux 真正调度的是:

1
线程

不是我们平时口语中的“进程”。


2. SCHED_IDLE ≠ Idle 调度类

1
SCHED_IDLE

是一个极低优先级的任务调度策略。

而:

1
Idle scheduling class

属于调度器内部的空闲类别。两者完全不是一回事。


3. nice ≠ 实时优先级

1
nice

主要影响普通调度任务。

而:

1
rtprio

对应实时调度优先级。不要混在一起。


4. CFS ≠ EEVDF

在传统 Linux 内核中:

1
CFS

是经典公平调度器。而现代 Linux 内核已经逐渐转向:

1
EEVDF

RHEL 10 官方已经明确采用 EEVDF。所以以后看到:

1
2
3
sched_min_granularity_ns
sched_latency_ns
sched_wakeup_granularity_ns

不要直接套用到 RHEL 10。


这一篇真正应该记住什么?

如果今天的内容全部忘掉,只记住下面这张图:

flowchart TD
    A[线程 Runnable] --> B[CPU Run Queue]
    B --> C[Linux Scheduler]

    C --> D{Scheduling Class}

    D --> E[Stop]
    D --> F[Deadline]
    D --> G[Real-time]
    D --> H[Fair]

    H --> I[SCHED_OTHER]
    H --> J[SCHED_BATCH]
    H --> K[SCHED_IDLE]

    I --> L[EEVDF]
    J --> L
    K --> L

    L --> M[选择下一个任务]
    M --> N[CPU 执行]

    N --> O{状态变化}
    O -->|等待 I/O| P[阻塞]
    O -->|被抢占| B
    O -->|继续运行| M
    O -->|退出| Q[结束]

然后记住几个最关键的命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 查看线程
ps -L

# 查看调度策略
chrt -p PID

# 查看 nice
ps -eo pid,tid,ni,pri,rtprio,policy,comm

# 修改 nice
renice 10 -p PID

# 查看上下文切换
grep -E 'voluntary|nonvoluntary' /proc/PID/status

# 查看调度器统计
cat /proc/schedstat

# 查看 EEVDF 相关调度参数
ls /sys/kernel/debug/sched/
cat /sys/kernel/debug/sched/base_slice_ns

如果服务器出现 CPU 负载高的问题,也不要再停留在:

1
2
3
4
5
top
↓
CPU 99%
↓
CPU 很高

而应该逐渐形成这样的思维:

1
2
3
4
5
6
7
8
9
10
11
12
13
CPU 很高
↓
是谁在消耗 CPU?
↓
任务有没有排队?
↓
调度延迟怎么样?
↓
是什么调度策略?
↓
nice / 调度策略是否合理?
↓
上下文切换是否异常?

这才是真正开始理解:

Linux CPU 调度。