实时通信 — WebSocket、gRPC 流式调用与 WebRTC
动手实现连接池并让它耗尽
目标
亲手构建一个具备上限、等待时间、废弃规则和空闲上限的客户端连接池,并用同一个测量工具,分别测量没有请求超时的慢调用把池耗干的场景,以及恢复过来的场景。
为什么重要
HTTP 客户端、DB 驱动和 gRPC 通道,内部都有连接池。连接池一旦枯竭,服务器明明很空闲,客户端的所有请求却都在排队,而只看服务器指标的人,什么也看不到。原因通常不在池的配置,而在池之外的规则——没有请求超时、把已超时的连接放回池中、重新使用对方已经关闭的连接。本实验会逐一重现并阻止这三种情形。只使用标准库。
步骤
- 有上限的池——在 /root/rt/pool/pool.py 中创建 PoolTimeout 异常和 Pool 类。Pool(factory, max_size, acquire_timeout, idle_timeout=None) 接收一个不带参数调用就能创建一个连接的 factory。acquire() 在有空闲连接时返回它,没有空闲连接且到目前为止创建的连接少于 max_size 时,用 factory 新建一个并返回。release(conn) 把连接放回空闲列表。如果 max_size 不是 1 以上的 int,或 acquire_timeout 不是正数,则为 ValueError,bool 不作为数字接受。
- 没有空位时,只等待规定的时间——修改 acquire(),使得在连接全部被占用、也无法再创建时,最多等待 acquire_timeout 秒。这期间如果有人 release,等待的一方就立即得到那个连接,时间用完则抛出 PoolTimeout。等待期间不要占着 CPU 空转。
- 状态不明的连接不放回——给 Pool 的 release(conn, broken=False) 加上 broken,并添加 connection() 上下文管理器。如果 broken=True,就调用连接的 close(),并把它从池中去掉,让下一次 acquire 可以新建连接。如果 with pool.connection() as conn: 块正常结束,就把连接放回,如果以异常结束,就按 broken 处理,然后原样再次抛出该异常。
- 用数字给出池的状态——给 Pool 添加 stats()。返回由 size(当前存活的连接数)、idle(空闲连接数)、in_use(被借走的连接数)、waiting(当前在 acquire 中等待的调用数)、timeouts(到目前为止抛出 PoolTimeout 的次数)五个键构成的 dict。
- 已超时的连接中还残留着上一个响应——在 /root/rt/pool/pool.py 中添加 get(pool, path, timeout)。从 pool 借出连接(套接字),设置 settimeout(timeout),用 /opt/fixtures/rt/rtnet.py 的 h1_get(sock, path) 发送 HTTP/1.1 GET,并返回(状态,正文)。包括超时在内,只要出现任何异常,就把该连接按 broken 丢弃,并重新抛出异常。评分器会用 max_size 为 1 的池让一个慢请求超时,然后立即发送下一个请求。
- 四个慢调用占住整个池——运行 /opt/rt-lab/bin/python /opt/fixtures/rt/pool/starve.py。用你的、大小为 4 的池,先发送四个耗时 5 秒的请求,再发送二十个快请求。会给出没有请求超时和请求超时为 0.5 秒时,各有多少个快请求成功。把输出中的 starved_ok 和 bounded_ok 两行写入 /root/rt/pool/report.txt。评分器会重新测量并核对。
- 不再使用服务器先关闭的连接——实现 Pool 的 idle_timeout。记录通过 release 回来的时刻,在 acquire 取出空闲连接时,对空闲时间超过 idle_timeout 秒的连接执行 close() 并丢弃,然后再看下一个。如果是 None,就不丢弃。评分器会在一个会断开安静 1 秒的连接的中继器后面放置服务器,用 idle_timeout 为 0.5 的池,依次发送请求 → 休息 1.5 秒 → 请求。
参考
- 工作文件夹是 /root/rt/pool。请先用 mkdir -p /root/rt/pool 创建。
- 测量工具是 /opt/rt-lab/bin/python /opt/fixtures/rt/pool/starve.py,它会调用你的 Pool 和 get。服务器由测量工具自己启动。
- 评分器会用多个线程同时调用 acquire。所有状态变更都要在锁内进行。
- 有两个常见错误:把已超时的套接字放回池中,导致下一个请求读到上一个响应;以及从 wait 中醒来之后没有重新检查条件,从而抢走别人的连接。
- Python 必须用 /opt/rt-lab/bin/python 运行。本实验的库只装在那个虚拟环境里,如果直接用 python3 运行,就会出现 ModuleNotFoundError。像 alias rpy=/opt/rt-lab/bin/python 这样简写一下会比较方便。
- 实验 Pod 的对外连接被封锁。所有通信都发生在同一个 Pod 内的 127.0.0.1 上,不需要安装或下载。
- 评分器会以单独的进程加载你的代码,并实际建立连接。示例文件只是函数框架,原样保留是通不过的。前面步骤中已经完成的函数不要删除。
- 实验会话结束后,/root 中的文件不会保留。需要的代码请在结束之前另行保存。
有上限的池
在 /root/rt/pool/pool.py 中创建 PoolTimeout 异常和 Pool 类。Pool(factory, max_size, acquire_timeout, idle_timeout=None) 接收一个不带参数调用就能创建一个连接的 factory。acquire() 在有空闲连接时返回它,没有空闲连接且到目前为止创建的连接少于 max_size 时,用 factory 新建一个并返回。release(conn) 把连接放回空闲列表。如果 max_size 不是 1 以上的 int,或 acquire_timeout 不是正数,则为 ValueError,bool 不作为数字接受。
优先使用空闲连接,正是池存在的理由。目的是不必为每个请求都付出创建连接的成本(TCP 握手、TLS)。评分器会统计 factory 被调用了几次。
没有空位时,只等待规定的时间
修改 acquire(),使得在连接全部被占用、也无法再创建时,最多等待 acquire_timeout 秒。这期间如果有人 release,等待的一方就立即得到那个连接,时间用完则抛出 PoolTimeout。等待期间不要占着 CPU 空转。
threading.Condition 的 wait(timeout) 不会告诉你是因为什么而醒来的。每次醒来都要重新检查条件,并重新计算剩余时间。如果先用 time.monotonic() 确定一次截止时刻,计算就会变简单。
状态不明的连接不放回
给 Pool 的 release(conn, broken=False) 加上 broken,并添加 connection() 上下文管理器。如果 broken=True,就调用连接的 close(),并把它从池中去掉,让下一次 acquire 可以新建连接。如果 with pool.connection() as conn: 块正常结束,就把连接放回,如果以异常结束,就按 broken 处理,然后原样再次抛出该异常。
出现异常的那一刻,并不知道该连接上还残留着什么。响应的后半部分可能还在套接字里。不知道的就丢弃,是池的基本规则。使用 contextlib.contextmanager 可以写得很简短。
用数字给出池的状态
给 Pool 添加 stats()。返回由 size(当前存活的连接数)、idle(空闲连接数)、in_use(被借走的连接数)、waiting(当前在 acquire 中等待的调用数)、timeouts(到目前为止抛出 PoolTimeout 的次数)五个键构成的 dict。
池的枯竭在服务器指标中看不到。因为服务器很空闲,只有客户端在排队。waiting 和 timeouts 是唯一能显示这条队伍的数字。开始等待时加一,无论因为什么原因等待结束时都要减一。
已超时的连接中还残留着上一个响应
在 /root/rt/pool/pool.py 中添加 get(pool, path, timeout)。从 pool 借出连接(套接字),设置 settimeout(timeout),用 /opt/fixtures/rt/rtnet.py 的 h1_get(sock, path) 发送 HTTP/1.1 GET,并返回(状态,正文)。包括超时在内,只要出现任何异常,就把该连接按 broken 丢弃,并重新抛出异常。评分器会用 max_size 为 1 的池让一个慢请求超时,然后立即发送下一个请求。
超时只是不再等待,并不是服务器不发送响应。那个响应即使迟到,也会通过同一个套接字到达。如果把这个套接字放回池中,下一个请求就会读到别人的响应。把 /opt/fixtures/rt 加入 sys.path,就可以导入使用 rtnet。
四个慢调用占住整个池
运行 /opt/rt-lab/bin/python /opt/fixtures/rt/pool/starve.py。用你的、大小为 4 的池,先发送四个耗时 5 秒的请求,再发送二十个快请求。会给出没有请求超时和请求超时为 0.5 秒时,各有多少个快请求成功。把输出中的 starved_ok 和 bounded_ok 两行写入 /root/rt/pool/report.txt。评分器会重新测量并核对。
没有超时的调用会无限期地占着池的位置。acquire_timeout 只能解救等待的一方,却无法让占着位置的一方结束。你必须能说明这两个值为什么会是那样。
不再使用服务器先关闭的连接
实现 Pool 的 idle_timeout。记录通过 release 回来的时刻,在 acquire 取出空闲连接时,对空闲时间超过 idle_timeout 秒的连接执行 close() 并丢弃,然后再看下一个。如果是 None,就不丢弃。评分器会在一个会断开安静 1 秒的连接的中继器后面放置服务器,用 idle_timeout 为 0.5 的池,依次发送请求 → 休息 1.5 秒 → 请求。
负载均衡器和服务器会先断开安静的连接。客户端连接池只有在下次向那个连接发送数据时才会知道。如果把池的空闲上限设得比对方的空闲上限更短,就能避开这场竞争。