实时通信 — WebSocket、gRPC 流式调用与 WebRTC
在一个 HTTP/2 连接上叠加请求并测量代价
目标
在字节层面确认 HTTP/2 帧和头部压缩,构建一个在一个连接上叠加承载多个请求的客户端,亲自测量它与 HTTP/1.1 的差异以及 TCP 层面的 HOL 阻塞。
为什么重要
HTTP/2 是为了用一个连接同时传送多个请求而设计的协议。作为代价,一切都押在一个连接上,所以一个不归还流量控制窗口的 bug,或一次报文段丢失,就会让该连接上的所有流都停住。gRPC 运行在 HTTP/2 之上,而流式响应突然停住的故障中,有相当一部分就出在这个窗口上。本实验用同一个工具测量收益与代价,用数字留下哪一方在什么时候获胜。
步骤
- 读取 9 字节的帧头部——在 /root/rt/h2/h2lab.py 中创建 frame_header(data)。把 bytes 的前 9 个字节读作 HTTP/2 帧头部,返回由(长度、类型、标志、流编号)四个 int 构成的 tuple。长度是前 3 字节的无符号大端整数,流编号是最后 4 字节去掉最前面的保留位之后的 31 位。短于 9 字节时为 ValueError,9 字节之后的字节会被忽略。
- 相同的头部发送两次就会变小——在 /root/rt/h2/h2lab.py 中添加 hpack_sizes(headers, times)。用 hpack.Encoder 把由(名称,值)tuple 构成的 list headers 编码 times 次,并把每次编码结果的字节数以 list 返回。这是在模拟一个连接多次发送请求的情形,所以只创建一个编码器并持续使用。
- 在一个连接上叠加承载请求——在 /root/rt/h2/h2lab.py 中添加 fetch_all(host, port, paths, timeout=10)。向 host:port 打开一个 TCP 连接,用 h2 库的客户端连接发送连接前言,然后不等待响应,把 paths 中的 GET 请求全部发出。每当有响应结束就收集起来,并按与 paths 相同的顺序,返回由(路径、状态 int、正文 bytes、从开始到结束的毫秒 float)tuple 构成的 list。对收到的 DATA 用 acknowledge_received_data 归还窗口,如果在 timeout 秒内没有结束,就抛出异常。
- 用与 HTTP/1.1 相同的标尺来测量——运行 /opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py compare。会给出把 6 个耗时 300ms 的请求,通过一个 HTTP/1.1 keep-alive 连接依次发送所用的时间,以及用你的 fetch_all 发送所用的时间。把输出中的 h1_total_ms 和 h2_total_ms 两行原样写入 /root/rt/h2/report.txt。评分器会当场重新测量并核对。
- 遵守服务器通告的并发流上限——修改 fetch_all,使它在收到服务器的 SETTINGS 之后,不超过 remote_settings.max_concurrent_streams 来打开流。如果达到上限,其余的请求先等待,等有流结束就立即打开。评分器会向通告上限为 2 的服务器发送 5 个请求,查看是否既不超出上限,又能每次两个地叠加发送。
- 测量流量控制窗口关闭的位置——在 /root/rt/h2/h2lab.py 中添加 window_probe(host, port, path, window, quiet=0.3)。在新连接的前言之后,发送把 INITIAL_WINDOW_SIZE 通告为 window 的 SETTINGS,然后发送 GET path。对收到的 DATA 不归还窗口,只做计数,如果在 quiet 秒内什么也没有到来,就记录到那时为止收到的字节,然后把收到的量归还窗口,并读取到结束。返回(停住之前收到的字节, 总字节)。
- TCP 的一条线被堵住,所有流就都会停住——运行 /opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py hol。在下游经过 20000 字节之后停顿一次 800ms 的中继器夹在中间,同时请求一个大响应和一个小响应,测量小响应结束的时刻。HTTP/2 用你的 fetch_all 通过一个连接发送,HTTP/1.1 则拆成两个连接发送。把输出中的 hol_h2_ms 和 hol_h1_ms 两行添加到 /root/rt/h2/report.txt 中。
参考
- 工作文件夹是 /root/rt/h2。请先用 mkdir -p /root/rt/h2 创建。
- 测量工具是 /opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py 的 compare 和 hol。它们会调用你的 fetch_all。
- 评分器使用的服务器是 /opt/fixtures/rt/h2/h2server.py。想自己启动的话,请使用 /opt/rt-lab/bin/python /opt/fixtures/rt/h2/h2server.py --port 9443。
- 有两个常见错误:不归还所收到 DATA 的窗口,导致在大响应处停住;以及在收到服务器 SETTINGS 之前就把请求集中发出,超出并发流上限。
- Python 必须用 /opt/rt-lab/bin/python 运行。本实验的库只装在那个虚拟环境里,如果直接用 python3 运行,就会出现 ModuleNotFoundError。像 alias rpy=/opt/rt-lab/bin/python 这样简写一下会比较方便。
- 实验 Pod 的对外连接被封锁。所有通信都发生在同一个 Pod 内的 127.0.0.1 上,不需要安装或下载。
- 评分器会以单独的进程加载你的代码,并实际建立连接。示例文件只是函数框架,原样保留是通不过的。前面步骤中已经完成的函数不要删除。
- 实验会话结束后,/root 中的文件不会保留。需要的代码请在结束之前另行保存。
读取 9 字节的帧头部
在 /root/rt/h2/h2lab.py 中创建 frame_header(data)。把 bytes 的前 9 个字节读作 HTTP/2 帧头部,返回由(长度、类型、标志、流编号)四个 int 构成的 tuple。长度是前 3 字节的无符号大端整数,流编号是最后 4 字节去掉最前面的保留位之后的 31 位。短于 9 字节时为 ValueError,9 字节之后的字节会被忽略。
帧头部的形态就是 RFC 9113 第 4.1 节的图。保留位发送时必须为 0,但接收时规定要忽略。即使收到时被置位,也不能让流编号发生变化。
相同的头部发送两次就会变小
在 /root/rt/h2/h2lab.py 中添加 hpack_sizes(headers, times)。用 hpack.Encoder 把由(名称,值)tuple 构成的 list headers 编码 times 次,并把每次编码结果的字节数以 list 返回。这是在模拟一个连接多次发送请求的情形,所以只创建一个编码器并持续使用。
HPACK 的动态表是每个连接一个,并在连接存活期间不断累积。编码器对象就是那张表。如果第二次的结果与第一次相同,请检查你每次都在新建什么。
在一个连接上叠加承载请求
在 /root/rt/h2/h2lab.py 中添加 fetch_all(host, port, paths, timeout=10)。向 host:port 打开一个 TCP 连接,用 h2 库的客户端连接发送连接前言,然后不等待响应,把 paths 中的 GET 请求全部发出。每当有响应结束就收集起来,并按与 paths 相同的顺序,返回由(路径、状态 int、正文 bytes、从开始到结束的毫秒 float)tuple 构成的 list。对收到的 DATA 用 acknowledge_received_data 归还窗口,如果在 timeout 秒内没有结束,就抛出异常。
h2 不认识套接字。要用 data_to_send() 取出要发送的字节并亲自发送,把收到的字节放进 receive_data(),以事件的形式取回。如果不归还窗口,一个大响应就会在 64KiB 处让整个连接停住。评分请求中混有 200000 字节的响应。
用与 HTTP/1.1 相同的标尺来测量
运行 /opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py compare。会给出把 6 个耗时 300ms 的请求,通过一个 HTTP/1.1 keep-alive 连接依次发送所用的时间,以及用你的 fetch_all 发送所用的时间。把输出中的 h1_total_ms 和 h2_total_ms 两行原样写入 /root/rt/h2/report.txt。评分器会当场重新测量并核对。
HTTP/1.1 在一个连接上只能按响应顺序处理请求。规范中虽然有管线化,但浏览器和代理实际上并不使用。所以六个 300ms 会原样累加。
遵守服务器通告的并发流上限
修改 fetch_all,使它在收到服务器的 SETTINGS 之后,不超过 remote_settings.max_concurrent_streams 来打开流。如果达到上限,其余的请求先等待,等有流结束就立即打开。评分器会向通告上限为 2 的服务器发送 5 个请求,查看是否既不超出上限,又能每次两个地叠加发送。
在收到 SETTINGS 之前并不知道上限。按规范,那时的默认值是不限,所以一发出前言就把请求集中发出,服务器就会拒绝。第一个 SETTINGS 会以 RemoteSettingsChanged 事件的形式到来。
测量流量控制窗口关闭的位置
在 /root/rt/h2/h2lab.py 中添加 window_probe(host, port, path, window, quiet=0.3)。在新连接的前言之后,发送把 INITIAL_WINDOW_SIZE 通告为 window 的 SETTINGS,然后发送 GET path。对收到的 DATA 不归还窗口,只做计数,如果在 quiet 秒内什么也没有到来,就记录到那时为止收到的字节,然后把收到的量归还窗口,并读取到结束。返回(停住之前收到的字节, 总字节)。
流量控制窗口是每个流一个,整个连接一个。通过 SETTINGS 能改变的只有流的初始值,连接窗口从 65535 开始。如果把 window 设为 100000,请先算一算会在哪里停住。
TCP 的一条线被堵住,所有流就都会停住
运行 /opt/rt-lab/bin/python /opt/fixtures/rt/h2/bench.py hol。在下游经过 20000 字节之后停顿一次 800ms 的中继器夹在中间,同时请求一个大响应和一个小响应,测量小响应结束的时刻。HTTP/2 用你的 fetch_all 通过一个连接发送,HTTP/1.1 则拆成两个连接发送。把输出中的 hol_h2_ms 和 hol_h1_ms 两行添加到 /root/rt/h2/report.txt 中。
中继器的停顿,模拟的是一个丢失的报文段在等待重传期间,TCP 不把后面的字节交给应用程序的情形。流是 HTTP/2 的概念,TCP 并不知道它。