第12讲 调度II

第12讲 调度II
agsd第12讲 调度 II:多核调度、负载衡量与能耗感知
本文根据《第12讲 调度II.md》的对话转录整理而成。核心问题是:当系统从单核走向多核、异构多核后,调度器不仅要决定“谁运行”,还要决定“在哪个 CPU 上运行”,并在负载均衡、缓存友好、性能与能耗之间做取舍。
1. 从单核调度到多核调度
在单核调度中,核心问题主要是:
- 下一个运行哪个任务?
- 这个任务运行多久?
但在多核系统中,还要增加一个问题:
- 这个任务应该在哪个 CPU 核上运行?
因此,多核调度可以概括为:
多核调度 = 选任务 + 选 CPU + 控制运行时长
这比单核调度复杂得多。因为不同 CPU 核之间可能存在负载差异、缓存差异,甚至性能与功耗差异。
例如在一个四核系统中,任务 A 可以放在 CPU0,也可以放在 CPU2。表面上只是换一个 CPU,实际上会影响:
- 该 CPU 是否已经很忙
- 该 CPU 缓存中是否已有任务 A 的数据
- 该 CPU 是大核还是小核
- 迁移任务是否会破坏局部性
- 系统整体能耗是否会上升
flowchart TD
A[就绪任务] --> B{多核调度器}
B --> C[选择任务]
B --> D[选择 CPU]
B --> E[控制运行时长]
D --> F[CPU0]
D --> G[CPU1]
D --> H[CPU2]
D --> I[CPU3]
这张图强调:多核调度不是单纯把单核调度复制多份,而是多了“任务放到哪个 CPU 上”的决策维度。
2. BSP:并行执行与同步点
BSP,即 Bulk Synchronous Parallel,是一种常见的并行计算模型。它的关键不是“所有线程在同一个 CPU 上执行”,恰恰相反,BSP 通常就是为多核、多机并行设计的。
BSP 的基本思想是:
- 一轮中没有依赖的任务并行执行。
- 到达同步点后,各任务等待其他任务完成。
- 同步点进行数据交换或状态对齐。
- 然后进入下一轮并行计算。
可以理解为:
1 | 逻辑核心 0:执行一部分任务 |
2.1 同一进程多线程中的数据交换
如果多个线程属于同一个进程,并运行在同一台机器的多个 CPU 核上,它们通常共享同一个地址空间。因此,它们可以通过共享内存交换数据。
例如:
1 | // 线程 1 |
这里所谓“交换数据”,不一定是 CPU0 真的把数据搬到 CPU1,而是:
- 各线程把结果写入共享内存;
- 通过 barrier 等同步机制保证大家都写完;
- 下一轮中其他线程安全读取这些结果。
所以,同一进程多线程的核心机制是:
共享内存 + 同步原语
常见同步原语包括:
- barrier
- mutex
- condition variable
- semaphore
- atomic operation
2.2 分布式系统中的数据交换
如果任务运行在不同机器上,就不能依赖共享内存。这时需要通过网络交换数据,例如:
- 消息传递
- RPC
- 分布式框架中的 shuffle
- socket 通信
- 分布式文件系统或对象存储
所以 BSP 中“同步交换数据”要分情况理解:
| 场景 | 数据交换方式 |
|---|---|
| 同一进程多线程 | 共享内存 + 同步原语 |
| 不同进程 | IPC / 共享内存 / 管道 / socket |
| 不同机器 | 网络通信 / RPC / shuffle |
❗ 本节核心结论: BSP 的重点不是线程是否在同一个 CPU 上,而是“并行阶段 + 同步点 + 下一轮并行”的计算结构。
3. 两级调度:全局调度器与本地调度器
“两级调度”的“两级”不是指“选任务”和“运行任务”两个步骤,而是指:
- 全局调度器
- 每个 CPU 的本地调度器
可以理解为“总管 + 分店店长”模式:
| 角色 | 对应调度结构 | 负责内容 |
|---|---|---|
| 总管 | 全局调度器 | 决定任务大致分配到哪个 CPU |
| 分店店长 | 本地调度器 | 每个 CPU 从自己的队列里选任务 |
| 分店排队表 | 本地运行队列 | 保存该 CPU 上等待运行的任务 |
结构如下:
flowchart TD
A[全局调度器] --> B[CPU0 本地调度器]
A --> C[CPU1 本地调度器]
A --> D[CPU2 本地调度器]
A --> E[CPU3 本地调度器]
B --> BQ[CPU0 运行队列 rq]
C --> CQ[CPU1 运行队列 rq]
D --> DQ[CPU2 运行队列 rq]
E --> EQ[CPU3 运行队列 rq]
Linux 中,每个 CPU 都有自己的运行队列,通常称为 rq,也就是 struct rq。
两级调度的好处是:
- 每个 CPU 可以快速从本地队列中选择任务;
- 不需要所有 CPU 每次都竞争一个全局队列;
- 可以提高可扩展性;
- 可以减少任务频繁迁移;
- 也可以在必要时进行负载均衡。
但它也带来一个问题:
每个 CPU 都有自己的队列,如何判断某个 CPU 是否过载?又如何决定是否迁移任务?
这就引出了负载衡量与负载均衡。
4. CPU 负载不能只看任务数量
判断 CPU 是否繁忙,不能简单看运行队列中有几个任务。
原因是不同任务对 CPU 的消耗完全不同。
| 任务类型 | 行为特征 | 对 CPU 的负载 |
|---|---|---|
| I/O 密集型任务 | 短暂使用 CPU,然后等待 I/O | 可能较低 |
| CPU 密集型任务 | 长时间持续计算 | 通常较高 |
| 交互式任务 | 间歇性运行,需要快速响应 | 瞬时敏感 |
| 后台批处理任务 | 长时间运行但不急 | 可被延后 |
例如:
- CPU0 上有 5 个 I/O 密集型任务,它们大部分时间在等待磁盘或网络。
- CPU1 上只有 1 个 CPU 密集型任务,但它持续满负载计算。
如果只看任务数量,会误以为 CPU0 更忙。但实际 CPU 使用压力可能是 CPU1 更高。
所以,多核调度需要更精细的负载指标,而不是简单的“任务个数”。
❗ 本节核心结论: 运行队列长度只能粗略反映负载,不能准确表示 CPU 压力;真正重要的是任务实际消耗了多少 CPU。
5. 任务迁移与缓存局部性
多核调度需要负载均衡。如果 CPU0 很忙而 CPU1 很空,调度器可能把一部分任务从 CPU0 迁移到 CPU1。
但任务迁移不是免费的。
常见误解是:任务迁移性能差,是因为要把整个任务状态复制到另一个 CPU。
这个说法不准确。任务的 PCB、TCB、地址空间等状态本来就在内存中,调度器并不是把整个进程复制到另一个 CPU。
真正关键的问题是:
任务迁移会破坏缓存局部性。
任务在 CPU0 上运行一段时间后,它访问过的指令和数据可能已经进入 CPU0 相关缓存。此时如果迁移到 CPU1,CPU1 的缓存中可能没有这些数据,于是会出现更多 cache miss,需要从更低层缓存或内存重新加载。
流程可以理解为:
flowchart LR
A[任务在 CPU0 上运行] --> B[CPU0 缓存中已有任务数据]
B --> C[任务迁移到 CPU1]
C --> D[CPU1 缓存缺少相关数据]
D --> E[cache miss 增多]
E --> F[性能下降]
所以,负载均衡和缓存友好之间存在冲突:
| 目标 | 好处 | 风险 |
|---|---|---|
| 负载均衡 | 避免某些 CPU 过忙、某些 CPU 空闲 | 可能迁移任务 |
| 缓存友好 | 任务长期留在同一 CPU,缓存命中率高 | 可能导致负载不均衡 |
调度器必须在二者之间取舍。
❗ 本节核心结论: 任务迁移的主要代价不是复制整个任务状态,而是破坏缓存局部性,导致 cache miss 增多。
6. PELT:以调度实体为粒度追踪负载
Linux 中常用 PELT,即 Per-Entity Load Tracking,来追踪负载。
这里的 entity 是调度实体。调度实体不能简单等同于“进程”。在调度器视角中,调度实体可以是:
- 线程
- 任务
- 调度组
- 其他可被调度器管理的对象
因此,不能说“一个调度实体就对应一个进程”。更准确的说法是:
调度实体是调度器眼中可以被调度和计量负载的对象。
为什么 PELT 要以调度实体为粒度?
因为只看 CPU 队列长度无法准确判断负载。不同任务实际运行时间、可运行时间和 CPU 消耗不同。调度器需要知道每个任务对负载的具体贡献。
例如:
| 情况 | 队列长度 | 实际负载 |
|---|---|---|
| 5 个 I/O 密集型任务 | 5 | 不一定高 |
| 1 个 CPU 密集型任务 | 1 | 可能很高 |
PELT 的意义就在于:
- 记录任务最近一段时间的 CPU 使用情况;
- 让越近的行为权重越高;
- 让越久远的行为逐渐衰减;
- 形成更能反映当前状态的负载估计。
7. 历史负载衰减:针对过去,不是未来
历史负载衰减是 PELT 中非常重要的思想。
它针对的是过去的历史负载,不是预测未来负载会衰减。
可以抽象写成:
1 | 当前负载估计 = |
也就是说:
| 时间位置 | 权重 |
|---|---|
| 当前周期 | 最大 |
| 上一周期 | 乘 γ |
| 上上周期 | 乘 γ² |
| 更早周期 | 乘更高次 γ |
越久以前的负载,乘的 γ 次数越多,权重越小。
Linux 中有一个典型关系:
1 | γ^32 = 0.5 |
意思是:一个负载贡献经过 32 个周期后,影响力衰减到原来的一半。
可以这样理解:
- 刚刚很忙:很能说明任务现在可能还忙。
- 10ms 前很忙:仍有参考价值,但弱一些。
- 很久以前很忙:参考价值很低。
所以,历史衰减的目的不是预测未来,而是评价过去时,把越久远的历史看得越轻。
❗ 本节核心结论: 衰减系数 γ 作用于过去的历史负载;越久以前的行为,对当前负载判断的影响越小。
8. CpuScaleFactor:异构 CPU 下的负载标准化
在传统同构多核系统中,每个 CPU 核性能大致相同。同样运行 1ms,完成的工作量差不多。
但在异构多核系统中,例如大小核架构,大核和小核性能不同:
| CPU 类型 | 特点 |
|---|---|
| 大核 | 性能强,功耗高 |
| 小核 | 性能弱,功耗低 |
这时,如果只看“运行了多久”,就不够准确。
同样运行 1ms:
- 大核可能完成更多计算;
- 小核完成的计算较少。
因此,需要 CpuScaleFactor 这类参数,把不同 CPU 上的负载统一到同一标准下。
它的作用是:
让不同性能 CPU 核上的负载可以比较。
可以理解为:
1 | 标准化负载 = 原始负载 × CPU 性能缩放因子 |
性能更强的 CPU,ScaleFactor 更高。这样调度器才能知道:
- 某个任务放在大核上是否浪费;
- 某个任务放在小核上是否会跑不动;
- 哪个 CPU 更适合当前任务。
9. 处理器亲和性:缓存友好与负载风险
处理器亲和性 processor affinity 指的是:限制任务可以在哪些 CPU 上运行。
它不一定是“强制任务只在某个 CPU 上运行”,也可以是设置一个允许运行的 CPU 集合。
例如:
1 | 任务 A 只能在 CPU0、CPU1 上运行 |
处理器亲和性的好处包括:
- 提高缓存命中率;
- 减少任务迁移;
- 增强调度可控性;
- 便于将某些任务限制在特定核心上;
- 在大小核系统中,可以间接影响任务放到高性能核或节能核。
但它也有风险:
- 如果限制过死,可能导致某些 CPU 很忙,其他 CPU 空闲;
- 可能破坏负载均衡;
- 可能让任务无法迁移到更合适的 CPU;
- 在复杂系统中可能降低整体吞吐量。
所以,处理器亲和性不是越强越好,而是要根据任务需求设置。
❗ 本节核心结论: 处理器亲和性能提高缓存友好性和调度可控性,但过度限制会造成负载不均衡。
10. 大小核调度:任务应放在哪类核心上
在大小核系统中,调度器不仅要考虑 CPU 是否空闲,还要考虑任务适合大核还是小核。
一个粗略原则是:
| 任务类型 | 更适合的核心 |
|---|---|
| 轻负载任务 | 小核 |
| 后台任务 | 小核 |
| 重计算任务 | 大核 |
| 延迟敏感任务 | 大核 |
| 短时间紧急任务 | 可临时放大核 |
| 长时间低优先级任务 | 小核或中等核心 |
例如:
- 后台同步、轻量日志处理:适合小核,节能。
- 游戏主线程、UI 渲染、视频编码:更适合大核,保证性能。
- 临时突发的交互响应:可以短时间放大核,降低延迟。
这里的目标不是“所有任务都放大核”,因为大核虽然快,但功耗高、发热高。如果轻任务也全部放大核,系统能耗会显著上升。
11. 能耗感知调度:性能与功耗的权衡
能耗感知调度 Energy-Aware Scheduling,简称 EAS,关注的问题不是单纯负载均衡,而是:
在满足性能需求的前提下,尽量降低能耗。
这和普通负载均衡不一样。
| 调度目标 | 关注重点 |
|---|---|
| 负载均衡 | CPU 之间负载是否均匀 |
| 能耗感知调度 | 当前任务放在哪个 CPU 上更省电,同时性能够用 |
能耗感知调度器通常会结合:
- 任务负载;
- CPU 性能容量;
- CPU 功耗模型;
- 大小核差异;
- 当前系统状态;
- 任务是否延迟敏感。
它的典型策略是:
- 轻任务放小核,节能;
- 重任务放大核,保证性能;
- 任务负载变化时动态调整;
- 在性能足够的前提下选择功耗更低的核心。
所以,如果把能耗感知调度理解为“动态分析 CPU 之间的负载均衡情况”,就不够准确。那更像是在描述普通负载均衡。
更准确的表述是:
能耗感知调度是在性能和功耗之间做权衡。
flowchart TD
A[任务到达] --> B[估计任务负载]
B --> C[查看 CPU 性能容量]
C --> D[结合功耗模型]
D --> E{性能是否足够}
E -->|小核足够| F[选择小核以节能]
E -->|小核不足| G[选择大核以保证性能]
❗ 本节核心结论: 能耗感知调度不是单纯追求负载均衡,而是在性能够用的前提下尽量省电。
12. 易混概念整理
| 易混点 | 正确理解 |
|---|---|
| BSP 是否要求线程在同一 CPU | 不要求,BSP 通常用于多核或分布式并行 |
| BSP 的同步交换数据 | 同机可用共享内存 + barrier,分布式用网络通信 |
| 两级调度的“两级” | 全局调度器 + 每个 CPU 的本地调度器 |
| Linux 每 CPU 运行队列 | rq,即 struct rq |
| CPU 负载是否等于任务数量 | 不是,任务类型和实际 CPU 消耗更重要 |
| 任务迁移为何影响性能 | 主要是破坏缓存局部性,不是复制整个任务状态 |
| 调度实体是否等于进程 | 不一定,调度实体是调度器计量和调度的对象 |
| PELT 衰减针对什么 | 针对过去历史负载,不是未来 |
| CpuScaleFactor 的作用 | 标准化不同性能 CPU 核上的负载 |
| 处理器亲和性 | 限制任务可运行的 CPU 集合 |
| 能耗感知调度 | 性能与功耗权衡,不只是负载均衡 |
13. 复习清单
学完本讲后,应能回答:
- 多核调度相比单核调度多了什么问题?
- BSP 中同步点的作用是什么?
- 同一进程多线程和分布式任务如何交换数据?
- 两级调度的两级分别是什么?
- Linux 中每个 CPU 的运行队列叫什么?
- 为什么不能用任务数量简单衡量 CPU 负载?
- 任务迁移为什么会破坏缓存局部性?
- 负载均衡和缓存友好为什么存在冲突?
- PELT 为什么要以调度实体为粒度追踪负载?
- 调度实体为什么不一定等于进程?
- 历史负载衰减为什么针对过去?
γ^32 = 0.5表示什么?- CpuScaleFactor 为什么在大小核系统中重要?
- 处理器亲和性的好处和风险是什么?
- 能耗感知调度和普通负载均衡有什么区别?
- 大小核系统中,轻任务和重任务通常分别放在哪里?
最终总结
调度 II 的核心可以压缩为一句话:
多核调度不只是决定哪个任务运行,还要决定任务在哪个 CPU 上运行,并在负载均衡、缓存局部性、处理器亲和性、异构核心性能和系统能耗之间做取舍。
从单核到多核后,调度问题从“一条队列上谁先运行”扩展为“多个 CPU 上如何分配任务”。两级调度用全局调度器和本地调度器解决可扩展性问题;PELT 用历史衰减追踪任务负载;CpuScaleFactor 让不同性能核心上的负载可比较;处理器亲和性提高缓存友好但可能破坏负载均衡;能耗感知调度则进一步把功耗纳入调度决策。
真正理解调度 II,需要抓住一个中心矛盾:任务迁移有助于负载均衡,但会损害缓存局部性;大核能提升性能,但会增加能耗;小核能省电,但可能无法满足重任务和延迟敏感任务的需求。 操作系统调度器的价值,就在于不断在这些目标之间做动态权衡。




