第12讲 调度II

第12讲 调度 II:多核调度、负载衡量与能耗感知

本文根据《第12讲 调度II.md》的对话转录整理而成。核心问题是:当系统从单核走向多核、异构多核后,调度器不仅要决定“谁运行”,还要决定“在哪个 CPU 上运行”,并在负载均衡、缓存友好、性能与能耗之间做取舍。


1. 从单核调度到多核调度

在单核调度中,核心问题主要是:

  1. 下一个运行哪个任务?
  2. 这个任务运行多久?

但在多核系统中,还要增加一个问题:

  1. 这个任务应该在哪个 CPU 核上运行?

因此,多核调度可以概括为:

多核调度 = 选任务 + 选 CPU + 控制运行时长

这比单核调度复杂得多。因为不同 CPU 核之间可能存在负载差异、缓存差异,甚至性能与功耗差异。

例如在一个四核系统中,任务 A 可以放在 CPU0,也可以放在 CPU2。表面上只是换一个 CPU,实际上会影响:

  • 该 CPU 是否已经很忙
  • 该 CPU 缓存中是否已有任务 A 的数据
  • 该 CPU 是大核还是小核
  • 迁移任务是否会破坏局部性
  • 系统整体能耗是否会上升

这张图强调:多核调度不是单纯把单核调度复制多份,而是多了“任务放到哪个 CPU 上”的决策维度。


2. BSP:并行执行与同步点

BSP,即 Bulk Synchronous Parallel,是一种常见的并行计算模型。它的关键不是“所有线程在同一个 CPU 上执行”,恰恰相反,BSP 通常就是为多核、多机并行设计的。

BSP 的基本思想是:

  1. 一轮中没有依赖的任务并行执行。
  2. 到达同步点后,各任务等待其他任务完成。
  3. 同步点进行数据交换或状态对齐。
  4. 然后进入下一轮并行计算。

可以理解为:

1
2
3
4
5
6
7
逻辑核心 0:执行一部分任务
逻辑核心 1:执行一部分任务
逻辑核心 2:执行一部分任务

同步点:等待 / 交换数据 / 对齐状态

进入下一轮并行

2.1 同一进程多线程中的数据交换

如果多个线程属于同一个进程,并运行在同一台机器的多个 CPU 核上,它们通常共享同一个地址空间。因此,它们可以通过共享内存交换数据。

例如:

1
2
3
4
5
6
7
8
9
10
// 线程 1
result[0] = partial_result_0;

// 线程 2
result[1] = partial_result_1;

// 同步点
barrier_wait();

// 下一轮中,所有线程都可以读取 result

这里所谓“交换数据”,不一定是 CPU0 真的把数据搬到 CPU1,而是:

  • 各线程把结果写入共享内存;
  • 通过 barrier 等同步机制保证大家都写完;
  • 下一轮中其他线程安全读取这些结果。

所以,同一进程多线程的核心机制是:

共享内存 + 同步原语

常见同步原语包括:

  • barrier
  • mutex
  • condition variable
  • semaphore
  • atomic operation

2.2 分布式系统中的数据交换

如果任务运行在不同机器上,就不能依赖共享内存。这时需要通过网络交换数据,例如:

  • 消息传递
  • RPC
  • 分布式框架中的 shuffle
  • socket 通信
  • 分布式文件系统或对象存储

所以 BSP 中“同步交换数据”要分情况理解:

场景 数据交换方式
同一进程多线程 共享内存 + 同步原语
不同进程 IPC / 共享内存 / 管道 / socket
不同机器 网络通信 / RPC / shuffle

本节核心结论: BSP 的重点不是线程是否在同一个 CPU 上,而是“并行阶段 + 同步点 + 下一轮并行”的计算结构。

3. 两级调度:全局调度器与本地调度器

“两级调度”的“两级”不是指“选任务”和“运行任务”两个步骤,而是指:

  1. 全局调度器
  2. 每个 CPU 的本地调度器

可以理解为“总管 + 分店店长”模式:

角色 对应调度结构 负责内容
总管 全局调度器 决定任务大致分配到哪个 CPU
分店店长 本地调度器 每个 CPU 从自己的队列里选任务
分店排队表 本地运行队列 保存该 CPU 上等待运行的任务

结构如下:

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,需要从更低层缓存或内存重新加载。

流程可以理解为:

所以,负载均衡和缓存友好之间存在冲突:

目标 好处 风险
负载均衡 避免某些 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
2
3
4
5
6
当前负载估计 =
当前周期负载
+ γ × 上一周期负载
+ γ² × 上上周期负载
+ γ³ × 更早周期负载
+ ...

也就是说:

时间位置 权重
当前周期 最大
上一周期 乘 γ
上上周期 乘 γ²
更早周期 乘更高次 γ

越久以前的负载,乘的 γ 次数越多,权重越小。

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
2
任务 A 只能在 CPU0、CPU1 上运行
任务 B 可以在 CPU0 ~ CPU7 上运行

处理器亲和性的好处包括:

  • 提高缓存命中率;
  • 减少任务迁移;
  • 增强调度可控性;
  • 便于将某些任务限制在特定核心上;
  • 在大小核系统中,可以间接影响任务放到高性能核或节能核。

但它也有风险:

  • 如果限制过死,可能导致某些 CPU 很忙,其他 CPU 空闲;
  • 可能破坏负载均衡;
  • 可能让任务无法迁移到更合适的 CPU;
  • 在复杂系统中可能降低整体吞吐量。

所以,处理器亲和性不是越强越好,而是要根据任务需求设置。


本节核心结论: 处理器亲和性能提高缓存友好性和调度可控性,但过度限制会造成负载不均衡。

10. 大小核调度:任务应放在哪类核心上

在大小核系统中,调度器不仅要考虑 CPU 是否空闲,还要考虑任务适合大核还是小核。

一个粗略原则是:

任务类型 更适合的核心
轻负载任务 小核
后台任务 小核
重计算任务 大核
延迟敏感任务 大核
短时间紧急任务 可临时放大核
长时间低优先级任务 小核或中等核心

例如:

  • 后台同步、轻量日志处理:适合小核,节能。
  • 游戏主线程、UI 渲染、视频编码:更适合大核,保证性能。
  • 临时突发的交互响应:可以短时间放大核,降低延迟。

这里的目标不是“所有任务都放大核”,因为大核虽然快,但功耗高、发热高。如果轻任务也全部放大核,系统能耗会显著上升。


11. 能耗感知调度:性能与功耗的权衡

能耗感知调度 Energy-Aware Scheduling,简称 EAS,关注的问题不是单纯负载均衡,而是:

在满足性能需求的前提下,尽量降低能耗。

这和普通负载均衡不一样。

调度目标 关注重点
负载均衡 CPU 之间负载是否均匀
能耗感知调度 当前任务放在哪个 CPU 上更省电,同时性能够用

能耗感知调度器通常会结合:

  • 任务负载;
  • CPU 性能容量;
  • CPU 功耗模型;
  • 大小核差异;
  • 当前系统状态;
  • 任务是否延迟敏感。

它的典型策略是:

  • 轻任务放小核,节能;
  • 重任务放大核,保证性能;
  • 任务负载变化时动态调整;
  • 在性能足够的前提下选择功耗更低的核心。

所以,如果把能耗感知调度理解为“动态分析 CPU 之间的负载均衡情况”,就不够准确。那更像是在描述普通负载均衡。

更准确的表述是:

能耗感知调度是在性能和功耗之间做权衡。


本节核心结论: 能耗感知调度不是单纯追求负载均衡,而是在性能够用的前提下尽量省电。

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,需要抓住一个中心矛盾:任务迁移有助于负载均衡,但会损害缓存局部性;大核能提升性能,但会增加能耗;小核能省电,但可能无法满足重任务和延迟敏感任务的需求。 操作系统调度器的价值,就在于不断在这些目标之间做动态权衡。