不要无限等待缓慢的包裹
一句话总结
长度和时间需要不同的上限。不能因为每次都在一点点推进,就可以无限期地等下去。
为什么需要它
包裹信封上写着将有接近 4 GiB 的正文到来。这个值本身能放进 32 位整数,但我们服务的接收预算是 4096 个字节。如果相信这个数字而先分配内存,一个小小的头部就会消耗大量资源。对方的承诺并不是资源分配的许可。本实验是练习:不等大正文真的到来,在头部完成的那一刻就拒绝。
限制了内存之后,还剩下时间。如果每来一个字节就重新等待 1 秒,那么对方每 0.9 秒发一个字节,连接就会一直存活。“整个帧要在 1 秒内收完”这一要求,和“每次 recv 都在 1 秒内返回”这一要求是不同的。前者需要把直到请求结束为止的剩余预算传递下去。
工作原理
一开始只计算一次 deadline=clock()+timeout。每次在调用 recv 之前,计算 remaining=deadline-clock()。如果 remaining 为 0 或更小,就在继续读取之前抛出 TimeoutError。否则用 sock.settimeout(remaining),使阻塞读取也不会超过剩余时间。头部和正文要共用同一个 deadline。不能因为头部来得晚,就重新给正文发放时间。
默认时钟是 time.monotonic。即使因时钟校准使墙上时钟倒退,也不会让经过时间的计算出现逆行。测试中通过 clock 参数注入一个函数。这样就不必真的睡上几秒去等待边界情况,而是在 recv 推进时一点点拨快模拟时刻。模拟测试以确定性的方式检查时间预算的计算,真实套接字测试则检查与操作系统接口的结合。不会只凭其中一个就说另一个也得到了证明。
read_exact 会按剩余大小反复调用 recv,直到获得 n 个字节。recv(n) 返回的字节少于 n 是正常的。b"" 是 EOF。如果在完全没有收到新头部的情况下遇到 EOF,说明已经没有更多消息,所以返回 None。只要收到了哪怕一个字节的头部,或者正文有所缺失,就是 EOFError。长度为 0 的正文没有可读的字节,所以直接返回 b""。这时如果再调用一次 recv,可能会吞掉下一个帧,或者白白等待。
不仅在成功时,在抛出异常时也必须恢复调用前的套接字 timeout。把同一个套接字交给下一个任务时,如果上一次读取留下的很短的 remaining 值还在,下一个任务就会因莫名其妙的原因很快失败。try/finally 表达的就是这个资源契约。如果 timeout 是 0、负数、无穷大或 NaN,就违反了“正的有限预算”这一 API 契约,所以是 ValueError。
出现长度错误后,要废弃该连接。这个协议没有定义那种随意一个字节一个字节地丢弃并寻找下一个头部的恢复方式。因此,如果因为下一个数字看起来像那么回事就重新同步,就会把正文的中间部分误认为新消息。真实的协议是否定义了恢复点或校验和,是另外的问题。解析器不会凭想象添加不存在的规则。
在现场相遇的样子
整个请求的期限与各子任务期限之间的差别,在数据库调用和外部 API 中也会出现。如果各为 2 秒的四个任务依次执行,用户请求的整体耗时可能远远超过 2 秒。要把剩余预算向下传递,并规定取消时要关闭什么。本课程只实现到一个同步 TCP 帧的预算。大规模并发连接、事件循环、全局发送队列的背压,属于另外的实时课程的范围。
例如,起始时刻为 10.0 秒、预算为 1 秒,则 deadline 为 11.0。如果在 10.4 收到第一个字节,下一次 recv 的预算是 0.6 秒;再在 10.8 收到下一个,预算就是 0.2 秒。此后如果字节要花 0.4 秒才到达,就是没能在剩余的 0.2 秒内读到。每次都重新给 1 秒的实现,看起来所有调用都成功了,但整个请求契约已经被违反了。
反过来,在注入了调用方时钟的测试中,如果反复使用真实的 time.sleep,模拟时间和现实时间就会混在一起。clock 函数只负责返回当前时刻,模拟套接字则配合字节的推进来拨快模拟时刻。在使用真实套接字的路径上,保持默认的 monotonic。把这种区分写进 API,其他实现也可以按同样的契约来测试,而不必为了迁就某条特定的 import 语句而勉强调整测试。
超时之后,要决定是继续读取剩余的帧,还是关闭连接。本课程在读取失败后废弃该连接,所以不会把中间状态拼接到其他调用中。如果捕获异常并换成空 bytes 返回,不完整的数据看起来就会像有效的空消息。把错误的种类传递给调用方,是分离恢复策略的起点。
下一项检查要做什么
在紧接着的测验中区分上限和整体期限,在最后一个模块中分别运行正常分割输入、中途 EOF、超长头部、缓慢输入。也会把每次都重新给 timeout 的那个貌似合理的错误答案放进测试。不仅确认发生了错误,还要确认原来的套接字设置是否已经恢复。1 秒是教学用的契约,并不是适用于所有业务的推荐值。