[译] 在 QUIC 和 HTTP/3 中的队头阻塞细节

文章原文

你可能听说了,经过四年的工作,新的 HTTP/3 和 QUIC 协议终于接近正式标准化。预览版现在可以在服务器和浏览器中进行测试

HTTP/3 与 HTTP/2 相比有很大的性能改进, 主要原因是因为它将底层传输协议从 TCP 改为基于 UDP 的 QUIC. 在这篇文章中我们将要深入讨论其中的一项提升, 也就是消除”队头阻塞(“Head-of-Line blocking” (HOL blocking))问题”, 这是非常有用的, 我读过许多误解关于这实际是什么, 且在实际上有多大帮助. 解决队头阻塞也是 HTTP/3 和 QUIC 以及 HTTP/2 背后的主要动机之一, 一次它也为协议演进的原因提供了一个极好的视角.

我将首先介绍问题和追溯它在HTTP发展历史中的不同形式. 我们也将看到它如何与其他系统继续(例如: 优先级排序和拥塞控制) 进行交互. 目标是帮助人们对 HTTP/3 的性能改进做出正确的假设,这(剧透)可能并不像营销材料中有时声称的那样惊人。

什么是队头阻塞(Head-of-Line blocking)?

很难给你一个清晰的队头阻塞(HOL blocking)的技术定义,因为这篇博客文章单独描述了它的四个不同变体。一个简单的定义是:

当单个(慢)对象阻止其他/后续的对象继续时

现实生活中一个很好的比喻就是只有一个收银台的杂货店。一个顾客买了很多东西,最后会耽误排在他后面的人,因为顾客是以先进先出(First In, First Out)的方式服务的。另一个例子是只有单行道的高速公路。在这条路上发生一起车祸,可能会使整个通道堵塞很长一段时间。因此,即使是在“头部(head)”一个单一的问题可以“阻塞(block)”整条“线(line)”。

这个概念一直是最难解决的 Web 性能问题之一。为了理解这一点,让我们从 HTTP/1.1 开始讲起。

HTTP/1.1的队头阻塞

HTTP/1.1 是简单协议时代的产物, 那个时候协议仍然基于文本并且在网络上可读, 如下图所示:

图1: HTTP/1.1 服务器响应 script.js 文件

在本例中,浏览器通过 HTTP/1.1 请求简单的 script.js 文件(绿色),图1显示了服务器对该请求的响应。我们可以看到 HTTP 方面本身很简单:它只是在明文文件内容或“有效荷载”(payload)前面直接添加一些文本“headers”(红色)。然后,头(Headers)+ 有效荷载(payload)被传递到底层 TCP(橙色),以便真实传输到客户端。对于这个例子,假设我们不能将整个文件放入一个 TCP 包中,并且必须将它分成两部分。

注意:实际使用HTPS时, 这还有另外一个安全层介于HTTP和TCP之间, 通常使用 TLS 协议, 然而我们这里为了清晰说明忽略了它, 我将 TLS情况下的 HOL 阻塞变体以及QUIC怎么防止放在了彩蛋的部分. 请随意阅读.

现在, 让我们看看当浏览器同时请求了 style.css 在图2中:
图2: HTTP/1.1 服务器响应 script.js 和 style.css 文件

在这种情况下, 在响应 script.js 文件传输完成之后我们发送了 style.css(紫色)文件. style.css 的头部 (headers) 和内容只是附加在 JavaScript (JS) 文件之后. 接收者使用 Content-Length header 来知道每个响应的结束位置和另一个响应的开始位置 (在我们的简化示例中,script.js是1000字节,而style.css只有600字节).

在这个只包含了两个先文件的简单实例中, 所有这些都似乎很合理. 但是, 假设 JS 文件比 CSS 文件大的多(比如是 1MB 而不是 1KB). 在这种情况下, CSS 文件必须等待直到整个 JS 文件下载完成. 即使 CSS 文件很小能被尽快的加载使用. 为了更加直观的展示, 我们使用 1 表示 large_script.js 2 表示 style.css:

11111111111111111111111111111111111111122

这就是一个队头阻塞的实例!, 现在你可能会想: 要解决这个很容易! 只要让浏览器在JS文件之前请求CSS文件! 关键的一点是, 浏览器无法预知这两个文件中的哪一个在请求时会是更大的文件. 这是因为没有办法在 HTML 中指明文件有多大 (类似这样的东西很不错,HTML工作组:<img src="thisisfine.jpg" size="15000" />)

这个问题的真正结局的方式是采用 多路复用 . 如果我们能够将每个文件的有效负荷(payload) 分成更小的(chunks), 我们就能在混合或者 交错(interleave) 在网络上发送这些块: 先发送一份 JS 的块, 再发送一份 CSS 的,然后继续 JS 的, 等等. 直到文件下载完成. 使用这个方法, 较小的 CSS 文件将会被更早的下载(并可用). 同时 JS 较大的 JS 文件将会延迟一小会, 用数字可视化是这样:

12121111111111111111111111111111111111111

遗憾的是, 这种多路复用在 HTTP/1.1 是不可能的, 由于协议的限制. 为了理解这一点, 我们甚至不需要继续使用大小文件的设想. 这已经在我们两个较小文件的实例中出现了. 图3中我们传输了两个文件只使用了4个块:

图3: HTTP/1.1 服务器响应 script.js 和 style.css 文件 使用多路复用

这里主要的问题是 HTTP/1.1 是纯文本协议, 只在负载的头部加上 headers. 它没有进一步的信息对块之间进行区分. 让我们举例说明, 如果我们执意这么做. 在图3中, 浏览器开始解析 script.js 的头部, 并期望剩下 1000 字节的负载(内容长度). 不过只接收了 450字节的JS 数据(第一个块中). 而后开始读取 style.css 的头部. 最终将 CSS 的头部和第一块 JS 的负载认为是 JS 文件. 因为头部信息和负载都是纯文本. 更糟的是在读取 1000 字节后停止,将会在读第二个 script.js 块的中途结束. 此时,它没有看到有效的新 heards,必须丢弃 TCP 数据包 3 的其余部分。然后浏览器将它认为是 script.js 的内容传递给 JS 解析器,但它失败了,因为它不是有效的 JavaScript:

1
2
3
4
5
6
function first() { return "hello"; }
HTTP/1.1 200 OK
Content-Length: 600

.h1 { font-size: 4em; }
func

同样, 你可能会说有一个简单的方式: 让浏览器查找 HTTP/1.1 {statusCode} {statusString}\n 表达式来进行匹配新的 header 部分的起始位置. 这可能对第二个 TCP 包有作用. 但是将在第三个 TCP 包中失败: 浏览器无法知道绿色的 script.js 块那里结束和紫色的 style.css 什么地方开始?

这是 HTTP/1.1 协议设计的一个基础限制. 如果只有一个 HTTP/1.1 连接, 一个资源的响应必须完整的在另一个资源响应前传输完成. 这将会导致许多 HOL 阻塞问题, 比如: 较早展示的资源创建起来很慢(例如: index.html 页面是由数据库查询填充后再返回的), 或者是文章前面举例的, 先加载的资源很大.

这就是为什么浏览器开始为 HTTP/1.1 上的每个页面加载打开多个并行 TCP 连接(通常是6个). 这样请求可以被分布在这些独立的链接上, 并且不再有队头阻塞. 除了页面上有 6 个独立的资源… 这是很常见的. 这就是在多个域(img.mysite.com、static.mysite.com 等)和内容交付网络 (CDN) 上”分片(sharding)”资源的做法的来源。由于单独的域可以打开6个连接, 因此浏览器可能为一个页面打开最多 30 个TCP 连接. 这是可行的, 但开销相当大: 建立新的 TCP 连接可能很昂贵(例如服务器的状态和内存, 以及计算 TLS 的加密) 并且需要一些时间(特别是对于 HTTPS 连接,因为 TLS 需要自己的握手)

由于 HTTP/1.1 不能解决这个问题, 而且随着时间的推移并行 TCP 的补丁方案不能很好的扩展.很明显需要一种全新的方法,这就是后来的 HTTP/2.

注意: 阅读本文的老哥可能会表示想知道 HTTP/1.1 管道(pipelining)。我决定不在这里讨论这一点,以保持整个故事的流畅性,但对更深入的技术感兴趣的人可以阅读结尾的彩蛋部分

在 TCP 之上 HTTP/2 的队头阻塞

所以, 让我们回顾一下. HTTP/1.1 有一个队头阻塞的问题, 一个大或者慢的响应会延迟后面其他的响应. 主要的原因是协议本身是纯文本的, 在资源块(resource chunks)之间没有定界符. 让浏览器打开许多并行TCP连接,这既不高效,也不可扩展.

因此, HTTP/2 的目标非常明确: 回到单个 TCP 连接,解决队头阻塞问题. 换句话说: 我们希望能够实现资源块的适当复用. 这在HTP/1.1 中是不可能的, 因为没有办法分辨一个块属于哪个资源, 或者在哪里结束, 另一个块从哪里开始. HTTP/2 非常优雅的解决了这个问题, 它在资源库之前添加了帧(frames). 如图四:

图4: HTTP/2 服务器响应 script.js 文件

HTTP/2 在每个块前面放置了一个称为 DATA 的帧. 这些 DATA 帧主要保存了两个关键的元数据. 1. 后续的块所属的资源. 每个资源的 “字节流(byte stream)” 都被分配了一个唯一的数字, stream id. 2. 块的大小是多少. 协议还有很多其他帧类型, 图5 也展示了 HEADERS frame. 这里也使用了 stream id 来指定这些 headers 的内容属于哪个流. 这样甚至能将 headers 信息与实际的响应数据分离.

使用这些帧, HTTP/2 实现资源块的多路复用, 如图5:
图5: HTTP/2 服务器多路复用响应 script.js 文件和 style.css 文件

不同于图3中的示例. 浏览器可以完美的处理这种情况. 它首先处理 script.js 的 HEADERS 帧, 之后处理 JS 文件的第一个 DATA 帧. 从数据帧中包含的长度信息. 浏览器知道目前只需要处理到 TCP 数据包1 的末端, 并且从 TCP 数据包2开始将会是一个全新的帧. 在哪找到了 style.css 的 HEADERS 帧. 下一个 DATA 帧的流的 ID 与之前的 DATA 帧不同, 所以浏览器知道这是属于不同的资源. 同样对于 TCP 数据包3, 其中 DATA 帧的 stream id 将相关的数据 “解复用(demultiplex)” 到了对应的steam中.

因此, 通过”帧”化单个消息, HTTP/2 相比 HTTP/1.1 更加灵活. 它允许在单个 TCP 连接上通过交错排列块来多路传输多个资源. 它还解决了第一个资源缓慢时的队头阻塞问题: 而不必等待查询数据库生成的 index.html,服务器可以在等待index.html时开始发送其他资源.

HTTP/2 还有一个重要的成果也是我们突然需要的一种方法. 让浏览器与服务器之间通信,以指定单个连接的带宽如何分配到各种资源中. 换句话说, 资源块应该如何 调度(scheduled)交错(interleaved) .如果我们再用1和2来进行可视化,那么我们可以看到, 对于HTTP/1.1, 唯一的选项是 11112222 (让我们称其为 顺序的). 然而,HTTP/2则拥有更多的自由:

  • 公平的多路复用(例如两个渐进式JPEG):12121212
  • 加权多路复用(2的重要性是1的两倍):221221221
  • 反向顺序调度(例如2是一个重要的服务器推送资源):22221111
  • 部分调度(流1被中止并未完全发送):112222

使用哪种方式是由 HTTP/2 中所谓的 “优先级(prioritization)” 系统驱动的, 所选择的方法对 Web 性能有很大的影响. 然而, 这本身就是一个很复杂的问题, 对于剩下的文章内容, 不需要你对此理解, 所以就不在这进行说明.(在youtube上有个关于此的讲座)

我想你会同意, 通过 HTTP/2 的帧和优先级设置. 它确实解决了 HTTP/1.1 的队头阻塞问题. 但并没有这么简单. 我们虽然解决了 HTTP/1.1 的阻塞, 但是还有 TCP 的队头阻塞.

TCP 队头阻塞

事实证明, HTTP/2 只解决了 HTTP 级别的队头阻塞, 我们可以称之为 “应用层” 队头阻塞. 然而在典型的网络模型中, 还需要考虑下面的其他层. 如图6:

图6: 典型网络模型中的前几个协议层

HTTP 位于顶层, 但安全和传输分别是由 TLS (查看彩蛋的 TLS 部分)和 TCP 进行支持的. 这些协议中的每一个都用一些元数据包装上层的数据. 就如 TCP 的报文头被添加到我们的 HTTP(S) 数据前面, 然后 IP 的报文头被添加到 TCP 数据前面, 等. 这使得所有协议之间保持界限, 有利于重用性: TCP 协议无需关系传输的数据类型(可能是 HTTP / FTP / SSH, 谁又知道呢), IP 层对于 TCP/UDP 都适用.

但这对我们使用 HTTP/2 的多路复用产生一个严重影响, 如图 7 所示:
图7: TCP 与 HTTP/2 的视角差异

虽然我们和浏览器都知道我们正在获取 JavaScript 和 CSS 文件, 但 HTTP/2 不需要知道这一点. 它所知道的仅仅是它正在处理不同资源流 ID 的 chunks. 而 TCP 甚至不到它正在传输 HTTP ! TCP 仅知道它被要求将这些字节序列从一台计算机传输到另外一台计算机. 为此使用了固定最大尺寸的数据包, 通常为 1450 字节. 每个数据包只保证携带的那段字节范围, 这样原始数据才能在以正确的顺序重建.

换句话说, 两个层观察到的视角不同: HTTP/2 观察到多个独立的资源字节流, 而 TCP 只是看到单一模糊的字节流. 图7 中的 Packet3 就是一个例子: TCP 只是知道携带了 750 ~ 1599 的字节序列进行传输. HTTP/2 知道 packet3 中实际上有两个独立资源的两个chunks (注意: 每个HTTP/2 帧头(如DATA/HEADERS)也需要占用几个字节, 为了方便说明, 此处忽略了, 以使数据更加直观)

当你意识到互联网本质是不可靠的网络时, 这些不起眼的细节很重要. 数据包在点对点的传输过程中会丢失和延迟. TCP 受到欢迎也正因为如此: 它在不可靠的 IP 之上保证了可靠性. 通过 重传丢失数据包的副本 简单的做到了这一点.

我们现在可以开始理解为什么这将会导致传输层的 HOL 阻塞. 回看图7 并尝试回答: 如果 在 TCP 的传输过程中丢失了 packet2, 但是 packet1/3 已经到达, 会发生什么? 注意 TCP 并不知道它承载了 HTTP/2 的数据, 只知道需要按照顺序传输数据. 因此, 它将 packet1 的数据传递给了浏览器. 但是, packet2 的数据缺失, 所以不能将 packet3 的数据传递给浏览器. TCP 将 packet3 的数据保存到缓冲区, 直到收到了 packet2 的重发副本(这至少需要往返服务器一次). 之后再按照正确的顺序将数据包传递给浏览器. 换句话说: 丢失的 packet2 队头阻塞了 packet3 !

可能这里没有将问题表述足够清楚, 所以让我们查看 图7 中的TCP 包中的内容进行深入解释. 我们可以看到 packet2 包中携带了 stream id 2 (CSS 文件) 而 packet3 中携带了 stream id 1(JS 文件) / 2 的数据. 在 HTTP/2 视角, 我们直到两个流相互独立并且数据帧也清楚的描述了这一点. 所以我们能够完美的将数据传递给浏览器, 而无需等待 packet2 的到达. 浏览器会看到 stream id 1 的数据帧并直接使用. stream id 2 的流将会被挂起, 等待数据的重传. 这比 TCP 的做法更加高效, 后者将会阻塞 stream id 1/2 的流.

另外一个例子是当 packet1 丢失, 但是却收到 packet 2/3. TCP 将会把 packet 2/3 缓存并等待 packet1 .然而我们从 HTTP/2 的视角来看, 对于 stream id 2 (CSS 文件) 所需的数据已经完全传输完成, 完全无需等待 packet1 的重传. 浏览器本可以完美地解析/处理/使用 CSS 文件, 但卡在等待 JS 文件的重新传输.

总之, TCP 因为无法知道 HTTP/2 中的 stream 相互独立, 这导致了 TCP 层队头阻塞(由于丢失或延迟的数据包)也最终导致 HTTP 队头阻塞!

到此, 你可能会产生疑问: 那有什么意义呢?如果我们仍然有 TCP HOL 阻塞,为什么还要使用 HTTP/2?emmm, 虽然丢包会发生, 但是比较少见, 尤其是在高速有线网络中, 概率大概为 0.01%. 现实中即使是在最差的蜂窝网络上丢包率也很少高于 2%. 这与数据包丢失和抖动(网络中的延迟变化)通常是突发性的伴随出现.包丢失率为 2% 并不意味着每100个包中总是有2个包丢失. 实际上可能更像是在总共500个包中丢失10个连续的包(比如数据包编号 255 到 265), 这是因为数据包丢失通常是由网络路径中路由器内存缓冲区暂时溢出引起的,路由器开始丢弃它们无法存储的数据包. 不过,这里的细节并不重要(但如果您想了解更多). 更加重要的是: TCP HOL 阻塞是真实存在的, 但它对 Web 性能的影响比 HTTP/1.1 HOL 阻塞要小得多, 你几乎可以保证每次都会发生, 并且同时也会出现 TCP HOL 阻塞!

不过, 这仅仅是在HTTP/2 与 HTTP/1.1 都处于单个连接情况下的比较时基本正确. 在之前的内容中提到实际上 HTTP/1.1 会打开多个连接. 这在某种情况下能够缓解 HTTP 以及 TCP 的阻塞.因此, 某些情况下单个 HTTP/2 的速度很难比得上 6 个 HPPT/1.1 连接的速度.这主要是 TCP 的拥塞控制(congestion control) 这不是我们的主要讨论的核心, 具体内容详见彩蛋

总之, HTTP/2 目前部署在浏览器和服务器中, 在大多数的情况下通常与 HTTP/1.1 一样快或者略快. 在我看来, 这部分的原因是因为部分原因是浏览器仍然经常打开多个并行 HTTP/2 连接(要么是因为站点仍然在不同的服务器上共享资源,要么是因为与安全相关副作用), 从而使两者兼得.

然而,也有一些情况 (特别是在数据包丢失率较高的低速网络上), 6个连接的 HTTP/1.1 仍然比一个连接的 HTTP/2 更为出色, 这通常是由于 TCP 级别的队头阻塞问题造成的. 正是这个事实极大地推动了新的 QUIC 传输协议的开发, 以取代 TCP。

HTTP/3(基于 QUIC)的队头阻塞

说了这么多, 终于可以开始讨论新东西了! 但首先, 我们先总结下目前提到的:

  • HTTP/1.1 有队头阻塞, 因为它需要完整的发送响应, 不能对他们进行多路复用
  • HTTP/2 通过引入”帧(frames)”并标识每个资源块所属的”流(stream)”进行解决
  • 而 TCP 并不知道这些单独的 “流”, 只是把所有的东西看作为一个大 “流”
  • 如果其中一个TCP 数据包丢失, 之后的所有数据包需要等待它的重传, 即使是来自不同 “流” 的不相关数据. 因为 TCP 的队头阻塞.

我很确定你已经猜到接下来我们要怎么解决 TCP 的问题. 解决的方式相当简单: 我们"只要"让传输层意识到这些是不同且独立的流! 这样, 如果一个流的数据丢失, 传输本身就知道它不需要阻塞其他流.

虽然这个解决方案在概念上看起来很简单, 但实现起来非常困难. 由于许多问题. 让 流具有感知 (stream-aware) 能力 通过改变 TCP 是不可能的. 可选的方法是以 QUIC 的形式实现全新的传输层协议. 为了使 QUIC 在互联网上实际部署, 它运行在不可靠的 UDP 协议之上. 但重要的是, 这并不意味着 QUIC 不可靠! 在许多方面,QUIC 应该被视为 TCP 2.0. 它包括所有 TCP 功能 (可靠性、拥塞控制、流量控制、排序等) 的最佳版本, 以及更多其他功能.QUIC 还完全集成了 TLS (见图 6) 并且不允许未加密的连接. 因为 QUIC 与 TCP 如此不同, 这也意味着我们不能只在它之上运行 HTTP/2, 这就是创建 HTTP/3 的原因 (我们稍后会详细讨论). 这篇博文已经足够长了, 没有更详细地讨论 QUIC (请参阅其他来源) , 所以我将只关注我们需要理解当前 HOL 阻塞讨论的几个部分. 如图8所示:
图8: 服务器响应 script.js 通过 HTTP/1.1 vs HTTP/2 vs HTTP/3

我们可以看到要让 QUIC 意识到数据所属于不同的流是相当直接的. QUIC 受到 HTTP/2 帧的启发, 并且加上了自己的帧; 在这个例子中展示的是流帧 (STREAM frames). stream id 以前存在于 HTTP/2 中的数据帧中, 现在被下移到传输层的 QUIC 流帧 (STREAM frame) 中. 这同时也说明了为什么在使用 QUIC 时需要新的 HTTP 协议的一个理由: 如果我们直接在 QUIC 上运行 HTTP/2, 那么我们将有两个(可能冲突) “流层”. HTTP/3 从 HTTP 中移除了流的概念(数据帧中没有 stream id), 而是复用了底层 QUIC 中的流进行代替.

注意: 这并不是意味着 QUIC 知道传输的内容是 HTTP, 或者是 JS/CSS 文件; 与 TCP 一样 QUIC 只是通用的/可重用的协议. 只是知道独立的流, 可以被单独的处理, 而并不知道具体的内容.

现在我们知道了 QUIC 的 STREAM帧, 这也很容易看出来它们是如何帮助传输层解决 HOL 阻塞的问题, 如图 9:

TCP 与 QUIC 不同的视角

与 HTTP/2 的数据帧类似, QUIC 的 STREAM帧 对每个流中的传输的数据范围单独追踪. 与 TCP 的不同之处是 TCP 将所有的 stream 的数据杂糅在一起成为一个大块. 当 QUIC 中的 packet2 丢失时, 但是 packet 1/3 到达. 与 TCP 相同, stream 1 中 packet1 可以直接被传输到浏览器中. 然而, 对于 packet3 来说, QUIC 比 TCP 更加智能. 它查看 stream1 的字节范围, 然后确认 stream帧 中是 stream1 后续的字节(450 是 499 的下一位, 所以字节中没有空缺). 这些数据能够立即被浏览器所处理. 对于 stream2 , QUIC 发现了空缺(并没有收到0-299的数据, 这些数据存在于 packet2 中). 这时将会保留 STREAM帧 直到重传的 QUIC packet2 到达. 再次与 TCP 比较, TCP会阻止 packet3 中所有流的数据传输, 包括 stream1 的数据,直到缺失的数据包被重新传输并接收.

在 packet1 丢失但 packet2/3 到达的情况下也会发生类似的情况. QUIC 知道已经收到 stream2 所有的预期数据, 并将其传递给浏览器, 仅保留 stream1. 对于这个例子, QUIC 确实解决了 HOL 阻塞!

这个个方案产生了几个重要的影响: 其中最重要的是一个是QUIC数据可能不再以发生时相同的顺序到达浏览器. 对于 TCP 而言, 如果你发送 packet 1/2/3 它们的内容将按照顺序发送给浏览器(也是导致队头阻塞的原因). 但对 QUIC 来说, 在上面的第二个示例中, packet1 丢失, 浏览器将首先收到 packet2 和 packet3 后半部分中的数据, 之后才是 packet1 (重传)和 packet3 前半部分的数据. 换言之: QUIC 仅保证了单个流中的顺序, 并不保证流之间的顺序.

HTTP/2 中的几种机制高度依赖 TCP 中确定的顺序跨流传输, 这也是需要 HTTP/3 的重要原因之一. 例如: HTTP/2 的优先级机制是通过传输 树形数据结构 进行操作(例如: 将资源5 添加为资源6的子级). 如果这些操作的顺序与发送时不同(如果使用QUIC将可能出现), 客户端与服务端可能会得到不同的优先级状态. 所以, 直接将这些 HTTP/2 的机制建立在 QUIC 上是非常困难的. 因此, 在 HTTP/3 中, 有些机制使用完全不同的方案. 例如, QPACK是HTTP/3中的HPACK版本, 拥有在可能的HOL阻塞和压缩性能之间进行自我选择的权衡. HTTP/2的优先级系统甚至被完全删除, 很可能会被 HTTP/3 的简化版本所取代. 所有这些都是因为, 与TCP不同, QUIC不能完全保证先发送的数据也会先接收到.

因此, 所有在 QUIC 和重新构想的 HTTP 版本上的工作只是为了消除传输层 HOL 阻塞. 我当然希望这是值得的……

QUIC 和 HTTP/3 真的完全消除了队头阻塞?

说点不好听的, 请允许我引用前面段落的内容;

QUIC 保留单个资源流中的顺序

这是合乎逻辑的, 简单来说是: 如果你有一个 JS 文件, 这个文件要被以开发者(或是 webpack)定义的那样进行组装, 否则将不会工作. 这对其他文件也是一样: 将图像以随机顺序重新拼合起来,会导致你阿姨寄来的数字圣诞卡变得异常奇怪(甚至更加奇怪). 这意味着. 即使是在 QUIC 中, 我们仍然还是存在另一种形式的队头阻塞: 如果在单个流中存在字节间隙, 那么这个流中在间隙后的部分将会阻塞至间隙被填补.

这关键的含义是: QUIC 的 队头阻塞只是在多个资源流同时工作的情况下移除了. 因为这样, 其他流上的数据丢失时, 其他流并不会阻塞. 这也是图9说说明的. 然而, 如果在给定时刻只有一个流在活动, 任何数据的丢失都会使流阻塞, 即使是 QUIC. 所以, 实际的问题是: 我们处于多个并发流的情况频繁吗?

正如在 HTTP/2 中解释的那样, 这是可以通过使用适当的资源调度程序/多路复用方法来配置. stream 1/2 可以使用 1122 / 2121/ 1221, 等. 并且浏览器可以使用优先级系统指定它希望服务器遵循的方案(这对于 HTTP/3 仍然适用). 所以浏览器可以说: “我发现这个连接有严重的数据包丢失. 我打算让服务器以 121212 模式, 而不是 111222 向我发送资源. 这样, 如果 资源1 的数据包丢失, 那么 资源2 还能继续. 不过这种模式的问题是, 121212 模式(或者类似的) 对资源加载的性能并不是最优的.

这是另一个复杂的话题,我们现在不深入讨论(在 YouTube 上有一个关于这个的讨论). 通过简单的 JS 和 CSS 示例, 很容易理解这些概念. 浏览器需要完整接收整个 JS 和 CSS 文件, 然后才能进行执行/应用(有些浏览器能够编译/解释部分下载的文件, 但还是需要完整文件才能进行执行). 但因为多路复用, 可能导致所有文件都延迟了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
使用多路复用 (较慢):
---------------------------
Stream1 只有到这里才能使用

12121212121212121212121212121212

Stream2 在这里下载完毕

未使用多路复用/顺序 (Stream 1 更快):
------------------------------------------------------
Stream1 在这里下载完毕,可以更早地使用

11111111111111111122222222222222

Stream2 还是在这里下载完毕

这个话题在不同的情况下有细微差别, 比如一个文件比另外一个文件小得多, 那么多路复用的方法就相对较快. 但是对于一般的情况, 我们可以说顺序的方法更加有效(再次提醒, 可以看看这个)

那么, 现在我们该怎么做呢? 我们现在又两个相互矛盾的优化方案:

  • 从 QUIC 的队头阻塞移除中获得增益:使用多路复用发送资源(12121212)
  • 为了确保浏览器能够尽快(ASAP: as soon as possible)处理核心资源:按顺序发送资源(11112222)

那么, 哪个是正确的呢? 或者至少: 哪个优先与另一个? 遗憾的是我目前没有办法给你一个明确的答案, 因为这也是我正在研究的一个主题. 这之所以困难, 主要原因是丢包模式难以预测.

正如我们上面讨论过的, 包的丢失通常是突发性的和分组的. 这意味着我们上面 12121212 的例子过于简单, 图10 是更加贴近现实的例子. 在这我们假设正在下载两个流(绿色和紫色):

图10: 流复用对使用 QUIC 预防队头阻塞的 HTTP/3 的影响. 每个矩形都是单独的 QUIC 数据包. 红叉表示丢失的数据包

图10 顶部的第一行中, 是关于顺序加载资源时的丢包的情况, 通常来说顺序加载资源性能较好. 这里我们并不能看到 QUIC 对于队头阻塞有什么优化: 在已经丢包的那部分数据后的绿包并不能直接被浏览器处理, 因为它们属于同一个流. 第二个文件的紫包还没有进行发送, 所以浏览器也没法进行处理.

中间那行中, 丢失的包都是属于绿包(偶然的情况!). 这意味着浏览器能够处理收到的所有紫包. 但是就如前面所说的, 如果是 JS 或者是 CSS 文件, 浏览器并不会从中获得过多的收益. 所以, 从 QUIC 消除的队头阻塞中获取了一些好处(紫色文件没有被绿色阻塞), 但是牺牲了整体资源的加载性能(因为使用了多路复用导致文件延迟传输).

最后一行几乎是最糟的情况. 8个丢失的包分布在两个流中. 这意味着两个流都被队头阻塞了: 不是像 TCP 那样的原因, 而是对于每个流自身是需要保持有序的.

注意: 这也是为什么大多数的 QUIC 实现时, 很少将不同流的数据放入同一个数据包中. 因为如果这个包丢失, 将会导致包中所有流的阻塞.

所以, 我们可以得出在 HOL 阻塞预防与资源加载的性能之间存在某种最佳位置(中间一行), 这种权衡可能是值得的. 但丢包的模式很难预测. 不会总是8个数据包, 或者是同样的8个数据包, 如果丢失的包多了一个紫色的包, 这样情况就变成了最后一行的情况……

我想你会同意我的看法, 这听起来很复杂, 甚至可能太复杂了. 所以疑问是它能产生多大的帮助. 正如之前所讨论的, 在许多网络类型上, 数据包丢失通常是相对少见的, 可能(也许?)太罕见了, 以至于 QUIC 的 HOL 阻断功能无法产生太大的影响. 另一方面, 已经有充分的证据表明, 无论你使用的是HTTP/2还是HTTP/3, 逐包复用资源 (图10的底行) 对资源加载性能是相当糟糕的.

所以, 有人说虽然 QUIC 和 HTTP/3 不再受应用层和传输层队头阻塞的影响, 但是这些在现实中并不重要.我不能确定这一点, 因为我们还没有完全实现 QUIC 和 HTTP/3. 所以我也没有相关的衡量方式. 然而, 我以个人的直觉 (我个人的几个早期实验支持的) 说, QUIC 消除的队头阻塞可能实际上对于 Web 的性能没有太大的帮助, 因为理想情况下, 您不希望为了资源加载性能而多路复用许多流. 而如果希望它能够发挥作用, 那么你必须要巧妙的根据连接类型选择合适的多路复用方法, 因为你肯定不希望再数据包丢失非常低的快速网络上进行大量的多路复用(因为本身并不会产生多少阻塞). 就个人而言, 我认为不会发生.

注意: 在最后, 你可能会觉得我的文章有些不一致. 一开始, 我说 HTTP/1.1 的问题在于不能多路复用. 而最后却说多路复用可能在现实中并不重要. 为了说明这个矛盾, 我在结尾添加了一个彩蛋

总结与结论

这篇文章中, 队头阻塞贯穿全文. 我们首先讨论了为什么 HTP/1.1 会受到应用层队头阻塞的影响. 这主要是因为 HTP/1.1 没有识别单个资源块的方式. HTTP/2 使用帧来标记这些块并启用多路复用. 这解决了 HTTP/1.1 的问题, 但遗憾的是 HTTP/2 仍然受到底层 TCP 的限制. 由于 TCP 将 HTTP/2 数据抽象为一个单一的/有序的/但是不透明的流, 因此如果数据包在网络上丢失或是严重延迟, 它将出现的队头阻塞. QUIC 通过将 HTTP/2 的一些概念引入传输层来解决这个问题. 这反过来会产生严重的影响. 因为跨流的数据不再完全有序. 这最终导致需要全新运行在 QUIC 上的 HTTP 版本3(HTTP/2 运行在 TCP 之上).

我们需要所有这些上下文来批判性地思考 QUIC(以及 HTTP/3)中的队头阻塞移除在现实中对 Web 性能的实际帮助有多大. 我们观察到它可能只会对有大量丢包的网络产生很大的影响. 我们还讨论了为什么即使是这样, 可能仍然需要多路复用资源. 并且丢包对多路复用的影响全凭运气(可能会有优势或者完全没效果). 我们看到了为什么这样做实际上弊大于利, 因为资源多路复用通常不是 Web 性能的最佳方案. 我们得出结论, 尽管现在还为时过早, 但 QUIC 和HTTP/3 的 HOL 阻塞移除在大多数情况下可能对 Web 性能并没有太大的帮助.

那么…这会给我们 Web 性能爱好者带来什么影响呢? 忽略 QUIC 和 HTTP/3 并坚持使用 HTTP/2 + TCP? 我当然不希望! 我仍然认为 HTTP/3 总体上比 HTTP/2 要快, 因为 QUIC 还包括其他性能改进. 例如: 它比 TCP 在网络上的开销更小, 在拥塞控制方面更加灵活, 且最重要的是, 它具有 0-RTT 连接建立特性. 我觉得 0-RTT 能提供最多的 Web 性能提升, 尽管存在很多挑战. 以后我会写一篇关于 0-RTT 的文章, 但是如果你迫不及待想要知道放大攻击预防、重放攻击、初始拥塞窗口大小等的信息,请看我的另一篇 YouTube 讲座或阅读我最近的论文.

如果你喜欢这些内容, 并且想要了解更多, 请在 Twitter 上关注我 @programmingart.

在线文档版本的文章能在 github 找到. 如果您有关于如何改进它的建议, 请让我知道!

感谢您的阅读!

彩蛋

HTTP/1.1 管道

HTTP/1.1 包含了一个名为 管道(pipeline) 的特性, 在我看来这是经常被误解的. 我看过很多文章, 甚至书籍中都有人声称 HTTP/1.1 管道解决了队头阻塞的问题. 我甚至见过一些人说管道和正确的多路复用是一样的. 这两种说法都是错误的.

我发现用类似彩蛋图1中的插图进行解释 HTTP/1.1 管道是最简单的:

彩蛋图1: HTTP/1.1 管道

没有管道(上图中左侧图片), 浏览器必须等待第一个资源发送完成之后才能进行发送第二个资源. 这会为每个请求增加一个往返时间(Round-Trip-Time (RTT)) 的延迟,这对 Web 性能不利

使用管道技术之后(上图中间图片) 浏览器不必等待任何响应数据,现在可以连续发送请求. 通过这种方式, 我们可以在连接时节省一些 RTT 使得加载的更快速些. 请回顾图2:实际上已经使用了管道, 因为服务器在 TCP 数据包2中打包 script.js 以及 style.css 的响应数据. 这只有在服务器同时接收到这两个请求时才是可能的.

至关重要的是, 这种管道只适用于来自浏览器的请求. 正如[HTTP/1.1 规范][h1spec]所说:

服务器必须按照接收请求的顺序发送对这些[管道化]请求的响应.

因此, 响应块的多路复用(彩蛋图1右侧)在HTTP/1.1 管道中仍然是不可能的. 换句话说: 管道解决了请求的队头阻塞,而不是响应的队头阻塞. 令人沮丧的是, 响应队头阻塞是导致 Web 性能问题最多的原因.

大多数的浏览器并没有在现实中使用 HTTP/1.1 管道, 因为这会使队头阻塞在多个并行 TCP 连接变得更不可预测. 例如: 我们通过两个 TCP 从服务器请求三个文件 A(大), B/C (小). A 和 B 在不同的连接上被请求. 现在浏览器该怎么选择 C 使用哪个 TCP 呢? 就如之前所说的, 浏览器并不知道 A 还是 B 是最慢/最大的资源.

如果浏览器猜对了是 B , 它就能在传输 A 所需的时间内同时下载 B 和 C , 从而获得很好的加速效果. 但是如果猜测错误, B 的连接将长时间处于空闲的状态, 而 C 则被阻塞在 A 之后. 这是因为 HTTP/1.1 没有提供一种在请求发送后 “中止” 的方法(HTTP/2 和 HTTP/3) 允许这样做. 因此, 浏览器不能简单的通过 B 的连接请求 C , 这样将会请求两次 C.

为了解决这一切,现代浏览器不使用管道,甚至会主动延迟对某些已发现资源(例如图像)的请求一段时间,以查看是否找到更重要的文件(例如 JS 和 CSS),以确保高优先级资源不会被阻塞.

很明显,HTTP/1.1 管道的失败是 HTTP/2 使用截然不同方法的另一个动机. 然而,由于 HTTP/2 的优先级系统指导多路复用在现实中常常无法执行,一些浏览器甚至采取了延迟 HTTP/2 资源请求的方式来获得最佳性能.

TLS 队头阻塞

之前我们提到, TLS 为应用层协议(如 HTTP) 提供加密 (和其他功能). 它通过将 HTTP 获取的数据包装到 TLS 中, TLS 记录在概念上类似于 HTTP/2 的帧或 TCP 数据包.例如, 它们在开头包含一些元数据以指示记录的长度. 该记录及其 HTTP 内容随后被加密并传递给 TCP 进行传输.

由于加密在 CPU 使用方面可能是一项昂贵的操作,因此一次加密大量数据通常是个好主意,因为这通常效率更高. 实际上,TLS 可以以最大 16KB 的块对资源进行加密,这足以填充大约 11 个典型的 TCP 数据包(给或取)

关键是TLS 只能对整个记录进行解密, 这就是为什么会出现某种形式的 TLS HOL 阻塞. 假设 TLS 记录分散在 11 个 TCP 包上,最后一个 TCP 包丢失. 由于 TLS 记录是不完整的,它不能被解密,因此被卡在等待最后一个 TCP 包的重传. 值得注意的是, 在这种情况下并没有TCP 队头阻塞: 没有数据需要在第 11 个 TCP 包后阻塞并需要重新传输. 换句话说如果此处使用的是 HTTP 而非 HTTPS. 那么之前的数据包可能已经移入浏览器进行处理了. 然而, 因为我们需要完整的 11 个包才能进行解密, 所以就有了一种新形式的队头阻塞.

虽然这是一个现实中并不常见的情况, 但在设计 QUIC 时考虑到了. 因为目标是彻底消除所有形式的队头阻塞(至少是尽可能的), 即使是这种不常见的情况. 同时这也是为什么 QUIC 整合了 TLS. 总是以包为基础进行加密, 而不是直接使用 TLS 记录. 就如之前的描述, 与使用更大的块相比, 这效率更低, 需要跟多的 CPU . 这也是为什么 QUIC 在当前的实现中仍然比 TCP 慢的主要原因之一

传输拥塞控制

传输层如 TCP 和 QUIC 都有称之为拥塞控制(Congestion Control)的机制. 拥塞控制的主要目的是为了防止同时过多的数据导致网络过载. 如果出现这种情况, 路由器的缓冲区将会溢出, 将会导致出现丢弃不能装入的数据包. 所以通常开始传输时仅发送较小的数据(通常是 14 KB 的数据), 看看是否能被接收. 如果数据可达, 接收方将向发送方发送确认, 发送方收到确认后会在下一个 RTT 翻倍发送速率. 直到观察到数据包丢失事件(这意味着网络过载了一些, 需要减少一些). 这就是 TCP 连接“探测”其可用带宽的方式.

注意: 以上的描述只是拥塞控制中的一种. 目前其他方式逐渐流行, 主要是以 BBR 算法 . BBR 不是直接查看数据包丢失,而是大量考虑 RTT 波动以确定网络是否过载,这意味着它通常通过探测带宽本身导致更少的数据包丢失.

关键是: 拥塞堵塞的机制对于每个 TCP (和 QUIC) 连接都是独立的! 这反过来也会影响到 HTTP 层的 Web 性能. 首先, 这意味着 HTTP/2 单个连接仅发送 14KB 的数据. 然而, HTTP/1.1 的6个连接每个都能发送 14 KB, 这将总共发送大约 84KB 的数据! 随着时间的推移,这会变得更加复杂,因为每个 HTTP/1.1 连接都会使用每个 RTT 将其数据加倍. 其次, 只有在丢包的情况下, 连接才会降低发送频率. 对于 HTTP/2 的单连接, 即使是单个数据包丢失都将导致速度减慢(除了导致 HOL 阻塞). 然而, 对于 HTTP/1.1, 仅一个连接上的单个数据包丢失, 不会影响其他5个连接的速度.

这说明一件事: HTTP/2 多路复用与HTTP/1.1的同时下载是不一样的(其他人也常说). 单个 HTTP/2 连接的带宽被用于不同的文件(分布/共享)传输, 但是 chunk 数据还是按照顺序进行发送, 与 HTTP/1.1 真正的并行发送不同.

现在, 你可能疑惑: 那么 HTTP/2 怎么可能比 HTTP/1.1 更快? 这是一个好问题, 我也已经断断续续思考了这个问题很久. 一个显而易见的例子就是当文件的数量大于6个时, 当年HTTP/2是这样宣传的: 通过将图像分割成小方块并比较 HTTP/1.1 和 HTTP/2 加载. 这主要展示了 HTTP/2 移除了 HOL 阻塞的效果. 然而对于普通的网站, 事情并不是那么简单. 取决于网站的资源数量, 资源的大小, 使用的优先级/多路复用方案, 与服务器之间的 RTT, 实际上有多少数据包损失且什么时候发生, 在这条链路上同时存在多少传输, 所使用的拥塞控制协议, 等. 一个例子就是当HTTP/1.1 处于带宽限制的情况下时: 6个 HTTP/1.1连接将会各自增加它们的发送速率, 并且导致网络很快过载, 迅速减少并尝试找到带宽利用的平衡点.(在 HTTP/2 之前,人们认为 HTTP/1.1 的并行连接可能是互联网丢包的主要原因).相反, 单个 HTTP/2 连接较为缓慢,但在丢包事件后恢复得更快,并能更快地找到其最佳带宽. 可以在这张图片(看起来有些复杂)中找到另一个更详细的示例,其中带有注释的拥塞窗口,其中 HTTP/2 更快.

QUIC 和 HTTP/3 会看到类似HTTP/2的挑战. HTTP/3 使用单个 QUIC 连接, 你可能会说 QUIC 连接在概念上有点像多个 TCP 连接, 因为丢失检测是在每个流的基础上完成的, 流可以看待成单独的 TCP 连接. 然而, QUIC的拥塞控制仍然是连接级别的, 并不是流级别. 虽然流在逻辑层面是独立的, 但是还是受限于 QUIC 的单个连接的拥塞控制, 如果流中的包丢失, 还是会导致速度减慢. 换句话说:单一 HTTP/3+QUIC 连接速度仍然不会像 6 个 HTTP/1.1 连接那样快速增长,就想单一连接上的 HTTP/2+TCP 增长速度并不快.

多路复用是否重要?

就如前文描述和这个PPT深入解释的那样.通常建议以顺序的方式而不是多路复用的方式发送大多数网络资源. 换句话说, 如果存在两个文件, 尽量使用 11112222 而不是 12121212. 对于需要完全接收后才能使用的资源亦是如此, 例如: JS/CSS/字体.

如果处于这种情况下, 我们会疑惑为什么需要多路复用? 并且由此扩展: 这是 HTTP/2 甚至 HTTP/3的主要特点之一, 而 HTTP/1.1 没有. 首先, 一些可以增量处理/渲染的文件确实从多路复用中获益. 例如逐步加载的图形. 其次, 我们在之前讨论过, 如果加载的文件中有一个比其他文件小将会比较有用, 因为更早的下载, 不会滞后其他文件太久. 再者, 多路复用允许改变响应顺序以获得最高优先级的响应.

一个现实的典型例子是使用 CDN 缓存. 假设浏览器从 CDN 请求两个文件. 其中 资源1 并没有缓存在 CDN 中, 需要从服务器获取. 资源2 存在就能直接向浏览器传输.

在一个连接上使用 HTTP/1.1, 由于HOL 阻塞. 我们必须等待 资源1 完全发送后才能进行发送 资源2. 直观的感受就是 11112222, 但有较长的前期等待时间. 使用 HTTP/2 就能对 资源2 直接响应, 利用 CDN 与 源站之间的 “交流时间” 对连接的拥塞控制进行 “预热”. 注意, 如果 资源1 的响应在 资源2 响应完成之前到达, 我们也可以简单的将 资源1 的数据放入响应流中. 直观的感受是 22111122, 等待的时间要短得多. 这甚至能够在连接的一开始就使用 服务器推送 或者 103 early hints.

因此, 虽然像 12121212 这样完全的 “轮询” 的多路复用并不是你想要的 Web 性能提升, 但是总体上是有用的特性.

感谢

我要感谢所有提前审阅过这篇文章的人,包括:Andy Davies, Dirkjan Ochtman, Paul Reilly, Alexander Yu, Lucas Pardue, Joris Herbots, 和 neko-suki.

所有图片都是用 https://www.diagrams.net 制作的. 使用的字体是 “Myriad Pro Condensed”.

扩展阅读

Author: Sean
Link: https://blog.whileaway.io/posts/226289c/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.