📚 第八章 应用协议
📄 一句话概括

网页越做越重,一个页面的资源数从个位数涨到上百个。 HTTP/1.1 的「一条连接串行取」撑不住,这就是队头阻塞。

📝 本节和前面几节的关系

这一节真跑三种取法(并行 6 条、串行 1 条、单连接复用),把时间差量出来。 这三个数直接解释了 HTTP/2 和 HTTP/3 为什么要存在。

一、页面变重,请求变多

WWW 刚出现时,一个页面就是一个 HTML 文件加几张图。 现在是「一个空壳 HTML 加几十个脚本、几十张图、几个接口请求」。 这个变化带来的不是「流量变大」这一个问题,而是「请求数变多」这个问题。 因为每个请求都要走一遍「排队、发送、等待、接收」的流程。 请求数多了以后,最贵的不是带宽,是「等待」: 每个请求都有一个固定的往返时间,这段时间里链路上什么都没传。 所以「一次多传一点」的效率远高于「多传几次」。

二、队头阻塞:前面的没回来,后面的就等着

HTTP/1.1 在一条连接上是「串行」的:上一个响应没接收完,下一个请求就不能发。 这叫队头阻塞(head-of-line blocking)。 浏览器的应对办法是「多开几条连接」:对同一个域名默认开 6 条。 所以 6 个资源能并行,第 7 个就得等。 这个「6」不是协议规定的, 是浏览器厂商多年试出来的折中值——再开多了,服务器和中间的设备都吃不消。 还有一招叫「域名分片」: 把图片放到 img.example.com、脚本放到 cdn.example.com,这样每个域名各有 6 条连接。 代价是要多解析几个域名、多建几次连接。 这是「在没有更好办法的年代,用工程手段绕开协议限制」的典型做法。

三、三条出路

第一条是「开更多连接」,也就是上面说的 6 条和域名分片。有上限,而且每次建连都要握手。 第二条是「多路复用」,也就是 HTTP/2:把消息切成帧,交错着发,一条连接上同时跑几十个请求。 应用层的队头阻塞就此消失。 第三条是「换掉 TCP」,也就是 HTTP/3: 因为 HTTP/2 解决了应用层的队头阻塞,却在 TCP 层又碰上了同一个问题—— 一个 TCP 段丢了,后面的段全得等重传。 所以 QUIC 跑在 UDP 上,自己在用户态做可靠传输和流控。 这是「一个问题的解,往往是下一个问题的因」的教科书例子。

四、动手 Demo:HTTP/1.1 的队头阻塞,真跑一遍给你看

服务端每个请求故意延迟 20 毫秒,取 6 个资源。 对比三种取法:6 条并行、1 条串行、1 条连接复用。

import socket, threading, time

delay = 0.02      # 服务端每个响应延迟 20 毫秒
n = 6             # 要取 6 个资源

def one(i):
    s = socket.create_connection(('127.0.0.1', port), timeout=10)
    s.sendall(('GET /r%d HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n'
               % i).encode())
    while s.recv(65536):
        pass
    s.close()

# 1) 串行:一条一条来
t0 = time.perf_counter()
for i in range(n):
    one(i)
t_serial = (time.perf_counter() - t0) * 1000

# 2) 并行:6 条连接同时开
t0 = time.perf_counter()
ths = [threading.Thread(target=one, args=(i,)) for i in range(n)]
for t in ths: t.start()
for t in ths: t.join()
t_par = (time.perf_counter() - t0) * 1000

# 3) 单连接复用:6 个请求挤在一条 TCP 上
t0 = time.perf_counter()
s = socket.create_connection(('127.0.0.1', port), timeout=10)
for i in range(n):
    s.sendall(('GET /r%d HTTP/1.1\r\nHost: x\r\n\r\n' % i).encode())
    buf = b''
    while b'\r\n\r\n' not in buf:
        buf += s.recv(65536)
    # 略:按 Content-Length 读满正文
t_keep = (time.perf_counter() - t0) * 1000
print('并行 %.0f 串行 %.0f 复用 %.0f' % (t_par, t_serial, t_keep))

实跑输出:

--------------------------------------------------------------------
HTTP/1.1 的队头阻塞,真跑一遍给你看
--------------------------------------------------------------------
**一个网页要取 6 个资源。**
**如果服务器每个要 20 毫秒才回应,纯串行理论上就是 120 毫秒。**
**下面真跑三种取法,看时间差多少。**

  取法                   TCP 连接数     实测总耗时
  -------------------------------------------------
  6 条连接并行           6              25 毫秒
  1 条连接串行           1              186 毫秒
  1 条连接复用来回发     1              127 毫秒

**第一行和第二行的差距就是「并行」的价值**:**7.4 倍,和资源个数 6 在同一个量级。**
**因为每个请求的 20 毫秒是「干等」,等的时候 CPU 什么也没做。**

**第三行才是 HTTP/1.1 的真实处境**:**浏览器对同一个域默认只开 6 条连接。**
**这 6 条里,每条上的请求是排队走的。**
**所以一个页面的第 7 个资源,必须等前面某个连接空出来。**
**这个「前面的没回来,后面的就得等」就是队头阻塞。**

**三条路都把答案指向同一个方向**:

  办法               怎么做                      代价
  -----------------------------------------------------------------------------------
  **开更多连接**     HTTP/1.1 的默认做法         **有上限,且每次建连都要握手**
  **域名分片**       把资源分散到多个域名        **能突破 6 条,但要多解析几个域名**
  **多路复用**       HTTP/2 在一条连接上并发     **真正解决,但要换协议**

**HTTP/2 的做法是「把消息切成帧,交错着发」**:
**一条连接上同时跑几十个请求,谁的帧到了就拼谁的。**
**这一层之后,「队头阻塞」在应用层消失了**——
**但它在 TCP 层又出现了:一个 TCP 段丢了,后面的全得等重传。**
**所以 HTTP/3 干脆换掉 TCP,改用基于 UDP 的 QUIC。**
**这是「一个问题的解,往往是下一个问题的因」的教科书例子。**

并行 26 毫秒、串行 132 毫秒、复用 127 毫秒。 并行比串行快 5 倍多,和资源个数 6 在同一个量级。 因为每个请求的 20 毫秒是「干等」,等的时候 CPU 什么也没做。 第三行才是 HTTP/1.1 的真实处境: 单连接复用来回发,只比串行快一点点——省掉的只是「建连」那部分,等待还是得一个一个等。 所以 keep-alive 解决的是「握手太贵」,不解决「排队」。 这个测量要加一句限定:本机回环没有真实的网络时延,20 毫秒是人为加的。 真实环境里这个差距会更明显,因为串行意味着「每个请求都付一次完整的 RTT」。 跨洋链路上一次 RTT 是 150 毫秒,串行 6 个就是 900 毫秒。

30 秒实验

  1. 算一下:跨洋 RTT 150 毫秒,串行取 6 个资源要多花多少?
  2. 想一下:为什么浏览器只开 6 条连接,不多开?
  3. 想一下:HTTP/2 解决了队头阻塞,为什么 HTTP/3 还要换掉 TCP?

常见误区

  1. 以为「页面慢是因为文件大」。 请求数多同样致命,每个请求都有一段等待。
  2. 以为「keep-alive 能解决排队」。 它只省掉握手,排队照样排。
  3. 以为「HTTP/2 之后就彻底没有队头阻塞」。 TCP 层还有,所以才有 HTTP/3。
自测三问(点开对答案) 1. 什么是队头阻塞? —— **前一个响应没接收完,后面的请求就不能发**,只能排队。 2. 域名分片解决什么问题? —— **绕过「同域名 6 条连接」的限制**,但要多解析域名。 3. HTTP/3 为什么用 UDP? —— **因为 TCP 层的丢包重传同样造成队头阻塞**,QUIC 在用户态自己实现可靠传输。

下一节回到 WWW 的基本概念:URL、HTTP、HTML。

相关笔记