大请求旁的小请求:就绪队列与公平性
一句话总结
找到就绪的连接,与给这个连接多少 CPU,是两个不同的问题。用有限的工作预算和不重复的就绪队列,让小请求不会在长请求后面饿死。
为什么需要它
前面是 recv 一次之后就回到其他连接。这次来设想一种优化:只要有数据就一直读下去。客户端 A 给出 8KiB 的稿件,客户端 B 只给一个字符。如果把 A 完全清空之后再去看 B,总吞吐量看起来不错,但 B 的第一次响应会很晚。如果 A 发送的速度始终快于读取的速度,循环甚至可能回不到 B 或终止请求那里。非阻塞的意思是每次调用都不会等待,并不意味着无限重复调用的代码就是公平的。
这个问题在 accept 上同样存在。一直接收新连接直到 EAGAIN 的循环,平时很快就结束,但在连接不断涌入的条件下,会推迟已有连接的工作。这就是要对读取字节数、发起连接数和就绪任务处理数分别设置预算的原因。预算不同于连接的总容许量。“这一轮只读 1KiB”与“请求最多只接收 1KiB”是两种不同的契约。
工作原理
epoll(7) 说明了通过就绪列表和轮转来避免饥饿的设计。水平触发在条件仍然成立时,下一次等待中仍能看到就绪状态。边缘触发如果只相信已经收到的通知,读完一部分数据后就去睡眠,就可能错过剩余的数据。在 ET 下,因预算用完而停下的任务,必须由应用程序自己记住。只有观察到 EAGAIN,才能判断现在能做的事已经做完。
这次的对比实验直接在 Python 的底层 epoll 上指定 EPOLLET。后续的连接诊断工具使用 DefaultSelector,并不实现 ET 读取服务器。请区分这两段代码。实验展示的是遗忘就绪状态的错误,而诊断工具则把同样的队列原则用于按预算分批处理多个完成事件。
python3 /opt/fixtures/reactor/fairness_probe.py
实验不涉及外部通信,创建两对 socketpair,先向 A 放入 8192 字节,向 B 放入 1 字节。为了不去假设内核的返回顺序,程序会有意先处理 A。每一轮最多读取 1024 字节。在没有额外发送、也没有 EOF 的情况下,比较下面的结果。
| 策略 | 从 A 消耗 | B 的轮次 | 下一个内核事件 |
|---|---|---|---|
| 每个连接只处理一次,并丢弃就绪事实 | 1024 字节 | 第二个 | 0 个 |
| 把还有事可做的连接放回队列末尾 | 8192 字节 | 第二个 | 0 个 |
第一种策略让 B 得到了轮次,却把 A 剩下的 7168 字节留在了那里。不能把“下一个事件 0 个”解释为输入为 0 字节。第二种策略既能很快处理 B,又能继续完成 A 的工作。这是两个连接在受控条件下的结果,并不能证明所有流量的延迟上限或性能提升率。
别让队列变成新的内存泄漏
即使同一个连接在每次 select 中反复出现,也只往队列里放一次。deque 负责顺序,set 负责是否包含。取出时也要从 set 中移除,之后才能再次放入。连接终止时,还要把它从队列中删掉。如果只用文件描述符的数字作为键,当已关闭 FD 的数字被新连接重用时,就可能与之前的任务混淆。本实验使用 Dial 对象作为键,概念测验则给相同的 FD 附上不同的代次编号来区分。
预算小并不总是好
如果把预算降到 1,连接之间的切换会变得频繁,但队列管理和系统调用的开销也会增加。反过来,如果预算给得很大,一轮的工作量就会变大,控制请求可能被推后。不要只运行总字节数相同的请求,而要改变长请求与短请求的比例。分别记录吞吐量、短请求的等待时间,以及观察到控制请求之前经过的轮数,就能说明得到了什么、失去了什么。这次实验中“第二个轮次”这一取值只是这批输入的证据,并不保证在任意数量的请求下也是第二个。
如果队列里还有任务,却在 select 中睡上 1 秒,就等于自己推迟了已经发现的工作。下一次等待应设为 0,但本轮的处理预算要保持不变。反过来,如果队列和新任务都没有,却一直用 0 来调用,就变成了忙等待。等待时间不是“一律设短”,而是根据仍然可做的工作和最近的截止时间来计算。
在现场相遇的样子
只看下载服务器的总传输量,可能会掩盖短控制请求被推后的现象。请把长连接和短连接混合起来,同时观察短连接是在第几轮被处理的,以及最长的等待时间。如果是单个任务要计算很长时间的结构,只靠 I/O 预算是不够的。CPU 工作的分离或计算量限制,需要另行设计。
下一项检查要做什么
在接下来的测验中,先确认就绪队列的反例。在之后综合实验的 ReadyQueue 中,要实现去重、FIFO 和删除已终止的任务。连接发起预算与完成处理预算要分别验证。如果只建了队列,却用一次性全部消耗掉的循环,就无法达到这个模块的目的。