📘 第九章 基于HTTP的功能追加协议
📄 一句话概括

HTTP 的瓶颈不在带宽,在等待:一条连接上一次只有一个请求在途,首部每次重发还不压缩。 管线化本来想救,结果只是把排队挪了个位置。

📝 本节和前面几节的关系

上一节说了 HTTP 做不了什么,这一节把「为什么慢」拆成三条,逐条量。

一、一条连接,一次一个

2.7.1 的持久连接省掉了每次建连的开销,但它没允许并行——同一条连接上的请求仍然是一个发出去、等回来、 再发下一个。 浏览器只好靠「多开几条连接」来凑并行,但每条连接都要单独握手、单独慢启动, 而且服务端一般限制每域名 6 条左右。这个数字是十多年前定的,一直没怎么变。 于是就有了一个奇怪的局面:带宽早就涨了几十倍,可页面的加载时间没有同比例变短, 因为卡住的地方不是管子粗细,是队伍太长。

二、首部是纯文本,还得重复付

HTTP/1.1 的首部是一行行明文,一点不压缩。 Demo 里一条普通浏览器请求的首部 232 字节,正文只有 2 字节,首部是正文的 116 倍。 这本身不算问题——问题是同一个站点的几十个请求,发出去的首部八九成是一样的:同一个 Host、 同一个 User-Agent、同一串 Accept。 一百个资源,光首部就是二十多 KB,而真正的图片可能才几百 KB。 这些字节每一份都要占用带宽、都要等一个往返,纯属重复。

三、管线化只是把排队挪了个位置

2.7.2 讲过管线化:不等前一个响应就先发后一个。 听上去能救,但它有个硬规定——响应必须按请求的顺序回来。 Demo 里六个请求一次全发出去,第一个故意慢 200 毫秒,其余各 50 毫秒。 完成时刻依次是 201.0、251.7、302.5、353.3、404.3、455.2 毫秒——一条漂亮的等差数列, 说明它们真的是一个接一个在排队。 第 2 个请求自己只要 50 毫秒,却等到 252 毫秒才回来。 这就是队头阻塞:前面那个慢的没回来,后面的就算已经做好了也不许先走。 同样的六个请求,改成六条连接各走各的,203.4 毫秒全部完成,比排队少付 251.8 毫秒。 所以真凶不是「连接少」,是「一条连接里只能排一队」。

四、动手 Demo:六个请求,一条队伍,一条等差数列

本机服务端,第一个路径故意慢 200 毫秒,其余各 50 毫秒。 先把六个请求一次全发到同一条连接上(流水线),量每个响应回来的时刻; 再用六条连接各发一个,量总耗时。

# 一条连接:六个请求一次全发
for p in PATHS:
    s.sendall(req(p))
for p in PATHS:
    recv_response(s)          # 只能按顺序收
    arrive.append(now())

实跑输出:

--------------------------------------------------------------------
队头阻塞:一个慢请求把后面全堵住
--------------------------------------------------------------------
  六个请求一次全发出去(流水线),第一个故意慢 200 毫秒,其余各 50 毫秒。

  请求     路径         完成时刻(毫秒)
  1      /slow         200.6
  2      /q2           251.6
  3      /q3           302.5
  4      /q4           353.5
  5      /q5           404.4
  6      /q6           455.2

  一条连接收完六个           455.2 毫秒
  六条连接收完六个           203.2 毫秒
  排队多付的时间             252.0 毫秒

  注意第 2 个请求:它自己只要 50 毫秒,却等到 252 毫秒才回来。
  它在慢请求后面,只能干等——这就是队头阻塞(head-of-line blocking)。

六个完成时刻 201.0/251.7/302.5/353.3/404.3/455.2, 间隔稳定在 50 毫秒左右——后面的请求在等前面的,不是在等自己。 一条连接收完六个 455.2 毫秒,六条连接 203.4 毫秒,排队多付 251.8 毫秒。 注意六条连接也没快到 50 毫秒,因为最慢那个本身就是 200 毫秒,这是下限。 第 2 个请求是最冤的:它 50 毫秒就做完了,却要等到 252 毫秒才被送出去。 它的时间花在「等别人」,不在「自己算」。 到这里三条瓶颈就齐了:一条连接只能排一队、首部不压缩、排了队还不能插队。 后面三个协议各治其中一两条。

30 秒实验

  1. 把慢请求挪到最后一位,看六个完成时刻会变成什么样。
  2. 把六个路径都改成 50 毫秒,比较一条连接和六条连接的差距还剩多少。
  3. 想一想:为什么服务端不愿意让浏览器无限多开连接?

常见误区

  1. 「持久连接就等于并行了」——省的是建连,不是排队。
  2. 「管线化解决了队头阻塞」——它只允许提前发请求,响应仍须按序回。
  3. 「多开连接就能解决」——浏览器每域名只开几条,而且服务端也扛不住。
自测三问(点开对答案) 1. HTTP/1.1 的三个瓶颈分别是什么? —— 一条连接一次一个请求、首部不压缩且重复、响应必须按序回导致队头阻塞。 2. 为什么第 2 个请求要等 252 毫秒? —— 它在慢请求后面,响应必须按序返回,前面的没走完它不能走。 3. 管线化和多路复用的根本区别是什么? —— 管线化只是提前发请求,响应仍按序;多路复用给每个请求编号,响应可以乱序交错。

瓶颈量清楚了。谷歌的答案是:在这条连接里塞进很多条「流」,各走各的。

相关笔记