先来后到是一种选择,不是天理
队已经排在那儿了。这一卷不再问「怎么让队变短」,只问「谁先走」。而第一个结论就足够让人不适:换一种叫号顺序,平均等待一秒都不会变。
一条队,利用率 80%。现在把叫号规则从先来后到(FIFO)改成后来先服务(LIFO,像一个栈:最新进来的排最前面)。
不加机器,不改速度,只改顺序。平均等待会怎么变?
一个守恒量
先看结果:
| 规则 | 平均等待 | 标准差 | p50 | p99 | 最惨的那位 |
|---|---|---|---|---|---|
| FIFO 先来后到 | 3.774 | 4.641 | 2.219 | 20.97 | 49.2 |
| LIFO 后来先服务 | 3.788 | 12.770 | 0.571 | 57.16 | 405.9 |
平均等待:3.774 vs 3.788。二十万个请求的模拟,差 0.4%——那是采样噪声,理论上它们精确相等。
而其他每一列都天差地别:
- 中位数:LIFO 0.571,只有 FIFO 的 四分之一;
- p99:LIFO 57.16,是 FIFO 的 2.7 倍;
- 最惨的那位:FIFO 等了 49.2,LIFO 等了 405.9——八倍。
为什么平均值不变
这不是巧合,它有一个非常干净的理由,而这个理由是这一整卷的地基。
定义一个量叫未完成工作量 V(t):此刻系统里所有活加起来还需要多少时间才能做完。
现在问:换一种叫号顺序,V(t) 会变吗?
不会。因为:
- 每来一个活,
V就增加那个活的长度——和顺序无关; - 服务台每忙一秒,
V就减少一秒——和它在服务谁无关。
# 未完成工作量 V(t):一条锯齿,只由到达和"服务台忙不忙"决定 V(t) │ ╱╲ │ ╱ ╲ ╱╲ │ ╱ ╲ ╱ ╲╱╲ │╱ ╲ ╱ ╲___ └──────────────────────→ t ↑ 来活就跳一格(活有多长跳多高) ↓ 服务台忙的时候,以每秒 1 的速度往下走 # ★ 这条锯齿在 FIFO / LIFO / 短作业优先下 **完全一样** # 只要服务台"有活就干、不偷懒"(这叫 work-conserving,保功)
而 V(t) 的时间平均,通过 Little 定律(第 2 章),直接决定了平均等待。V 不变,平均等待就不变。
只要调度策略满足两个条件:
- 保功(work-conserving):只要有活,服务台就不闲着;
- 不看活的长短:决定谁先走时,不参考这个活要做多久。
那么平均等待时间完全相同,不管你是先来后到、后来先服务、还是随机挑一个。
这两个条件都很关键:
- 破坏第一条(服务台故意闲着),平均等待会变长——这就是第 20 章批处理的攒批阶段在做的事;
- 破坏第二条(看活的长短),平均等待会变短——这就是第 18 章短作业优先的全部秘密。
这本书剩下的调度章节,本质上都是在讨论怎么破坏第二条,以及破坏它的代价。
那 LIFO 到底改了什么
它改了方差的分配。
FIFO 下,每个人的等待都差不多——大家排在同一条队里,前面有几个人就等几个人的时间。等待时间的分布相对集中(标准差 4.641)。
LIFO 下,情况分裂成两种:
- 系统不忙的时候来的人:立刻被服务,等待接近 0。大多数人属于这一类(中位数只有 0.571)。
- 系统忙的时候来的人:被后来的人不断插队,可能等到天荒地老(最惨的等了 405.9)。
总的等待时间是守恒的,所以「大多数人等得更少」必然意味着「少数人等得多得多」。这是一笔零和交易,而不是一次优化。
听起来 LIFO 只有坏处?不一定。它在两种场景下是明确更优的:
1. 有超时的时候。假设所有请求超过 T 就没意义了(用户已经走了)。FIFO 下,队积压时每个人都在慢慢逼近超时,最后可能所有人都超时——你做完的工作全部白费。LIFO 下,新来的人立刻被服务,至少有一部分请求是有效的。
# 极端例子:队里积压了 100 个请求,超时是 10 个服务时长
FIFO:从最老的开始做,做到第 10 个的时候,前面 10 个刚好卡在超时线上,
后面 90 个全部超时 → 有效产出 ≈ 10 个
LIFO:从最新的开始做,最新的都还很"新鲜",
而最老的那些反正已经超时了 → 有效产出高得多
这个道理是:当工作会「变质」时,先做新鲜的。Facebook 的负载脱落系统、很多消息队列的过载策略,用的就是这个思路(有时叫 LIFO 或者 CoDel 式的丢弃)。
2. 缓存局部性。最近提交的任务,它的数据大概率还在缓存里。LIFO 能提高命中率,也就是降低 E[S]——这是在打第 8 章的第三个因子。工作窃取调度器(Go、tokio、ForkJoinPool)本地队列用 LIFO、偷别人的时候从队尾偷(也就是对被偷者是 FIFO),正是在同时吃这两个好处。
第 13 章说过叫号机卖的不是公平是效率。这一章给出真正只关于公平的那个选择。
但「公平」本身也得拆开。至少有三种互相冲突的公平:
- 顺序公平:先来的先服务。FIFO 满足,LIFO 严重违反。
- 比例公平:等待时间应该和你的活的长短成正比(办一件小事不该等一件大事那么久)。FIFO 严重违反这一条——第 18 章会看到,FIFO 下一个 0.076 长的小活平均要等 20.204,被放大 2025 倍。
- 结果公平:没有人等得特别惨。FIFO 满足,LIFO 严重违反。
FIFO 之所以是默认选择,是因为它同时满足第一和第三条,而这两条是看得见的。它违反的第二条是看不见的——没有人会注意到「我这个简单请求本来只该等 0.1 秒」。
默认值往往不是最优的,只是最不容易被投诉的。
过载时把队列从 FIFO 换成 LIFO。这是一个在几家大厂被独立发现过的技巧。系统过载时,FIFO 队列里堆的全是「客户端早就超时放弃了」的请求,服务器在拼命处理没人要的结果。改成 LIFO,先处理最新的,有效吞吐立刻回升。注意这不是在提高吞吐,是在提高「有效」吞吐——总工作量守恒,但浪费在死请求上的部分变少了。
电梯不是 FIFO。电梯用的是「扫描」算法(SCAN,也叫电梯算法):往一个方向走到底,再折返。这既不是 FIFO 也不是 LIFO,而是为了降低 E[S](移动距离)而牺牲顺序公平。磁盘调度器用的是同一个算法,理由也一样。
客服工单。大多数工单系统默认 FIFO。但如果积压严重,先处理最老的意味着客户已经等了三天而且很生气,而新提的工单还有救。这里的「变质」是客户满意度。很多成熟的客服团队会做分流,而不是纯 FIFO。
这个直觉的错误之处在于把「调度」和「优化」混为一谈。真正能提高吞吐的调度只有两类:
- 改变
E[S]的:比如通过缓存局部性、批量合并、减少上下文切换。这不算调度的功劳,算局部性的功劳。 - 避免浪费的:比如不去处理已经超时的请求。这也不是提高效率,是减少无效工作。
除此之外,调度就是一场分配游戏。而认清这一点其实是解放性的:当你在争论「用哪个调度算法」时,你争的不是效率,是价值判断——谁该等,谁不该等。那是一个产品问题,不是技术问题。
B完全不变。二十万个请求的模拟:FIFO 3.774,LIFO 3.788,差 0.4%(采样噪声,理论上精确相等)。
但标准差从 4.641 涨到 12.770,最惨的那位从等 49.2 变成等 405.9。
如果你选了 A,你的直觉抓住了「有人会等很久」这件事——那是对的,只是它被「大多数人等得更少」精确地抵消掉了。
A 「把人晾着不管,总体肯定更糟」——直觉上完全合理,而且它抓住了真实的一半(尾巴确实更糟)。它错在没意识到另一半(中位数好了四倍),而两半恰好抵消。 C 「LIFO 有缓存局部性优势」——这在真实系统里确实成立,而且是工作窃取调度器选择 LIFO 的原因之一。但那是在改E[S](第三个因子),不是调度本身的效果。在这道题的纯排队模型里,服务时间是给定的。
D 「取决于负载」——平均等待相等这件事在任何负载下都成立,它不依赖 ρ。变的只是方差的差距有多大(ρ 越高差距越夸张)。
这一章的一句话
只要服务台不偷懒、而且不看活的长短,换任何顺序都改不了平均等待——你能改的只有把方差分给谁。
下一章去破坏第二个条件:看活的长短。这是唯一一个能真正压低平均等待的调度手段,而效果大得惊人——同一批活,先来后到平均等 20.292,剩余时间最短优先只要 0.548。那些最小的活,从被放大 2025.1 倍变成被放大 1.0 倍。而代价,写在第四档那一列里。