第 11 章 调度:公平、吞吐与延迟的不可能三角
前面讲了缓存、零拷贝、多路复用——都是”怎么把一件事做快”。 这一章讲一个更上游的问题:CPU 只有那么几个核,却有成千上万个任务等着跑,先跑谁?
这就是调度(scheduling)。
调度器没有”最好”,只有”对什么最好”。因为调度器要同时满足几个互相矛盾的目标:公平(谁都别饿着)、吞吐(尽量多干活)、延迟(响应要快)。而这三个目标,你不可能同时做到最好。
理解这个”不可能三角”,你就理解了 Linux 的 CFS、Go 的 GMP,以及你自己系统里的任务分配,为什么那么设计。
引子:一个调度器,三种”最好”互相打架
设想你是一个餐厅只有一个厨师(一个 CPU),有三个客人点了菜(三个任务):
任务 A:要做 1 小时(长任务,比如视频转码)
任务 B:要做 10 分钟(中任务,比如生成报表)
任务 C:要做 1 分钟(短任务,比如查个订单)
三种”最好”:
最好一:让总完成时间最短(吞吐优先)。
先做 C(1 分钟)→ 再做 B(10 分钟)→ 最后做 A(1 小时)
→ C 等 0 分钟,B 等 1 分钟,A 等 11 分钟
→ 平均等待时间短,总体验好
(这就是"最短作业优先" SJF)
最好二:公平,每人轮流做一点(公平优先)。
A 做 1 分钟 → B 做 1 分钟 → C 做 1 分钟 → 循环
→ C 本来 1 分钟能做完,现在要等 2 分钟才做完
→ 公平了,但短任务 C 的体验变差了
(这就是"时间片轮转" RR)
最好三:响应快,谁来了立刻响应(延迟优先)。
C 来了,立刻放下 A 去做 C(抢占)
→ C 响应最快,但 A 被频繁打断,总时间变长
看出来了吗?三个目标是矛盾的:
要吞吐(总时间短)→ 短任务优先 → 长任务可能饿死(不公平)
要公平 → 轮流 → 短任务的延迟变差
要响应快 → 频繁抢占 → 总吞吐下降(切换有开销)
这就是调度的”不可能三角”:公平、吞吐、延迟,三者最多取其二。
而 Linux 的 CFS、Go 的 GMP、以及你设计任何任务分配系统,本质上都是在这个三角里选一个平衡点。
给你一句总纲:
调度器是”稀缺 CPU 时间的分配器”。
它的核心矛盾是:CPU 时间有限,而”公平”、“吞吐”、“延迟”三者互相冲突。
所以没有一个”最好的调度器”,只有”对特定目标最好的调度器”。理解你的目标,才能理解调度器的选择。
本章路线图
第一幕:调度器要解决什么
Q1 调度的核心指标有哪些?它们为什么互相矛盾?
Q2 抢占式和非抢占式调度,区别和代价是什么?
第二幕:Linux 调度器的演进
Q3 从 O(1) 调度器到 CFS,解决了什么问题?
Q4 CFS 的"虚拟运行时间"(vruntime)是怎么设计的?
Q5 红黑树为什么是 CFS 的完美容器?
Q6 EEVDF 又改了什么?为什么 CFS 也要被替换?
第三幕:优先级与公平
Q7 优先级反转是什么?优先级继承怎么解决?(回顾深化)
Q8 实时调度和普通调度的本质区别是什么?
第四幕:Go 的调度
Q9 Go 的 GMP 里,work stealing 是怎么实现负载均衡的?
Q10 GOMAXPROCS 该怎么调?网络服务和计算服务的调优为什么相反?
第一幕:调度器要解决什么
Q1 调度的核心指标有哪些?它们为什么互相矛盾?
调度的好坏,用这几个指标衡量:
1. 周转时间(turnaround time):
一个任务从"提交"到"完成"的总时间。
周转时间 = 完成时间 - 提交时间
目标:让平均周转时间短(= 吞吐高)
2. 响应时间(response time):
一个任务从"提交"到"第一次被调度执行"的时间。
目标:让响应时间短(= 交互体验好,点一下立刻有反应)
3. 吞吐量(throughput):
单位时间内完成的任务数量。
目标:让吞吐高
4. 公平性(fairness):
每个任务都能分到"应有的" CPU 时间,没有任务被饿死。
目标:公平
5. CPU 利用率(utilization):
CPU 不闲着,尽量满负荷工作。
目标:利用率高
它们为什么矛盾?用一个例子说透:
三个任务:A(长,1 小时)、B(中,10 分钟)、C(短,1 分钟)
调度策略 1:先到先服务(FCFS,非抢占)
执行顺序:A → B → C
C 的响应时间 = 1 小时 10 分钟(太差了!)
平均周转时间 = (1h + 1h10m + 1h11m)/3 ≈ 1h7m(差)
→ 公平性差(C 等太久),但实现简单
调度策略 2:最短作业优先(SJF)
执行顺序:C → B → A
平均周转时间 = (1m + 11m + 71m)/3 ≈ 28m(好多了)
C 的响应时间 = 0(好)
→ 吞吐好,但 A 可能被不断插队的短任务饿死(不公平)
调度策略 3:时间片轮转(RR,抢占式)
C 做 1m → B 做 1m → A 做 1m → 循环
响应时间都很快(好)
但 C 本来 1 分钟能做完,被拖到 3 分钟才完成(周转时间差)
→ 响应好,但吞吐差(切换有开销)
看出矛盾了吗?
SJF(吞吐好)→ 长任务可能饿死(不公平)
RR(响应好、公平)→ 短任务被拖长(周转差)
FCFS(简单)→ 短任务等太久(响应差)
没有一种策略,能同时"周转短、响应快、公平"。
这就是调度的本质:一个”多目标优化”问题,而这些目标互相冲突。
所以调度器设计的核心问题,不是”找到最好的”,而是”根据目标,选择权衡”:
- 批处理系统(离线计算)→ 吞吐优先(SJF 类)
- 交互式系统(桌面、手机)→ 响应优先(RR 类)
- 实时系统(航空、工控)→ 截止时间优先(EDF 类)
而通用操作系统(Linux)的目标是”都兼顾一点”,所以 CFS 选择了”公平优先 + 尽量兼顾响应”。
Q2 抢占式和非抢占式调度,区别和代价是什么?
非抢占式(non-preemptive):
一个任务一旦开始运行,就一直跑到"主动让出"(等 I/O、结束、主动 yield)。
特点:
1. 实现简单(不用保存/恢复大量状态)
2. 没有"抢占"的开销
3. 但一个长任务会"霸占" CPU,其他任务干等
→ 响应时间差
抢占式(preemptive):
调度器可以在任务运行中,"强制"打断它,把 CPU 给别的任务。
特点:
1. 响应时间好(高优先级任务能立刻抢到 CPU)
2. 公平性好(长任务不能霸占 CPU)
3. 但"抢占"有开销(保存/恢复上下文、TLB、缓存)
抢占的代价(这是理解”为什么调度不能太频繁”的关键):
每次抢占(上下文切换)的成本(第 2 章讲过):
显性:保存/恢复寄存器 = 1-2 微秒
隐性:缓存失效 + TLB 失效 = 5-10 微秒
如果时间片太小(比如 100 微秒),而切换成本 10 微秒:
10% 的时间浪费在切换上
→ 吞吐下降
如果时间片太大(比如 100 毫秒):
切换开销占比小(吞吐好)
但响应时间差(一个任务要等 100ms 才轮到)
→ 交互体验差
所以时间片大小是一个权衡:
时间片小 → 响应快,但切换开销大(吞吐差)
时间片大 → 吞吐好,但响应慢
Linux 的 CFS 时间片(通常几毫秒到几十毫秒)是一个经验平衡值。
工程启示:你的系统里也有”抢占 vs 非抢占”的权衡。
- 消息队列的消费者:一次性拉多少条?拉少了切换频繁(抢占频繁),拉多了单次处理久(响应慢)
- 批处理任务:一批多大?批小切换多,批大响应慢
- 线程池的任务队列:抢不抢占?
核心都是:切换(抢占)有成本,你要在”响应”和”吞吐”之间权衡切换的频率。
第二幕:Linux 调度器的演进
Q3 从 O(1) 调度器到 CFS,解决了什么问题?
Linux 的调度器经历了多次演进,理解这个演进,能理解”调度器设计的核心矛盾”。
O(1) 调度器(Linux 2.6 早期):
设计:
- 两个"运行队列"(active 和 expired)
- 每个队列有 140 个优先级位图
- 调度:找最高优先级的非空队列,取第一个任务
优点:
- 调度操作 O(1)(不依赖任务数量)
- 支持抢占、优先级
问题:
- 复杂的启发式(大量"经验参数"):交互任务判断、睡眠补偿等
- 这些启发式难以维护,且经常判断错误
- 对"交互任务"和"批处理任务"的区分不准确
- 在多核上扩展性有问题(全局队列锁竞争)
CFS(Completely Fair Scheduler,完全公平调度器,Linux 2.6.23 引入):
设计理念的转变:
O(1) 调度器:复杂的启发式,试图"精确"地识别任务性质
CFS:简单的原则——"完全公平",每个任务按权重分 CPU
核心思想:
理想情况下,N 个任务,每个分 1/N 的 CPU 时间。
CFS 用"虚拟运行时间"(vruntime)来度量公平性。
O(1) 到 CFS 的哲学转变:
从”复杂启发式”到”简单原则”。
O(1) 调度器试图”聪明地”判断每个任务(这是交互任务吗?给它多点 CPU),结果启发式越来越复杂,越来越难维护,还经常判断错。
CFS 反过来:不试图”聪明”,只追求”公平”——每个任务按权重分 CPU。让”公平”这个简单的原则,自然产生好的效果。
这个转变的启示:当”复杂的启发式”变得难以维护时,退回到”简单的原则”,往往更 robust。
类似的例子:
- TCP 拥塞控制:从复杂的启发式,回到简单的”线性增、乘性减”
- 缓存淘汰:从复杂的预测,回到简单的 LRU/LFU
- 限流:从复杂的预测模型,回到简单的令牌桶
Q4 CFS 的”虚拟运行时间”(vruntime)是怎么设计的?
vruntime 是 CFS 的核心概念,它的设计极其精妙。
先理解问题:怎么度量”公平”?
理想情况:N 个任务,每个分 1/N 的 CPU。
但怎么知道"谁多占了,谁少占了"?
朴素想法:记录每个任务的"实际运行时间"。
问题:不同任务"权重"不同(优先级不同),
高优先级的任务应该分更多 CPU。
所以不能简单比较"实际运行时间",要比较"加权后的时间"。
vruntime 的定义:
vruntime = 实际运行时间 × 权重系数
具体:权重越高(优先级越高),vruntime 增长越慢
(因为高优先级任务"应该"获得更多 CPU,所以它的时间"贬值"了)
公式(简化):
vruntime += 实际运行时间 × (NICE_0_LOAD / 任务权重)
NICE_0_LOAD = 基准权重(nice=0 的权重)
任务权重 = 根据 nice 值查表得到
所以:
nice=0(默认):vruntime 按 1:1 增长
高优先级(nice<0,权重高):vruntime 增长慢(时间"贬值",能跑更久)
低优先级(nice>0,权重低):vruntime 增长快(时间"增值",少跑)
CFS 的调度规则:
每次调度,选"vruntime 最小"的任务(它"最吃亏",欠它最多 CPU)。
这个规则的美妙之处:
1. vruntime 小的任务(还没跑够的)优先
2. 任务跑一段时间后,vruntime 变大,自然让出 CPU
3. 于是所有任务的 vruntime 趋于一致 → 公平!
vruntime 的巧妙:
1. 公平是"自然涌现"的:
不需要显式地"轮流",只要每次选 vruntime 最小的,
公平自然达成(因为跑得多的 vruntime 大,会自然让位)
2. 优先级是"乘在时间上"的:
高优先级任务 vruntime 增长慢 → 能"跑更久"才被换下
→ 优先级的差异,体现为"连续运行时间的差异"
3. 睡眠任务的处理:
任务睡眠醒来后,vruntime 会"追上"(取一个调整值),
避免"睡眠久了醒来后,因为 vruntime 小,霸占 CPU"
(否则睡醒的任务会一直占 CPU,直到追平)
vruntime 给你的启示:
“公平”不一定靠”显式的轮流”,可以靠”一个衡量’亏欠’的度量”来自然涌现。
这个思想在工程里很实用:
- 负载均衡:给每个节点算一个”负载值”,把请求发给”负载最小”的节点
- 限流:给每个用户算”配额消耗”,先服务”消耗少”的用户
- 消息队列:给每个分区算”积压量”,优先消费”积压最多”的分区
核心:定义一个”公平度量”,每次选”最亏欠”的,公平就自然达成。
Q5 红黑树为什么是 CFS 的完美容器?
CFS 需要的数据结构:
1. 快速找到"vruntime 最小"的任务(每次调度都要)
2. 快速插入/删除(任务创建、睡眠、唤醒频繁)
3. 按 vruntime 排序
为什么是红黑树?
红黑树是"自平衡的二叉搜索树":
- 查找最小值:O(log n)(一直往左子树走)
- 插入:O(log n)
- 删除:O(log n)
- 且保持平衡(最坏情况也是 O(log n))
对于 CFS:
- 找 vruntime 最小的 = 找最左节点,O(log n)
- 任务睡眠/唤醒 = 删除/插入,O(log n)
- 完美契合
为什么不用别的结构?
优先队列(堆):找最小 O(1),但删除任意元素 O(n)(任务睡眠要删除它)
→ 不适合(任务会随机睡眠/唤醒)
链表:插入 O(1),但找最小 O(n)
→ 不适合(每次调度都要找最小)
红黑树:找最小 O(log n),插入/删除 O(log n)
→ 平衡,最适合
CFS 里的红黑树:
每个 CPU 的运行队列里,有一棵红黑树:
- 节点 = 可运行的任务
- 键 = vruntime
- 最左节点 = vruntime 最小的任务(下一个要跑的)
调度 = 取最左节点
唤醒任务 = 插入红黑树
睡眠任务 = 从红黑树删除
工程启示:数据结构的选择,由”操作模式”决定。
CFS 选择红黑树,是因为它的操作模式是”频繁找最小 + 频繁插入删除”。
你在设计系统时也要问:
- 我的操作模式是什么?(查什么、插什么、删什么)
- 每种操作的频率?(高频操作要 O(1) 或 O(log n))
- 有没有”最坏情况”?(红黑树保证 O(log n),比普通 BST 的 O(n) 好)
“数据结构”不是孤立的,它服务于”操作模式”。 这也是为什么面试爱问”这个场景用什么数据结构”——考的是你能不能匹配”操作”和”结构”。
Q6 EEVDF 又改了什么?为什么 CFS 也要被替换?
CFS 统治了 Linux 调度十几年,但在 Linux 6.6(2023)被 EEVDF 替换。为什么?
CFS 的问题:
1. 延迟敏感任务的"响应"不够好
CFS 是"公平优先",但对"延迟敏感"的任务(音频、视频、交互)
没有特别的照顾,导致延迟抖动。
2. vruntime 的"睡眠补偿"逻辑复杂且不精确
睡眠任务醒来后,vruntime 怎么调整?CFS 的补偿逻辑是"启发式"的,
在某些场景下不准确。
3. 权重和延迟的关系不清晰
高权重任务到底该多"快"地响应?CFS 没有明确的答案。
EEVDF(Earliest Eligible Virtual Deadline First):
核心概念:每个任务有一个"虚拟截止时间"(virtual deadline)。
deadline = 任务开始运行的时间 + (请求的时长 / 权重)
调度规则:选"虚拟截止时间最早"的任务运行。
这个规则的好处:
1. 高权重任务 → 虚拟截止时间更近 → 更优先(权重体现为"更近的 deadline")
2. 延迟敏感任务 → 请求的时长短 → deadline 近 → 优先响应
3. 公平性 → 每个任务按权重,在"它的 deadline"前获得 CPU
EEVDF 相对 CFS 的改进:
1. 更低的调度延迟(对延迟敏感任务更友好)
2. 更精确的"权重"语义(权重直接映射到"deadline 的紧迫度")
3. 更简单的逻辑(去掉了 CFS 的复杂睡眠补偿)
这个演进的启示:
调度器的演进,是在”公平、吞吐、延迟”三角里,不断调整平衡点。
CFS 把”公平”做到了极致,但牺牲了”延迟敏感任务”的体验。 EEVDF 在保持公平的基础上,通过”虚拟截止时间”更好地照顾了延迟。
这告诉我们:没有”最终答案”,只有”随需求变化而调整的平衡”。
你设计任务系统时也一样:
- 今天的负载是”计算密集”→ 吞吐优先
- 明天加了”实时推荐”→ 要照顾延迟
- 你的调度策略,要随负载变化而调整。
第三幕:优先级与公平
Q7 优先级反转是什么?优先级继承怎么解决?(回顾深化)
第 6 章讲过优先级反转(Mars Pathfinder),这里从”调度”的角度再深化。
优先级反转(priority inversion)的完整机制:
场景:三个任务,优先级 H > M > L
1. L(低优先级)持有锁 S,正在临界区里
2. H(高优先级)请求锁 S,被阻塞(等 L 释放)
3. M(中优先级)来了,抢占 L(因为 M > L)
4. L 被 M 抢占,锁 S 迟迟不释放
5. H 一直等锁 S,被"事实上"饿死
结果:H(最高优先级)被 M(中优先级)阻塞了
→ 优先级"反转"了:中优先级跑在了高优先级前面
为什么这是”调度”问题?
因为根因是”调度器让 M 抢占了 L,而 L 持有 H 需要的锁”。调度器不知道”L 持有 H 等待的锁”,所以做了”错误的调度决策”。
优先级继承(priority inheritance)的解法:
当 H 等待 L 持有的锁时,把 L 的优先级"临时提升"到 H 的优先级。
这样:
L(临时提升到 H 的优先级)→ M 抢不过 L → L 尽快释放锁 → H 拿到锁
优先级继承的本质:
“锁持有者的优先级 = 所有等待者的最高优先级”。
即:“谁在等我,我就有多重要”。这是对”优先级”语义的修正——一个任务的”有效优先级”,不仅取决于它自己,还取决于”有多少重要的任务依赖它”。
优先级继承的问题(它也不是万能的):
1. 只解决"优先级反转",不解决"死锁"(第 6 章)
2. 链式继承复杂:L 持锁 A 等锁 B,H 等锁 A → 继承要传递
3. 无法处理"锁之外"的依赖(比如 L 等一个 M 持有的资源)
Linux 的 futex 支持 PI(priority inheritance),但普通 mutex 默认不用(因为开销)。
Go 为什么没有优先级反转问题?
因为 Go 的 goroutine 没有"优先级"概念(第 8 章讲过)。
所有 goroutine 平等,调度器不区分优先级,
所以不存在"高优先级 goroutine 被低优先级阻塞"的问题。
(代价是:Go 无法表达"这个任务更重要",只能靠 worker pool 分层等间接手段)
优先级反转的深刻教训:
“调度”和”同步”(锁)是耦合的,分开设计就会出问题。
调度器只看到”优先级”,看不到”锁依赖”;而锁持有者可能被低优先级抢占。两者的信息不对称,导致了优先级反转。
解法(优先级继承)的本质是:让”同步”的信息(谁在等锁)反馈给”调度”(提升优先级)。
这个”跨层次的信息反馈”,是系统设计里的一个普遍难题。你在微服务里也会遇到:负载均衡器(调度)不知道某个实例正在等一个慢下游(同步),所以可能把请求调度到”正在忙”的实例上。
Q8 实时调度和普通调度的本质区别是什么?
普通调度(CFS):追求”公平”,不保证”截止时间”。
CFS 的目标:让所有任务公平地分 CPU,响应快一点。
但 CFS 不保证:"任务 X 一定在 10ms 内完成"。
对普通系统(服务器、桌面)这没问题,
因为"慢一点"是可以接受的。
实时调度(RT):追求”满足截止时间”,公平次要。
实时系统的目标:任务必须在"截止时间(deadline)"前完成。
否则就是"系统失败"(可能致命)。
例:
汽车的 ABS 刹车:必须在 5ms 内响应,否则刹车失效
心脏起搏器:必须按时脉冲,否则危及生命
工业机器人:必须在截止时间内完成动作,否则损坏
实时调度的关键区别:
| 普通调度(CFS) | 实时调度(RT) | |
|---|---|---|
| 目标 | 公平、吞吐 | 满足截止时间 |
| 失败后果 | 慢一点(可接受) | 错过截止 = 失败(不可接受) |
| 确定性 | 不确定(延迟可能抖动) | 确定(最坏情况可预测) |
| 优先级 | 软优先级(nice) | 硬优先级(严格) |
| 抢占 | 可抢占 | 严格抢占(高优先级必抢占) |
实时调度的核心:可预测性(predictability)。
实时系统最怕的不是"慢",是"不确定"。
它要求"最坏情况下的响应时间"是可预测的、有界的。
例:
ABS 刹车,平均响应 2ms,最坏 100ms → 不行!
因为最坏 100ms 时,已经撞上了。
实时系统要求:最坏情况也必须 < 5ms。
实时调度算法(EDF、RMS):
EDF(Earliest Deadline First):谁截止时间最近,先跑谁
RMS(Rate Monotonic Scheduling):周期越短(越频繁)的任务,优先级越高
这些算法都有"可证明的最坏情况保证"。
工程启示:你的系统需要”实时性”吗?
判断标准:错过截止时间,后果有多严重?
- 严重(致命)→ 实时系统(RTOS、硬实时)
- 中等(体验差)→ 软实时(音频、视频、游戏,延迟敏感但可容忍偶尔抖动)
- 不严重(慢一点没关系)→ 普通调度(服务器、批处理)
大多数后端服务是”普通调度”,但其中可能有”软实时”的部分(比如延迟敏感的 API,P99 要求 < 100ms)。
关键:别用”实时系统”的复杂度,去解决”普通系统”的问题。 实时调度的确定性是有代价的(吞吐低、利用率低)。
第四幕:Go 的调度
Q9 Go 的 GMP 里,work stealing 是怎么实现负载均衡的?
第 2 章讲过 GMP,这里从”负载均衡”的角度深化。
问题:多核调度,怎么让每个核都”忙”,又不”挤在一起”?
假设 4 个 P(逻辑处理器),各自有本地运行队列:
P0: [G1, G2, G3, G4]
P1: []
P2: [G5]
P3: []
问题:P0 忙死,P1/P3 闲着 → 负载不均衡
方案一:全局队列(简单但慢)。
所有 goroutine 放一个全局队列,谁空谁取。
问题:全局队列需要加锁,所有 P 竞争一个锁
→ 高并发下,锁成为瓶颈
方案二:work stealing(Go 的选择)。
每个 P 有本地队列(无锁)。
P 的本地队列空了,去"偷"别的 P 的队列。
偷的规则:
- 优先偷"队列后半段"(一次偷一半)
- 随机选一个 P 偷(或者按一定顺序)
好处:
1. 本地队列无锁(大部分操作无锁)
2. 偷窃是"按需"的(空了才偷,不空不偷)
3. 一次偷一半(批量,摊销偷窃的开销)
work stealing 的精妙之处:
1. 大部分时间:每个 P 用自己的本地队列(无锁,快)
2. 负载不均时:空闲的 P 主动去偷(有锁,但频率低)
3. 偷"一半":批量转移,减少偷窃次数
本质:这是"分层 + 本地缓存 + 批量流转"模式(第 1、2 章反复出现)
- 本地队列 = 每 P 的缓存
- 偷窃 = 批量流转
work stealing 的一个细节:为什么偷”后半段”?
队列是 FIFO(先进先出):
头部:老任务(可能快执行完了)
尾部:新任务(还没开始)
偷"后半段"(尾部)的原因:
1. 尾部是"新任务",还没被缓存"预热"(偷走影响小)
2. 头部是"热任务"(缓存里热),留在原 P 上更好(缓存亲和性)
work stealing 的普适性:
work stealing 是”无锁负载均衡”的经典解法,它在很多地方出现:
- Go 的 GMP:P 之间偷 goroutine
- Fork/Join 框架(Java):任务窃取
- Cilk、TBB 等并行框架:work stealing
- 分布式任务队列:空闲 worker 拉取积压任务
核心思想:让”忙的”不用主动分发(推),让”闲的”主动去拿(拉)。 这样避免了”全局协调”的锁,用”局部偷窃”实现全局均衡。
Q10 GOMAXPROCS 该怎么调?网络服务和计算服务的调优为什么相反?
第 2 章讲过 GOMAXPROCS 的基础,这里讲”怎么调”的实践。
GOMAXPROCS 的本质:
GOMAXPROCS = P 的数量 = 同时并行执行 goroutine 的数量上限。
它决定:同一时刻,最多多少个 goroutine 在"真并行"。
网络服务(I/O 密集)的调优:
网络服务的特点:
大部分 goroutine 在"等 I/O"(不占 CPU)
真正的 CPU 计算很少
所以:
GOMAXPROCS = 核数(默认)通常就是最优的
甚至 GOMAXPROCS 略大于核数,影响也不大
(因为大部分 goroutine 在等 I/O,P 经常"空闲")
调优方向:
1. 容器里必须设置(否则读到宿主机核数)
2. 如果 CPU 使用率很低(< 30%),说明 CPU 不是瓶颈,
调 GOMAXPROCS 意义不大
3. 重点是"别设太小"(否则 CPU 用不满)
计算服务(CPU 密集)的调优:
计算服务的特点:
goroutine 一直占着 CPU 算(很少等 I/O)
所以:
GOMAXPROCS = 核数(默认)就是最优的
设大了 → 上下文切换增加,吞吐下降
设小了 → CPU 用不满,吞吐下降
调优方向:
1. GOMAXPROCS 应该 = 核数(精确,不多不少)
2. 避免和其他 CPU 密集进程抢核(要留核或隔离)
3. 关键是"让每个核都被一个 goroutine 占满,且不切换"
为什么两者调优”相反”?
网络服务(I/O 密集):
瓶颈在 I/O 等待,不在 CPU
→ 可以"超额"分配(GOMAXPROCS > 核数 也影响不大)
→ 因为 goroutine 大部分时间不占 CPU
计算服务(CPU 密集):
瓶颈在 CPU 计算
→ 必须"精确"匹配(GOMAXPROCS = 核数)
→ 多了切换浪费,少了核闲着
一个通用的判断:
先搞清楚:你的服务是"I/O 密集"还是"CPU 密集"?
看 CPU 使用率:
- CPU 使用率低(< 50%),但延迟高 → I/O 密集(在等下游、等磁盘)
- CPU 使用率高(> 80%)→ CPU 密集(在计算)
然后:
- I/O 密集 → GOMAXPROCS 不太敏感,重点是异步化和连接池
- CPU 密集 → GOMAXPROCS 要精确,重点是别切换
一个容易被忽视的坑:
混合型服务(既有 I/O 等待,又有 CPU 计算):
高峰期 CPU 密集,低谷期 I/O 密集
这种情况,GOMAXPROCS 设一个"接近核数"的值,
用 GOMAXPROCS + GOMEMLIMIT 组合调优。
调度的最终启示:
调度参数的调优,前提是”知道你的负载性质”。
不知道负载是 I/O 密集还是 CPU 密集,就调 GOMAXPROCS,是瞎调。
这和第 2、8 章反复强调的”先搞清楚瓶颈在哪”是同一个原则: 先测量,理解负载,再调参数。
思考题
思考题 1
面试官问:“Linux 的 CFS 调度器是怎么工作的?它用什么数据结构?为什么?“请从”公平目标、vruntime、红黑树”三个层次回答。
参考答案
核心答案:CFS 追求”公平”,用 vruntime 度量公平,用红黑树维护”按 vruntime 排序”的任务,每次选 vruntime 最小的运行。
第一层:公平目标。
CFS 的理念:理想情况下,N 个任务,每个分 1/N 的 CPU 时间。
它不是"按优先级硬性分配",而是"按权重公平分配":
- 高优先级(nice 低)任务,分到更多的 CPU
- 低优先级(nice 高)任务,分到更少的 CPU
- 但"分到的 CPU / 权重"这个比例,对所有任务一致(公平)
第二层:vruntime(虚拟运行时间)。
vruntime 是"加权后的运行时间":
vruntime = 实际运行时间 × (基准权重 / 任务权重)
关键性质:
- 高优先级任务,vruntime 增长慢(能跑更久)
- 低优先级任务,vruntime 增长快(跑一会就被换下)
调度规则:每次选"vruntime 最小"的任务(它最"亏欠"CPU)。
为什么这样就公平?
- 任务跑得越多,vruntime 越大,自然让位
- 跑得少的任务,vruntime 小,优先获得 CPU
- 最终所有任务的 vruntime 趋于一致 → 公平
第三层:红黑树。
CFS 每个 CPU 的运行队列是一棵红黑树:
- 键 = vruntime
- 最左节点 = vruntime 最小的任务
为什么用红黑树?
- 找最小(最左节点):O(log n)
- 插入(任务唤醒):O(log n)
- 删除(任务睡眠):O(log n)
- 且自平衡,最坏情况也是 O(log n)
对比其他结构:
- 堆:找最小 O(1),但删除任意节点 O(n)(任务会随机睡眠,不适用)
- 链表:插入 O(1),但找最小 O(n)(每次调度都要找,不适用)
完整的调度流程:
1. 任务创建/唤醒 → 计算 vruntime → 插入红黑树
2. 定时器中断(或任务让出)→ 调度器运行
3. 调度器选红黑树最左节点(vruntime 最小)→ 运行它
4. 任务运行一段时间 → vruntime 增加 → 可能不再是"最小"→ 被换下
5. 任务睡眠 → 从红黑树删除
追问:睡眠任务醒来后,vruntime 怎么处理?
任务睡眠期间,其他任务在跑,它们的 vruntime 在涨。
如果睡眠任务醒来时 vruntime 还停留在睡眠前的值,
那它会因为 vruntime 小,而"霸占" CPU 直到追平。
CFS 的处理:睡眠任务醒来时,vruntime 取一个"调整值"
(比如 min(它自己的 vruntime, 队列最小 vruntime - 一个偏移)),
避免"睡眠补偿"过度,导致醒来任务霸占 CPU。
追问:多核下 CFS 怎么工作?
每个 CPU 有自己的运行队列(红黑树),避免了全局锁。
但有"负载均衡"问题:某个 CPU 队列满,另一个空。
CFS 的负载均衡器(load balancer)定期检查各 CPU 的负载,
把任务从"负载高"的 CPU 迁移到"负载低"的。
(Go 的 work stealing 是类似的思想,但用"偷窃"而不是"主动迁移")
这道题考察什么:
- 知不知道 CFS 的”公平”目标(而不是”优先级硬分配”)
- 知不知道 vruntime 的设计(加权、选最小、公平自然涌现)
- 知不知道红黑树为什么(操作模式匹配)
- 知不知道多核下的负载均衡
思考题 2
你的服务要处理两种任务:A)用户请求(要求响应快,P99 < 100ms);B)后台批处理(生成报表,可以慢,但量大)。你会怎么设计任务调度,保证 A 的延迟不被 B 拖累?
参考答案
这是”调度思想”在业务系统里的应用,核心是隔离 + 优先级。
问题分析:
A(用户请求):延迟敏感,量少但要求快
B(批处理):吞吐敏感,量大但可以慢
如果 A 和 B 用同一个线程池/goroutine 池:
B 的大量任务会占满池子,A 排队等待
→ A 的 P99 被 B 拖累
方案一:隔离(separate pools)。
A 和 B 用不同的 worker pool:
A:独立的 pool(比如 8 个 worker,专门处理用户请求)
B:独立的 pool(比如 4 个 worker,专门处理批处理)
好处:
- B 再忙,也不影响 A 的 pool
- 隔离是"防拖累"的最简单手段
代价:
- 资源分配固定,A 空闲时,A 的 worker 闲着(不能给 B 用)
- 峰值时,A 的 pool 可能不够(要扩容)
方案二:优先级队列(priority queue)。
一个共享的池子,但用优先级队列:
A 的任务优先级高,B 的低
worker 取任务时,优先取 A 的任务:
有 A 任务 → 先做 A
没有 A 任务 → 才做 B
好处:
- 资源利用率高(A 空闲时,worker 做 B)
- A 的延迟有保证(A 永远优先)
代价:
- 实现复杂(优先级队列、饥饿保护)
- B 可能被饿死(A 源源不断时)
方案三:分级 + 抢占(类似操作系统的做法)。
A 和 B 分开,但 B 可以被 A "抢占":
- B 的 worker 在跑批处理
- A 的任务来了 → B 的 worker 暂停,先处理 A
- A 处理完,B 继续
好处:
- A 响应快(抢占)
- 资源利用率高
代价:
- "抢占"的实现复杂(B 要能"保存现场")
- B 被频繁打断,吞吐下降
方案四(Go 里的推荐):不同 goroutine + 信号量限流。
// A:用户请求,用独立的 goroutine + 信号量限流
var userSem = make(chan struct{}, 100) // 最多 100 个并发用户请求
func handleUserRequest(req) {
select {
case userSem <- struct{}{}:
defer func() { <-userSem }()
// 处理用户请求
default:
// 超载,快速失败
}
}
// B:批处理,用独立的 goroutine + 更小的信号量
var batchSem = make(chan struct{}, 4) // 最多 4 个并发批处理
func runBatch(job) {
batchSem <- struct{}{}
defer func() { <-batchSem }()
// 处理批处理
}关键:用”独立的信号量”隔离 A 和 B 的并发度。
我的推荐(综合方案):
1. 隔离优先:A 和 B 用独立的资源池(信号量)
- 这是最可靠的"防拖累"手段
2. 资源分配:A 多分,B 少分
- A(延迟敏感):更多 worker / 更高并发上限
- B(吞吐敏感):少 worker,但持续跑
3. B 的"降级":A 高峰期,B 主动降速
- B 检测到 A 的队列变长 → B 降低自己的并发(让资源给 A)
4. 监控:分别监控 A 和 B 的延迟、队列长度
- 发现 A 被拖累,及时调整资源分配
一个更深的洞察(这是这道题的核心):
“调度”的本质,是”稀缺资源的分配”。而”防拖累”的关键,是”隔离 + 优先级”。
- 隔离:让”重要的”和”不重要的”不共享资源(各用各的池)
- 优先级:共享资源时,让”重要的”先来(优先队列、抢占)
操作系统的调度器,用的也是这两招:
- 隔离:不同 CPU 核、不同运行队列
- 优先级:nice 值、实时优先级
而你的业务系统,也应该用这两招:重要的任务(用户请求)和不重要的任务(批处理)隔离,或者给重要的任务更高优先级。
追问:什么时候”隔离”比”优先级”好?
隔离更好,当:
- B 的任务"不可抢占"(一个批处理任务跑起来,不好中断)
- A 和 B 的资源需求差异大(A 要低延迟,B 要高吞吐)
优先级更好,当:
- 资源珍贵(不想为 A 和 B 各留一份资源)
- B 能被"细粒度"地暂停和恢复
思考题 3
“Go 的 goroutine 没有优先级,这是缺陷还是设计选择?“请结合第 8 章(goroutine 无优先级)和本章(调度),给出你的判断,并说明如果你的业务确实需要”优先级”,有哪些替代方案。
参考答案
结论:是设计选择,不是缺陷。但它确实限制了一些场景,需要用替代方案。
为什么是设计选择(Go 团队的考虑):
1. 简单性:goroutine 无优先级,调度器逻辑简单,可预测
2. 公平性:所有 goroutine 平等,不会出现"高优先级饿死低优先级"
3. 避免优先级反转:无优先级,就没有优先级反转问题(第 6 章 Mars Pathfinder)
4. Go 的定位:通用服务端编程,不是实时系统
Go 团队的哲学:优先级的复杂度(反转、继承、饥饿),
比优先级带来的收益(能表达"谁更重要")多。
所以选择不要优先级。
为什么说它限制了某些场景:
场景:你的服务里,有些 goroutine 是"关键的"(比如心跳、健康检查),
有些是"可有可无的"(比如后台清理)。
无优先级 → 关键 goroutine 可能被"可有可无"的 goroutine 挤占 CPU
→ 心跳超时、健康检查失败
这在"实时性"要求高的场景,是真实的限制。
替代方案(如果业务需要”优先级”):
方案一:worker pool 分层(最常用)。
// 关键任务用独立的 pool(更多 worker,或独立 P)
criticalPool := make(chan Job, 10) // 关键任务,小缓冲,快速响应
backgroundPool := make(chan Job, 1000) // 后台任务,大缓冲
// 关键任务 pool 的 worker 数 > 后台任务 pool方案二:用 channel 的调度顺序。
// 关键任务和后台任务用同一个 worker,但 select 优先取关键任务
for {
select {
case job := <-criticalCh:
process(job) // 关键任务优先
default:
select {
case job := <-criticalCh:
process(job)
case job := <-backgroundCh:
process(job)
}
}
}方案三:多进程(进程级隔离)。
// 关键服务独立进程,用操作系统进程的优先级
// 后台任务独立进程,nice 值调低
// 这是"进程级优先级",绕过了 goroutine 无优先级的问题方案四:调整 GOMAXPROCS 和 P 的分配。
Go 1.x 的 runtime 有 GOMAXPROCS 和 P 的调度,
但没法给特定 goroutine 分配特定 P。
(不过可以通过 LockOSThread 把 goroutine 绑定到 OS 线程,
再通过 OS 的优先级,间接实现"优先级")
一个重要的判断:你真的需要 goroutine 优先级吗?
大多数"我想给 goroutine 加优先级"的场景,其实是想表达:
"这个任务应该先做,那个任务可以后做"
而这用"channel 的调度顺序"或"worker pool 分层"就能表达,
不需要真的"抢占式优先级"。
真正的"抢占式优先级"(高优先级 goroutine 抢占低优先级的 CPU),
在 Go 里几乎没有干净的方式实现,
而且大多服务端场景也不需要。
这道题考察什么:
- 知不知道 Go”无优先级”是设计选择(不是忘了实现)
- 知不知道无优先级避免了什么问题(反转、饥饿、复杂度)
- 知不知道替代方案(pool 分层、channel 顺序、多进程)
- 能不能判断”你的场景是否真的需要优先级”
思考题 4(开放题)
“公平、吞吐、延迟,三者不可兼得”——这个”不可能三角”,在分布式系统里对应什么?请举例说明(比如负载均衡、消息队列、缓存),并说明”理解这个三角”如何指导你的架构决策。
参考答案
开放题,考察你把”调度的不可能三角”抽象成”通用的资源分配权衡”。
不可能三角的本质:
任何"资源分配"系统,都面临三个目标的权衡:
- 公平(fairness):每个请求/任务都得到合理的资源
- 吞吐(throughput):整体处理能力最大化
- 延迟(latency):单个请求的响应最快
这三个目标,在任何"共享资源"的场景下,都互相冲突。
对应关系一:负载均衡。
公平 vs 吞吐 vs 延迟,对应负载均衡的三个考量:
公平:每个后端实例分到的流量均衡(避免某个实例过载)
吞吐:整体吞吐最大化(充分利用所有实例)
延迟:单个请求的响应最快(请求被路由到"最快"的实例)
冲突的例子:
1. 轮询(Round Robin)→ 公平,但"慢实例"拖累延迟
2. 最少连接(Least Connections)→ 偏向"快"实例,延迟好,
但"快"实例可能过载,且不均衡
3. 加权轮询 → 按权重分配,但权重设置不准会失衡
对应关系二:消息队列。
公平:每个消费者分到的消息均衡
吞吐:整体消费速度最大化
延迟:单条消息尽快被消费
冲突的例子:
1. 均匀分配(公平)→ 但慢消费者拖累整体(吞吐)
2. 谁快谁多消费(吞吐/延迟)→ 但快消费者过载,慢消费者闲着(不公平)
3. Kafka 的 partition 分配:既要均衡(公平),又要考虑消费者能力(吞吐)
对应关系三:缓存(第 9 章讲过)。
公平:每个 key 被缓存的机会均等
吞吐:整体命中率最高
延迟:热点 key 的访问最快
冲突的例子:
1. LRU(公平)→ 但扫描污染降低整体命中率(吞吐)
2. 热点优先(延迟)→ 但冷 key 可能永远不被缓存(不公平)
3. 分代 LRU → 试图平衡,但复杂
“理解三角”如何指导架构决策:
第一步:明确你的首要目标
- 你的系统,公平、吞吐、延迟,哪个最重要?
- 例:支付系统 → 延迟和正确性最重要,公平次要
- 例:日志系统 → 吞吐最重要,延迟可容忍
- 例:共享基础设施 → 公平最重要(多租户)
第二步:明确次要目标
- 首要是延迟,那吞吐能牺牲多少?
- 首要是公平,那延迟能牺牲多少?
第三步:设计权衡
- 基于"首要/次要",设计你的分配策略
- 例:延迟优先 → 最快优先的负载均衡 + 热点缓存
- 例:公平优先 → 轮询负载均衡 + LRU 缓存
一个通用的决策框架:
资源分配系统设计三步:
1. 问:这是"共享资源"吗?
(CPU、带宽、缓存、后端实例,都是共享资源)
2. 问:公平、吞吐、延迟,我的优先级排序是什么?
3. 问:为了首要目标,我能牺牲哪个次要目标?
然后,选择对应的策略:
- 公平优先 → 轮询、LRU、均匀分配
- 吞吐优先 → 最短优先、批处理、合并
- 延迟优先 → 优先级队列、热点缓存、抢占
一个深刻的总结:
“不可能三角”不是”调度的专属问题”,而是”所有共享资源分配”的通用规律。
从操作系统的 CPU 调度,到负载均衡、消息队列、缓存,再到数据库的连接池、K8s 的 Pod 调度,都在这个三角里做权衡。
而”理解这个三角”的价值,是让你:
- 不再幻想”完美方案”(能同时优化三个目标)
- 明确”我要牺牲什么”(有意识地权衡)
- 能解释”为什么这个系统这么设计”(因为它的目标决定了权衡)
架构设计的成熟标志,是”能明确说出自己在权衡什么”。 而不是”我用了某个技术”或”我遵循了某个最佳实践”。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 11 章 · 调度:公平、吞吐与延迟的不可能三角 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【调度的本质】 │
│ 稀缺 CPU 时间的分配器 │
│ 公平、吞吐、延迟三者互相冲突,不可能兼得 │
│ │
│ 【核心指标】 │
│ 周转时间(吞吐)、响应时间(延迟)、公平、利用率 │
│ SJF 快但不公平,RR 公平但吞吐差,FCFS 简单但响应差 │
│ │
│ 【抢占 vs 非抢占】 │
│ 抢占:响应好,但切换有成本(时间片是权衡) │
│ 时间片小→响应快吞吐差,时间片大→吞吐好响应慢 │
│ │
│ 【CFS 的精髓】 │
│ vruntime = 加权运行时间(权重乘在时间上) │
│ 每次选 vruntime 最小(最亏欠)的任务 → 公平自然涌现 │
│ 红黑树:匹配"频繁找最小 + 频繁插删"的操作模式 │
│ EEVDF 用"虚拟截止时间"改善延迟敏感任务 │
│ │
│ 【优先级反转】 │
│ 低优先级持锁被中优先级抢占,高优先级饿死 │
│ 解法:优先级继承(谁在等我,我就有多重要) │
│ Go 无优先级 → 天然避免反转(但无法表达"谁更重要") │
│ │
│ 【Go 调度】 │
│ work stealing = 无锁负载均衡(本地队列 + 空了才偷 + 偷一半) │
│ GOMAXPROCS 调优:先判断 I/O 密集还是 CPU 密集 │
│ │
│ 【一句话】 │
│ 调度器是在"公平、吞吐、延迟"三角里选平衡点; │
│ 理解你的目标,才能理解调度器的选择。 │
│ │
│ 【下一章预告】 │
│ 调度的背后,藏着一个更底层的智慧——摊销: │
│ 把随机的大代价,换成可预测的小代价。 │
│ 这是全书最重要的一章,前面埋的伏笔将在那里汇合。 │
└──────────────────────────────────────────────────────────────┘