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

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

找回大写字母打印店丢失的回复

在 TT Lab 中继续学习

目标

构建一个多路复用服务器组件:对只终止了发送方向的客户端,把答复一直发送到底,同时限制内存和总生存时长。

为什么重要

如果在 EOF 时就关闭双向,尚未发送的响应就会消失。反过来,如果无限期地等待,慢客户端就会占住连接资源。把输入终止、输出残余和最终原因分开,就能同时解决这两个问题。本实验是每个连接以 EOF 结束的一次请求,以及 ASCII 字节的大写转换,并不是 HTTP 或 TLS 的实现。原有的按行服务器不做修改,而是写在单独的文件中。

步骤

  1. 划分终止原因与套接字所有权——创建 Peer(sock, max_bytes=4096, expires_at=None)。max_bytes 是不含 bool 的 1–65536 int。expires_at 为 None 时设为 time.monotonic()+30.0,如果直接指定,则必须是不含 bool 的非负有限 int/float。违反则为 ValueError。把 sock 设为非阻塞,并保存 sock、max_bytes 和 float(expires_at)。inbox 和 outbox 是空 bytes,read_eof、prepared、closed 为 False,reason 为空字符串。finish(reason) 只在第一次调用时执行:closed=True、保存 reason、清空两个缓冲区,并且不关闭套接字。
  2. 区分 EOF 与超出上限——实现 Peer.read_once()。如果 closed 或 read_eof,则不调用 recv,返回 0。其余情况调用一次 recv(min(4096, max_bytes+1-len(inbox)))。BlockingIOError 保持状态并返回 0,其他 OSError 则 finish('io_error') 并返回 0。如果得到 b'',只是 read_eof=True 并返回 0,不关闭。把收到的字节追加到 inbox,若超出上限则 finish('request_limit')。如果有数据,则返回收到的长度。不修改 expires_at。
  3. 为已闭口的客户端生成答复——创建 Peer.prepare_reply()。如果 closed、尚未到 EOF 或 prepared=True,则什么也不做。否则标记 prepared=True,把 inbox.upper() 移入 outbox,然后清空 inbox。只有 outbox 为空时才 finish('complete')。即使多次调用,也不会重新生成剩余的答复。不做字符串解码,直接使用 bytes.upper()。
  4. 只清除实际发出的部分——Peer.write_once() 在 closed 或 outbox 为空时,不调用 send,返回 0。其余情况对 outbox[:4096] 调用一次 send,只从前面清除返回的长度,并返回该长度。BlockingIOError 保持缓冲区并返回 0,其他 OSError 或 send 返回 0 时 finish('io_error') 并返回 0。发送之后如果 outbox 为空且 read_eof,则 finish('complete')。
  5. EOF 之后关闭读关注——Peer.events() 在 closed 时返回 0。其余情况下,返回 read_eof=False 时的 EVENT_READ 与 outbox 非空时的 EVENT_WRITE 按 OR 合并之后的 int。不修改套接字或缓冲区。EOF 之后的答复准备由循环立即执行,所以实际注册掩码为 0 只适用于已终止的连接。
  6. 不让一个字节无限延长生存时长——Peer.expire(now) 仅在未 closed 且 now >= expires_at 时 finish('deadline')。now 是调用方提供的有限单调时刻。即使 EOF 之后还有剩余答复,也同样适用,并且不修改已经终止的原因。
  7. 先取消注册,再回收 FD——模块函数 retire(selector, peer) 在执行 selector.unregister(peer.sock) 之后,再执行 peer.sock.close()。即使因尚未注册而出现 KeyError 也要 close,其他错误则在 close 之后再次传播。不修改 peer.reason。输入是有效的已打开套接字,真正的 close 由作为所有者的循环只调用一次。
  8. 在沉默的客户端旁边完成真实的 TCP 答复——创建 serve(peers)。输入是由持有互不相同的已打开连接套接字的新 Peer 构成的 list,函数接管套接字所有权。用 DefaultSelector 统一处理所有连接,select 的等待时间取 0.05 秒与距离最近截止时间的时长中较小的值(最小为 0)。在每次循环和 I/O 之前,检查 expire(time.monotonic())。在 READ 时先调用 read_once 再调用 prepare_reply,在 WRITE 时调用 write_once,并更新关注掩码。终止的连接用 retire 移除,即使出现异常,也要回收其余套接字和 selector。全部终止后,按输入顺序返回 reason 的 list,输入为空时返回 []。监听、accept 和创建线程都不在这个函数中进行。评分会使用 4 个真实的 TCP 连接,并重新执行前面各步骤的契约。

参考

划分终止原因与套接字所有权

创建 Peer(sock, max_bytes=4096, expires_at=None)。max_bytes 是不含 bool 的 1–65536 int。expires_at 为 None 时设为 time.monotonic()+30.0,如果直接指定,则必须是不含 bool 的非负有限 int/float。违反则为 ValueError。把 sock 设为非阻塞,并保存 sock、max_bytes 和 float(expires_at)。inbox 和 outbox 是空 bytes,read_eof、prepared、closed 为 False,reason 为空字符串。finish(reason) 只在第一次调用时执行:closed=True、保存 reason、清空两个缓冲区,并且不关闭套接字。

状态终止与 FD 回收是两回事。不要让后来的清理过程覆盖最初留下的失败原因。

区分 EOF 与超出上限

实现 Peer.read_once()。如果 closed 或 read_eof,则不调用 recv,返回 0。其余情况调用一次 recv(min(4096, max_bytes+1-len(inbox)))。BlockingIOError 保持状态并返回 0,其他 OSError 则 finish('io_error') 并返回 0。如果得到 b'',只是 read_eof=True 并返回 0,不关闭。把收到的字节追加到 inbox,若超出上限则 finish('request_limit')。如果有数据,则返回收到的长度。不修改 expires_at。

即使恰好达到允许量,也必须再确认下一个字节或 EOF。recv(0) 不是终止的证据。

为已闭口的客户端生成答复

创建 Peer.prepare_reply()。如果 closed、尚未到 EOF 或 prepared=True,则什么也不做。否则标记 prepared=True,把 inbox.upper() 移入 outbox,然后清空 inbox。只有 outbox 为空时才 finish('complete')。即使多次调用,也不会重新生成剩余的答复。不做字符串解码,直接使用 bytes.upper()。

EOF 之后输出方向依然活着。prepared 用来防止在部分发送之后把答复从头重新生成。

只清除实际发出的部分

Peer.write_once() 在 closed 或 outbox 为空时,不调用 send,返回 0。其余情况对 outbox[:4096] 调用一次 send,只从前面清除返回的长度,并返回该长度。BlockingIOError 保持缓冲区并返回 0,其他 OSError 或 send 返回 0 时 finish('io_error') 并返回 0。发送之后如果 outbox 为空且 read_eof,则 finish('complete')。

不要只凭正常路径上的短响应来证明部分发送。EAGAIN 之后的下一次调用中,必须发出同样的剩余字节。

EOF 之后关闭读关注

Peer.events() 在 closed 时返回 0。其余情况下,返回 read_eof=False 时的 EVENT_READ 与 outbox 非空时的 EVENT_WRITE 按 OR 合并之后的 int。不修改套接字或缓冲区。EOF 之后的答复准备由循环立即执行,所以实际注册掩码为 0 只适用于已终止的连接。

有内容要发送,并不意味着还能再接收。要分别计算输入和输出的条件。

不让一个字节无限延长生存时长

Peer.expire(now) 仅在未 closed 且 now >= expires_at 时 finish('deadline')。now 是调用方提供的有限单调时刻。即使 EOF 之后还有剩余答复,也同样适用,并且不修改已经终止的原因。

不要把 idle 与总生存时长混为一谈。这一契约中的截止时间不由活动决定,而是在创建时就固定了。

先取消注册,再回收 FD

模块函数 retire(selector, peer) 在执行 selector.unregister(peer.sock) 之后,再执行 peer.sock.close()。即使因尚未注册而出现 KeyError 也要 close,其他错误则在 close 之后再次传播。不修改 peer.reason。输入是有效的已打开套接字,真正的 close 由作为所有者的循环只调用一次。

finally 不是用来掩盖异常的功能,而是用来释放所拥有资源的位置。要区分 KeyError 与其他异常。

在沉默的客户端旁边完成真实的 TCP 答复

创建 serve(peers)。输入是由持有互不相同的已打开连接套接字的新 Peer 构成的 list,函数接管套接字所有权。用 DefaultSelector 统一处理所有连接,select 的等待时间取 0.05 秒与距离最近截止时间的时长中较小的值(最小为 0)。在每次循环和 I/O 之前,检查 expire(time.monotonic())。在 READ 时先调用 read_once 再调用 prepare_reply,在 WRITE 时调用 write_once,并更新关注掩码。终止的连接用 retire 移除,即使出现异常,也要回收其余套接字和 selector。全部终止后,按输入顺序返回 reason 的 list,输入为空时返回 []。监听、accept 和创建线程都不在这个函数中进行。评分会使用 4 个真实的 TCP 连接,并重新执行前面各步骤的契约。

如果为等待某一个客户端的响应而构造内部循环,就又变回了顺序服务器。在每个就绪事件中,只执行有限的输入输出。