TT Lab
开始
学习 学习路径 课程

一个连接变慢,其余全都停住了

发出去的未必都出去了

在 TT Lab 中继续学习

一句话总结

recv 和 send 并不承诺所请求的大小。保管剩余的部分,是程序自己的职责。

为什么需要它

改成多路复用之后,会出现新类型的 bug。在阻塞模式下,sendall 会自己负责,直到全部发完才返回。一旦改成非阻塞,这种便利就消失了。如果内核发送缓冲区只剩 20 字节的空间,却要发送 200 字节,send 会返回 20,把剩下的留给程序。

send(2) 对返回值的说明很简短:成功时返回已发送的字节数。写的是“已发送的字节数”,而不是“打算发送的字节数”。如果把这一行草草读过,就会写出这样的程序:平时运行良好,只有在响应变大或对方变慢时,后面的部分才会被截掉。而这个症状几乎总是首先被当作对方解析器的错误报上来。

工作原理

接收一侧和发送一侧,都需要为每个连接准备存放剩余片段的位置。接收缓冲区与前一门课程中讲过的分帧是同一回事。这门课程的协议是按行划分的,所以只要攒到出现换行即可,但没有换行并不代表输入有误,只是还没有完全到达而已。

发送缓冲区是新学的部分。为每个连接用 outbox 之类的名字持有尚未发出的字节,写就绪到来时调用一次 send,只从前面截掉返回的那么多字节。

sent = sock.send(self.outbox)
self.outbox = self.outbox[sent:]     # 나머지는 남긴다

这里常见的错误是写成 self.outbox = b""。用很短的字符串测试时,通常一次就能全部发出,所以能通过,只有在生产环境中响应变大时才会出问题。

这样一来,什么时候改变关注事件就变得重要了。读就绪只有在对方发来了东西时才为真,而写就绪则是在发送缓冲区有空位时为真。大多数时候缓冲区是空的,所以写就绪几乎总是为真。如果在没有东西要发送时仍监视写就绪,多路复用调用就会变成什么事都没发生、立即返回的重复。

状态 要监视的事件 原因
没有要发送的内容 仅读 写就绪几乎总是为真,循环会空转
还有要发送的内容 读和写 必须知道空位出现的瞬间,才能接着发送

因此规则可以归纳为一句话:只有在还有要发送的内容时,才监视写就绪。在刚往队列里放入内容之后,以及刚发送完毕之后,这两个时点重新计算关注事件,就能遵守这条规则。

接收一侧也有与之成对的规则。对于一次读就绪,是只调用一次 recv,还是一直调用到出现 EAGAIN。在 selectors 的默认行为——水平触发下,只要还有剩余数据,下一次还会再次唤醒,所以只调用一次也是安全的。只调用一次,对连接之间的公平也更有利,因为一个话很多的客户端无法在一次唤醒中独占循环。

说到这里,会想起 epoll(7) 所说明的边缘触发。边缘触发只在状态改变的瞬间通知,所以醒来一次之后,如果不把数据读到没有可读内容为止,剩余的数据就会一直睡着等待下一次通知。相反,水平触发在条件持续期间会不断通知,所以即使读得少一些也不会被遗忘。这两种方式并不是性能上的优劣,而是责任所在的位置不同。如果不清楚用的是哪一种就把代码搬过来,就会变成那种偶尔丢失响应却无法复现的 bug。

缓冲区还需要上限。如果对方不断发送没有换行的字节,接收缓冲区就会无限增长。发送一侧也一样,如果对方不读取,而响应不断地堆进队列,内存就会被这一个连接占住。这意味着前一门课程中学过的长度上限和背压,在这里同样需要。多路复用只是转移了等待,并不能消除等待,而被转移的等待,往往以内存的形式表现出来。

在现场相遇的样子

部分发送平时藏得很好。如果响应小于内核发送缓冲区,就总是一次发出,测试能通过,预发布环境也能通过。暴露的时机是固定的:响应附带列表而变大时、对方放慢读取时,以及在一个连接上接连推入多个响应时。这时对方会收到被截断的 JSON 或只有一半的行,报告则以解析器错误的形式进来。而发送方的日志里只留下成功。

反过来,因为没有修改关注事件而引发的事故,首先会表现在 CPU 曲线上。无论连接数多少,都有一个核心贴在 100%,性能分析器会把多路复用调用本身显示为最热的位置。其实并不是那个调用慢,而是它在什么事都没有的情况下被调用得太频繁。

下一项测验要做什么

请整理一下,下面三种情形各应如何处理:send 在 20 字节中只返回了 3 的情形、一直监视写就绪的服务器的 CPU 曲线,以及收到没有换行的片段的情形。这三者都是“把剩余的内容放在哪里”这同一个问题的不同面孔。