用一个循环服务数百个连接
目标
亲自测量顺序服务器的排队情况,再构建一个用单个循环承受同样负载的多路复用服务器,并对比两个测量值。
为什么重要
只要有一个连接迟迟不开口,顺序服务器在这期间就什么也做不了。CPU 很空闲,日志也很安静,所以只看指标,连“被阻塞了”这件事本身都看不出来。如果不给每个连接各设一个等待位置,而是把等待集中到一处,这个问题就消失了,但作为代价,部分读取、部分写入、关注事件和空闲清理就得由程序自己管理。本实验会逐个构建这些管理成本,并用同一把尺子测量改动之前与之后,用数字留下究竟哪里真正变好了。只使用标准库,不需要联网或安装。
步骤
- 不会停住的监听套接字——在 /root/mux/server.py 中创建 make_listener(host, port, backlog=64)。对 AF_INET、SOCK_STREAM 套接字启用 SO_REUSEADDR,用 host 和 port 做 bind,再用 backlog 做 listen,并返回通过 setblocking(False) 改为非阻塞的套接字。port 0 允许由操作系统选择空闲端口。如果 host 不是非空字符串,或 port 不是 0 到 65535 的 int,或 backlog 不是 1 以上的 int,则为 ValueError。bool 不作为 int 接受。
- 把排队情况用数字记录下来——运行 python3 /opt/fixtures/mux/probe.py --target sequential。把输出末尾的 blocked 和 fast 两个值,以 seq_blocked=<值> 和 seq_fast=<值> 两行写入 /root/mux/report.txt。评分器会当场重新运行同样的测量,并对照这两个值。
- 一次醒来就把队列清空——在 server.py 中添加 accept_all(listener)。反复调用 accept,直到出现 BlockingIOError,返回所接受套接字的 list。如果没有等待的连接,则是空 list,所接受的套接字也要通过 setblocking(False) 改为非阻塞。出现 ConnectionAbortedError 或 ConnectionResetError 的连接要跳过,并继续接收剩余的队列。
- 只读取收到的部分,其余保管起来——在 server.py 中添加 Conn 类。Conn(sock, now=0.0) 把 inbox 和 outbox 设为空 bytes,closed 设为 False,last_active 设为 float(now)。on_readable() 只调用一次 sock.recv,把收到的字节接到 inbox 之后,然后只把以换行结尾的行去掉换行,以 list 返回。不完整的片段留在 inbox 中。如果 recv 返回 b"",就把 closed 设为 True 并返回空 list;BlockingIOError 则什么都不改并返回空 list;ConnectionResetError 则把 closed 设为 True 并返回空 list。长度为 0 的行也算一行。
- 只清除已发出的部分——在 Conn 中添加 queue(data)、wants_write 属性和 on_writable()。queue 只接收 bytes,把它接到 outbox 之后,如果不是 bytes 则为 ValueError。wants_write 表示 outbox 是否非空。on_writable 在 outbox 为空时不调用 send,返回 0;否则调用一次 sock.send,只从 outbox 前面截掉返回的那么多字节,并返回该数。BlockingIOError 返回 0,并保持 outbox 不变。BrokenPipeError 和 ConnectionResetError 把 closed 设为 True 并返回 0。
- 只有在有要发送的内容时才监视写——在 server.py 中添加 interest(conn)。如果 conn 的 outbox 为空,就返回仅 selectors.EVENT_READ;如果还有剩余,就返回 selectors.EVENT_READ 与 selectors.EVENT_WRITE 按 OR 合并的值。
- 挑出安静的连接——在 Conn 中添加 touch(now),在 server.py 中添加 idle_keys(conns, now, idle_timeout)。touch 把 last_active 改为 float(now)。idle_keys 接收从键到 Conn 的字典,只挑出 now - last_active 大于等于 idle_timeout 的键,排序后以 list 返回,并且不修改所接收的字典。如果 idle_timeout 不是正的有限 int 或 float,则为 ValueError,bool 不被接受。
- 用一个循环全部接收,再重新测量并对比——在 server.py 中添加 serve(host, port, idle_timeout, ready=None, clock=None)。用 make_listener 开始监听之后,如果有 ready,就用实际 bind 到的端口号调用一次,并通过 selectors 同时监视监听套接字和所有连接,循环往复。收到 REQ 后跟着一个值的行时,把 OK 后跟同一个值的行放入该连接的 outbox,用 interest 更新关注事件,并把 closed 的连接和 idle_keys 所挑出的连接从关注列表中移除并关闭。要给多路复用调用的等待时间设置上限,使得即使没有事件,空闲检查也会运行。然后运行 python3 /opt/fixtures/mux/probe.py --target /root/mux/server.py 和 python3 /opt/fixtures/mux/fdcount.py /root/mux/server.py,向 /root/mux/report.txt 再添加 mux_blocked=<值>、mux_fast=<值>、mux_fds=<值> 三行。
参考
- 所有函数都放在 /root/mux/server.py 一个文件中,测量值写入 /root/mux/report.txt。请先用 mkdir -p /root/mux 创建工作文件夹。
- 测量工具在 /opt/fixtures/mux 中。probe.py 测量排队情况,fdcount.py 测量 200 个连接时的文件描述符数,seqserver.py 是作为对比基准的顺序服务器。这三个都可以阅读。
- 评分器会当场重新测量你写下的数字。随便写一个看似合理的值是通不过的。
- 示例中尚未实现的函数保留为框架,但不要覆盖已经完成的函数。评分也会一并检查前面步骤的契约。
- 有两个常见错误:忽略 send 返回的数而把 outbox 整个清空,以及在没有要发送的内容时仍然一直监视写就绪,导致一个核心贴在 100%。
- 实验会话结束后,/root 中的文件不会保留。需要的代码请在结束之前另行保存。
不会停住的监听套接字
在 /root/mux/server.py 中创建 make_listener(host, port, backlog=64)。对 AF_INET、SOCK_STREAM 套接字启用 SO_REUSEADDR,用 host 和 port 做 bind,再用 backlog 做 listen,并返回通过 setblocking(False) 改为非阻塞的套接字。port 0 允许由操作系统选择空闲端口。如果 host 不是非空字符串,或 port 不是 0 到 65535 的 int,或 backlog 不是 1 以上的 int,则为 ValueError。bool 不作为 int 接受。
bind 和 listen 只改变内核状态,不会等待对方。让进程停住的是 accept,而阻止它的那一行就是套接字的模式。在检查中,必须单独处理 bool 是 int 的子类型这一点。
把排队情况用数字记录下来
运行 python3 /opt/fixtures/mux/probe.py --target sequential。把输出末尾的 blocked 和 fast 两个值,以 seq_blocked=<值> 和 seq_fast=<值> 两行写入 /root/mux/report.txt。评分器会当场重新运行同样的测量,并对照这两个值。
测量工具会先接入一个慢客户端,再让多个快客户端排在后面。blocked 是超过 1 秒的客户端数量,fast 是在 0.2 秒内完成的客户端数量。凭猜测写下的值,会与重新测量的值不一致。把输出保存为文件,到最后一步对比时会更方便。
一次醒来就把队列清空
在 server.py 中添加 accept_all(listener)。反复调用 accept,直到出现 BlockingIOError,返回所接受套接字的 list。如果没有等待的连接,则是空 list,所接受的套接字也要通过 setblocking(False) 改为非阻塞。出现 ConnectionAbortedError 或 ConnectionResetError 的连接要跳过,并继续接收剩余的队列。
读就绪通知不会告诉你等待的连接只有一个。也没有任何值能告诉你有几个,所以唯一的办法是反复调用,直到收到“没有更多了”的信号。那个信号不是错误,而是正常的结束条件。
只读取收到的部分,其余保管起来
在 server.py 中添加 Conn 类。Conn(sock, now=0.0) 把 inbox 和 outbox 设为空 bytes,closed 设为 False,last_active 设为 float(now)。on_readable() 只调用一次 sock.recv,把收到的字节接到 inbox 之后,然后只把以换行结尾的行去掉换行,以 list 返回。不完整的片段留在 inbox 中。如果 recv 返回 b"",就把 closed 设为 True 并返回空 list;BlockingIOError 则什么都不改并返回空 list;ConnectionResetError 则把 closed 设为 True 并返回空 list。长度为 0 的行也算一行。
不能保证一次 recv 就能得到一条完整的消息。必须与前面的片段接上之后才能找到换行,没有换行并不是输入有误,而是还没有全部到达。b"" 与空行的值看起来相似,含义却截然相反。
只清除已发出的部分
在 Conn 中添加 queue(data)、wants_write 属性和 on_writable()。queue 只接收 bytes,把它接到 outbox 之后,如果不是 bytes 则为 ValueError。wants_write 表示 outbox 是否非空。on_writable 在 outbox 为空时不调用 send,返回 0;否则调用一次 sock.send,只从 outbox 前面截掉返回的那么多字节,并返回该数。BlockingIOError 返回 0,并保持 outbox 不变。BrokenPipeError 和 ConnectionResetError 把 closed 设为 True 并返回 0。
send 的返回值是这次调用处理的字节数。用较短的响应测试时,通常一次就能全部发出,所以错误的实现也能通过。请先确定:放入 20 字节、只发出 3 字节时,outbox 中应该剩下什么。
只有在有要发送的内容时才监视写
在 server.py 中添加 interest(conn)。如果 conn 的 outbox 为空,就返回仅 selectors.EVENT_READ;如果还有剩余,就返回 selectors.EVENT_READ 与 selectors.EVENT_WRITE 按 OR 合并的值。
读就绪与写就绪变为真的频率完全不同。发送缓冲区大部分时间是空的,所以写就绪几乎总是为真。这一事实决定了这个函数的条件。
挑出安静的连接
在 Conn 中添加 touch(now),在 server.py 中添加 idle_keys(conns, now, idle_timeout)。touch 把 last_active 改为 float(now)。idle_keys 接收从键到 Conn 的字典,只挑出 now - last_active 大于等于 idle_timeout 的键,排序后以 list 返回,并且不修改所接收的字典。如果 idle_timeout 不是正的有限 int 或 float,则为 ValueError,bool 不被接受。
如果把挑选与断开混在一个函数里,遍历过程中数据结构就会改变。把挑选的一侧设为纯函数,只要传入时钟,就能测试边界条件。请先确定,当经过的时间恰好等于限制时按哪一边处理。
用一个循环全部接收,再重新测量并对比
在 server.py 中添加 serve(host, port, idle_timeout, ready=None, clock=None)。用 make_listener 开始监听之后,如果有 ready,就用实际 bind 到的端口号调用一次,并通过 selectors 同时监视监听套接字和所有连接,循环往复。收到 REQ 后跟着一个值的行时,把 OK 后跟同一个值的行放入该连接的 outbox,用 interest 更新关注事件,并把 closed 的连接和 idle_keys 所挑出的连接从关注列表中移除并关闭。要给多路复用调用的等待时间设置上限,使得即使没有事件,空闲检查也会运行。然后运行 python3 /opt/fixtures/mux/probe.py --target /root/mux/server.py 和 python3 /opt/fixtures/mux/fdcount.py /root/mux/server.py,向 /root/mux/report.txt 再添加 mux_blocked=<值>、mux_fast=<值>、mux_fds=<值> 三行。
把前面步骤中创建的函数直接接起来即可。循环新确定的只有顺序:接受、读取、写入、重新计算关注事件、断开该断开的。fds 的值是依赖于实现的数字,可能与别人的答案不同,而且你要能说明它为什么不是 200。