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

实时通信 — WebSocket、gRPC 流式调用与 WebRTC

在一个 HTTP/2 连接上叠加请求并测量代价

在 TT Lab 中继续学习

目标

在字节层面确认 HTTP/2 帧和头部压缩,构建一个在一个连接上叠加承载多个请求的客户端,亲自测量它与 HTTP/1.1 的差异以及 TCP 层面的 HOL 阻塞。

为什么重要

HTTP/2 是为了用一个连接同时传送多个请求而设计的协议。作为代价,一切都押在一个连接上,所以一个不归还流量控制窗口的 bug,或一次报文段丢失,就会让该连接上的所有流都停住。gRPC 运行在 HTTP/2 之上,而流式响应突然停住的故障中,有相当一部分就出在这个窗口上。本实验用同一个工具测量收益与代价,用数字留下哪一方在什么时候获胜。

步骤

  1. 读取 9 字节的帧头部——在 /root/rt/h2/h2lab.py 中创建 frame_header(data)。把 bytes 的前 9 个字节读作 HTTP/2 帧头部,返回由(长度、类型、标志、流编号)四个 int 构成的 tuple。长度是前 3 字节的无符号大端整数,流编号是最后 4 字节去掉最前面的保留位之后的 31 位。短于 9 字节时为 ValueError,9 字节之后的字节会被忽略。
  2. 相同的头部发送两次就会变小——在 /root/rt/h2/h2lab.py 中添加 hpack_sizes(headers, times)。用 hpack.Encoder 把由(名称,值)tuple 构成的 list headers 编码 times 次,并把每次编码结果的字节数以 list 返回。这是在模拟一个连接多次发送请求的情形,所以只创建一个编码器并持续使用。
  3. 在一个连接上叠加承载请求——在 /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 秒内没有结束,就抛出异常。
  4. 用与 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。评分器会当场重新测量并核对。
  5. 遵守服务器通告的并发流上限——修改 fetch_all,使它在收到服务器的 SETTINGS 之后,不超过 remote_settings.max_concurrent_streams 来打开流。如果达到上限,其余的请求先等待,等有流结束就立即打开。评分器会向通告上限为 2 的服务器发送 5 个请求,查看是否既不超出上限,又能每次两个地叠加发送。
  6. 测量流量控制窗口关闭的位置——在 /root/rt/h2/h2lab.py 中添加 window_probe(host, port, path, window, quiet=0.3)。在新连接的前言之后,发送把 INITIAL_WINDOW_SIZE 通告为 window 的 SETTINGS,然后发送 GET path。对收到的 DATA 不归还窗口,只做计数,如果在 quiet 秒内什么也没有到来,就记录到那时为止收到的字节,然后把收到的量归还窗口,并读取到结束。返回(停住之前收到的字节, 总字节)。
  7. 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 中。

参考

读取 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 并不知道它。