Fork me on GitHub
Google

RFC7540 笔记(四)——More on Stream States

上一篇中我犯了个严重错误,well 实际上是两个。出错的部分已经用删除线划掉。之所以不直接删掉,是考虑到曾经的错误理解也是有价值的,提醒自己不要两次踏进同一条河。

这次仍然讲 5.1 节的状态图,因为这个图是在是太太太重要了。会涵盖如下内容:

  1. 到底什么是 stream state?
  2. half-closed vs reserved
  3. server push stream 的关闭过程,暨对之前错误的修正

到底什么是 stream state?

前不久在 SO 写过一个零赞答案,为什么没人赞呢,因为除了包括 implementer 在内的少数人外,几乎没人理解 HTTP/2 stream state 的本质。我非常确信的是,那个提问者到现在也不懂,因为他采纳了一个完全没有触及本质的答案。那个问题其实是非常好的切入点,我姑且描述一下意思。

他问,既然 reserved(local) 状态只会在 server 端出现,而 reserved(remote) 只会在 client 端出现,那么是不是意味着在写 HTTP/2 server 和 client 的时候,分别只需要实现部分状态就行了?

下面是我的答案:

It's true that client will never enter the reserved(local) state, same for server with reserved(remote)However, in order to keep stream states synchronized, the simplest(maybe only) way is to also keep peer's state. In fact, stream state is not about one endpoint, but both endpoints. For any moment in a stream's life cycle, the stream's state is comprised of the client's state and the server's state. Sometimes they are the same(idle, open, closed), sometimes they're not identical(reserved, half-closed).

With that said, I can understand why you say that, but you really shouldn't think that way. There's no client stream state and server stream state, just one stream state with copies saved on both sides.

It's kind of like a coin, with two sides having different images. No matter which direction you see from, the coin doesn't change, but you name the side you see "front side/local state", and the one you don't see "back side/remote state".

The "remote/local" wording in the state machine diagram is very subtle, and worth thinking over.

就不翻译了,大家应该能看懂。之所以有人误认为 stream 状态被分为了 client 状态和 server 状态,主要是因为 RFC 在说明 remote 和 local 区别的同时并没有强调它们是一体的(这种不明确的描述在 RFC 7540 中到处都是,之后我们还会看到)。 此外,5.1 节的一段话也容易引起误解:

Both endpoints have a subjective view of the state of a stream that could be different when frames are in transit. Endpoints do not coordinate the creation of streams; they are created unilaterally by either endpoint. The negative consequences of a mismatch in states are limited to the "closed" state after sending RST_STREAM, where frames might be received for some time after closing.

意思是,client 和 server 保存的 stream state 有可能不同步,因为 stream 可以被任意 endpoint 单方面创建而不需要通知 peer。没错,不同步是普遍存在的,因为 frame 传输需要时间,但这并不意味着两边的状态不需要同步,实际上,只要在某个 stream 上有 frame 传输,并且 frame 成功到达 peer,那么状态总是可以同步的。“subjective”很容易让人以为两边分别保存自己的状态而不用管对方,这就大错特错了。为了方便记忆,我把关键点总结如下:

One state, two views, kept by two endpoints. Should sync, could sync, often mismatch.

half-closed vs reserved

这两个状态是最难理解的,并且它们的关系非常微妙。

先说 half-closed。在笔记一里我写到:

half-closed 表示一边想 close,但另一边还没有同意的状态。

如果某个 endpoint 处于 half-closed(local),代表该 endpoint 想 close 但 peer 还未同意。这个时候,该 endpoint 不能再发送 HEADERS 和 DATA 这种真正携带了数据的 frame,否则就跟说好的不一样了(能发送的只有 WINDOW_UPDATE, PRIORITY, RST_STREAM)。由于 peer 还没有同意 close,所以有可能接收到 peer 发送的任何种类的 frame。如果接到 END_STREAM,说明 peer 也同意 close,于是 stream 进入 closed 状态。

如果说 half-closed(local) 是硬币的一面,那么 half-closed(remote) 就是硬币的另一面,代表 peer 想 close 但该 endpoint 还未同意。相对地,此时可以发送任何种类的 frame,但是不应该接收到除 WINDOW_UPDATE, PRIORITY, RST_STREAM 之外的 frame。

half-closed 的设计看起来挺不错的,两边各发送一个 END_STREAM 来确认关闭。真的如此吗?如果我们只考虑 request-response 工作流(参见笔记二中的绿线),是的。但在 server push 工作流里(参见笔记三中的紫线),half-closed 就像一块丑陋的补丁一样。不夸张地说,这是我觉得 HTTP/2 设计中最糟糕的一点

让我们仔细看一下这张图。首先,在 PUSH_PROMISE 流程中,client 只能进入 half-closed(local) 状态,不会进入 half-closed(remote),而图上并没有说明这一点。更让人费解的是 reserved -> half-cloased 的状态转移,居然是靠 server 发送 HEADERS???这完全不符合 half-closed 的语义啊,凭什么 server 发送 HEADERS 就代表 client 要 close??

然后你会发现一件更奇怪的事:reservedhalf-closed 中,总有一个状态是多余的。从 server push 的角度看,既然只有 server 发送数据,close stream 也只需要 server 同意即可,那么设计成 reserved(local) -- send END_STREAM --> closed 岂不是正好?为什么非要画蛇添足强行走到 half-closed?从 request-response 的角度看,既然 PP 可以对应到 client 发送的 HEADERS(request),那么发送完 PP 为什么不能像发送完 request 一样直接进入 half-closed 状态呢?为了讲清楚这个问题,我们需要先了解 5.1.2 节 中的 Stream Concurrency。

简单说一下 Stream Concurrency。我们知道,HTTP/2 之所以快,一是头部压缩,二是 stream multiplexing,换句话说就是许多 stream 同时传输。但是资源有限,不可能允许无限多个 stream 同时传输。解决方法也很简单,就是设置一个上限。在 HTTP/2 中,这个上限叫做 SETTINGS_MAX_CONCURRENT_STREAMS,代表活跃 stream 的最大数量。需要注意的是,每个 endpoint 可以有不同的 SETTINGS_MAX_CONCURRENT_STREAMS,分别限制由自己初始化的 stream。

所以 SETTINGS_MAX_CONCURRENT_STREAMS 和 stream 状态又有什么关系呢?关系大了,因为并不是所有状态的 stream 都计入活跃 stream,只有处于 open 和 half-closed 状态的 stream 才算。相信看到这里你已经晕了,所以我来举个例子。

上图中,client 初始化了 3 个 stream:stream 1,stream 3,stream 5(正常 request-response);server 初始化了 2 个 stream(用于 server push),分别是 stream2 和 stream 4。此时 stream 1 因为已经关闭,client 这边的活跃 stream 是 stream3 和 stream 5。stream 4 处于 reserved 状态不计入,所以 server 这边活跃 stream 只有 stream 2。

好,现在我们了解了 stream concurrency,终于可以回到正题,说明为什么需要有 reserved 和 half-closed 两个状态。我原先也不懂,直到最近看到 mailing list 里的讨论。

https://lists.w3.org/Archives/Public/ietf-http-wg/2016JulSep/0601.html

在这封邮件里,RFC 的作者 Martin 写到:(意译)“reserved 和 half-closed 的最大区别,在于前者不计入 endpoint 当前维护的活跃 stream,而后者计入。如果我们也限制 reserved stream 的数量,就有可能导致 server 不能及时发出 PUSH_PROMISE……RFC 里的很多设计都基于一个看法,即发送 HEADERS 几乎是没有成本的。”RFC 之所以难读,一个原因是前后内容相互引用,并且 Martin 又是一个热衷于这么干的人……我来解读一下。Martin 的意思是,server push 过程中应该尽快让 server 把 PUSH_PROMISE 发出去而不受 SETTINGS_MAX_CONCURRENT_STREAMS 的限制,因为发送 PUSH_PROMISE 几乎是零成本的(在他的表述里把 PUSH_PROMISR 算作了 HEADERS),并且好处很大(client 接到 PP 才不会发后续请求)。这时候就体现出 reserved 状态的作用了,因为处于 reserved stream 是不计入活跃 stream 的,所以即使已经发出很多 PP 并带来一堆 reserved stream,也不会导致后续 PP 因为活跃 stream 达到上限而阻塞。那么 half-closed 呢?你会发现,因为 server 发送 HEADERS 后立刻进入 half-closed(local) 状态,所以 server push 的 DATA 实际上是在 half-closed 状态下发送的。发送 DATA 是有代价的,正好被 SETTINGS_MAX_CONCURRENT_STREAMS 限制住。总结一下:需要 reservedhalf-closed 两个状态的原因是,既要让 PP 尽快被发送,又需要控制 DATA 的发送速度。

看了 Martin 的解释,不得不说这个设计还是有道理的。但不限制 PP 却留下了某种隐患:server 不论发送多少 PP 都是合法的。最近 Scott Mitchell(Netty 排第三的 contributor)对这一点提出了质疑,我还回邮件反驳了一下。然后之前邮件里问 Martin 问题的哥们说,我在实现 Golang 的 server push 功能的时候就是把 reservedhalf-closed 合并成一个状态的,参见:

https://github.com/golang/net/commit/c46f265c325130a7a6c7b27db8c6fe14b64f1a68

最后讨论不了了之,Scott 还是坚持认为应该对 PP 做某种限制。究竟哪种更好?谁知道呢。

Server push stream 的关闭过程,暨对之前错误的修正

之前我以为,到达 half-closed 状态总是需要一个 END_STREAM,其实 server push 不需要。这是我犯的第一个错误。half-closed -> closed 过程是统一的,需要一个 END_STREAM。Server push 的整个流程 client 都不发送 END_STREAM。

第二个错误是弄错了 server push 中 DATA 发送的状态。如上文所说,half-closed 才是发送 DATA 时 stream 所处的状态,而非 reserved

后记?

这篇文章耗费了我三个晚上,内容也已远远超出“笔记”的范畴。也许文章的内容读者并不能看懂,但我想借此表达一点:RFC 不是完美的标准,它也是人写出来的,也有这样那样的问题,不论是措辞上,还是设计上。记下一些规则不难,只要肯花时间,但是理解背后的思想却极为困难,至少我做不到。而且有些东西别人不说,也许你就永远无法知道了。

最后,如果有人能看懂,我发自真心地祝贺你。说的好像有人看一样

Google

RFC7540 笔记(三)——Server Push

接着上回继续讲 stream 状态机。

5. Streams and Multiplexing

5.1 Stream States

上次讲了我们所熟知的 request-response 工作流在 HTTP/2 中的样子。这里再重复一次:在 HTTP/2 中一个 request-response 就对应一个 stream,换句话说一个 stream 的任务就是完成一组 request-response 的传输,然后就没用了

这次讲 server push。为了完整,我把不属于本章的一些内容(6.6, 8.2)也挪到这里。首先用一个例子介绍一下 server push。比如你在 HTTP/1.1 下访问 example.com,服务器会首先把 html 返回给你,然后浏览器看到 html 的内容,再发请求去拿 html 包含的资源比如图片、css 等。Server push 做的事是,服务器在返回 html 之后,因为知道 html 内容,所以可以直接把里面包含的资源发给客户端,这样客户端就不需要再发请求了。Server push 的好处是显而易见的:1. 减少了请求;2. 因为服务器不再需要等待后续请求到来而是主动 push,减少了一个 RTT 的等待时间。

上面淡紫色线表示的就是一个 server push 的流程。实际上,server push 依然符合之前对 request-response 的描述,即一个 request-response 对应一个 stream。下面会看到为什么。

先从熟悉的部分开始。上篇文章中详细讲了 stream 的关闭过程,对于 server push stream,这个过程是一样的。reserved 状态和 open 状态差不多,DATA frame 是在 reserved 状态下传输的。在 reserved 状态下,要么发送 RST_STREAM 直接 close stream,要么 client 和 server 各自发送 END_STREAM 先 half-closed 再 closed。这是我们已经了解的过程,如果不了解可以去阅读前一篇文章。

所以现在问题有两个:

  1. 既然功能差不多,为什么要弄一个 reserved,而不使用 open?reserved 到底是什么意思?
  2. 从 idle 到 reserved 的过程是怎样的?

现在没法回答这两个问题,因为要理解 server push,必须得先跳出 server push stream,去看前一个 stream。

前一个 stream

前一个 stream 是这样的 stream,该 stream 的 response reference 了要 push 的资源。什么意思?下面的继续使用访问 example.com 的那个例子来解释。

example.com 的服务器之所以可以提前 push 一些资源,是因为知道将要返回给客户端的 html 中 reference 了这些资源。在此场景下,“前一个 stream”,指的就是这个 html 作为 response 的 stream,也就是你访问 example.com 时发出的第一个 request 和 response 对应的 stream。

我们已经知道,server push 的好处是减少了 client 的请求。但是,client 怎么知道什么时候该发请求去拿 css,什么时候不该发呢?显然只能由 server 告知。问题是告知的时机。等 client 收到 html 之后再告知来得及吗?来不及,因为可能在 client 接收到 html 的一瞬间看到有 css 马上就发出请求,之后再告知也没意义了。所以,server 必须在 html 到达 client 之前告知将要 push 的资源。这样便引出了 8.2.1 节中的一个原则,我直接引用如下:

The server SHOULD send PUSH_PROMISE(Section 6.6) frames prior to sending any frames that reference the promised responses. This avoids a race where clients issue requests prior to receiving any PUSH_PROMISE frames.

至此终于可以正式引入 PUSH_PROMISE 这个概念了。PUSH_PROMISE 正是之前所说的“server 对 client 不要为某个资源发请求的告知”。所谓“promised responses”指的则是 server 打算 push 的那些资源。所以这个原则其实就是我们之前说的“server 必须在 html 到达 client 之前告知将要 push 的资源”,只不过把 html generalize 成了“frames that reference the promised responses”。

PUSH_PROMISE

OK,现在来讲 PUSH_PROMISE,以下简称 PP。PP 存在的意义上面已经说过了,下面是另外几件你需要知道的事:

  1. PP 必须在前一个 stream 上发送

    即是说,PP frame 的 stream ID 得是前一个 stream 的 stream ID。

  2. PP 实质上是一个 request

    前面说到在 server push 中,一个 request-response 仍然对应一个 stream。然而 client 并不发送 request,难道不是应该只有 response 么?原因在于,PP 充当了 request 的角色。没错,这是一个由 server 发出的 request。

  3. PP 的结构

    +---------------+
    |Pad Length? (8)|
    +-+-------------+-----------------------------------------------+
    |R|                  Promised Stream ID (31)                    |
    +-+-----------------------------+-------------------------------+
    |                   Header Block Fragment (*)                 ...
    +---------------------------------------------------------------+
    |                           Padding (*)                       ...
    +---------------------------------------------------------------+
    

    PP 的 payload 结构如图所示。Header Block Fragment 和 6.2 HEADERS 的 Header Block Fragment 是一个东西,也就是 HTTP/2 的 header fields。上篇文章说过,HTTP/2 中的 HEADERS 可以视作 HTTP/1.1 中的 request。所以现在就很清楚了,PUSH_PROMISE 中携带了 request 的所有信息,故它实质上就是一个 server 发出的 request。

    注意,图中只画了 payload,并未包含 stream ID,不要和 Promised Stream ID 弄混。那么 Promised Stream ID 是什么?它是由 server 选的一个偶数,代表一个还没有被使用过的 stream。之所以必须是没使用过的 stream,因为这个 ID 代表的 stream 正是接下来 push 要用的 stream,即传输资源 DATA 的 stream,也即 server push 的 request-response 对应的 stream。

Reserved State

现在来回答一开始提出的两个问题

  1. 既然功能差不多,为什么要弄一个 reserved,而不使用 open?reserved 到底是什么意思?

    PP 中的 Promised Stream ID 代表接下来发送 response 要用的 stream。不同于 open,之前并没有任何数据在这个 stream 上被发送,因为 PP 是在前一个 stream 上被发送的。这就好比,我打电话给餐馆订桌,虽然我现在不在餐馆,但是保证下午六点前会过去,这就是一个 promise。PUSH_PROMISE 也是一样的,虽然在 Promised Stream ID 代表的 stream 上还没有发送过任何数据,但是我 promise 马上会有一个 push response 在上面发送。

    Promise 的含义清楚了,但是 reserved 还没解释。继续用餐馆举例子。餐馆接到电话后,会给我留一个桌,于是这个桌就被预定了,处于 reserved 状态。Stream 也是同样的道理,reserved 状态表示这个 stream 被预定了,其它 request-response 不能用。

    这个万能的例子还可以展示另一种情况:client 拒绝 push。client 在接到 PP 之后,如果发送 RST_STREAM,代表它并不想接收 push。server 在接收到 RST_STREAM 时有可能已经发送了一些 DATA,若没发完,也就没必要接着发了。这就好比餐馆答应帮我留桌,但是几分钟后打电话过来说刚才搞错了,其实并没有空桌了。于是我只能取消在这个餐馆吃饭的计划。如果我已经在路上,则有必要更改目的地。

    提醒一下,上文所举的例子只是为了给读者一个概念,请勿强行对应。

  2. 从 idle 到 reserved 的过程是怎样的?

    已经解释过了,server 发送 PUSH_PROMISE 把一个 stream 由 idle 变为 reserved。再次强调,server 在选择 Promised Stream ID 的时候必须选那些还没被使用过,即处于 idle 状态的 stream 才行。

那么 server push 主要的内容就讲完了。虽然是一篇 2000 字的文章,但我并没有自信让读者看完就彻底理解 server push。想真正理解,还是要读 RFC,并且多想。

Google

RFC7540 笔记(二)——Stream 状态机

Stream 是 RFC7540 的重点,理解了 stream 的基本概念、flow-control、priority 再加上 server push,也就理解了 HTTP/2。

5. Streams and Multiplexing

stream 可以由 client 或 server 的任意一方创建。frame 在 stream 中的流动是双向的,且接收方会按照接收到 frame 的顺序处理它们。

5.1 Stream States

这个状态机示意图很重要,表示 stream 的状态变化。

                                +--------+
                        send PP |        | recv PP
                       ,--------|  idle  |--------.
                      /         |        |         \
                     v          +--------+          v
              +----------+          |           +----------+
              |          |          | send H /  |          |
       ,------| reserved |          | recv H    | reserved |------.
       |      | (local)  |          |           | (remote) |      |
       |      +----------+          v           +----------+      |
       |          |             +--------+             |          |
       |          |     recv ES |        | send ES     |          |
       |   send H |     ,-------|  open  |-------.     | recv H   |
       |          |    /        |        |        \    |          |
       |          v   v         +--------+         v   v          |
       |      +----------+          |           +----------+      |
       |      |   half   |          |           |   half   |      |
       |      |  closed  |          | send R /  |  closed  |      |
       |      | (remote) |          | recv R    | (local)  |      |
       |      +----------+          |           +----------+      |
       |           |                |                 |           |
       |           | send ES /      |       recv ES / |           |
       |           | send R /       v        send R / |           |
       |           | recv R     +--------+   recv R   |           |
       | send R /  `----------->|        |<-----------'  send R / |
       | recv R                 | closed |               recv R   |
       `----------------------->|        |<----------------------'
                                +--------+

          send:   endpoint sends this frame
          recv:   endpoint receives this frame

          H:  HEADERS frame (with implied CONTINUATIONs)
          PP: PUSH_PROMISE frame (with implied CONTINUATIONs)
          ES: END_STREAM flag
          R:  RST_STREAM frame

图比较难看懂,主要在于它包含了两个不同的过程。因此我把它们分开表示(server push 过程下次讲):

绿线所覆盖的流程代表我们所熟知的 request-response 工作流。在 HTTP/2 中一个 request-response 就对应一个 stream,换句话说一个 stream 的任务就是完成一组 request-response 的传输,然后就没用了。先不考虑 half-closed,于是流程可以简化为 idle ——> open ——> closed ,下面先解释这个流程。

idle

idle 很好理解,就是 stream 的初始状态。你也可以认为凡是还没被使用过的 stream 都是 idle 状态。

open

stream 发出或接收到 HEADERS(HEADERS + CONTINUATION 就相当于 HTTP/1.1 中的 request)之后进入 open 状态,这时 server 开始发送 DATA 也就是 response(没有标在图上)。有人可能会不理解为什么图中要把 send HEADER 和 receive HEADER 分开写,这是因为 stream 的状态实际上是在 client 和 server 端同时被维护的,即保存了两份。它们表示同一个 stream 的状态,理论上应该实时同步,但实际上两边维护的 stream state 变化过程是不同的,且并非每时每刻都相同。说得更明白点,idle ——> open 的过程是:client 发出 HEADER + CONTINUATION 后把 client 端维护的 stream state 变为 open,server 接收到 HEADER + CONTINUATION 后把 server 端维护的 stream state 变为 open。明白了 stream state 有两份这点,才能理解之后的内容。

closed

DATA(response) 发完之后就要关闭 stream,最终 stream 进入 closed 状态,永远不再被使用。

可以看到,idle ——> open ——> closed 的流程其实是我们已经熟悉的 request-response 工作流。不过 stream 并不是说关就关,下面解释 stream 由 open ——> closed 的过程。

Stream 是怎么关闭的?

第一种,通过 RST_STREAM。发送、接收到 RST_STREAM,那么这个 stream 直接进入 closed 状态。

第二种,通过 END_STREAM。Reset 快啊,但一般只有 stream 或某个 endpoint 出了些状况的时候才会使用,属于非常规手段。正常情况下 stream 是通过 END_STREAM 结束的。某个 endpoint 发送 END_STREAM 的意思是说,对一个 request-response 而言,我这边该发的数据都发完了,所以我觉得可以 close 这个 stream 了。一边说了不算,如果 peer 还没发完,当然是不能 close 的。如果接到 peer 发的 END_STREAM,那么说明 peer 也同意 close stream,于是 stream 变为 closed 状态。这是 open ——> half closed ——> closed 的流程。

所以你们现在知道,half-closed 表示一边想 close,但另一边还没有同意的状态。只不过呢,图里把这个流程分成了两条路,描述某个 endpoint 存储的 stream 状态变化(记得之前说到 stream 状态会存两份么)。

第一条路 open — send ES —> half closed(local) — recv ES —> closed ,endpoint 先发送 END_STREAM,再接收到 END_STREAM,half closed(local) 代表该 endpoint 想 close;

第二条路 open — send ES —> half closed(remote) — recv ES —> closed ,endpoint 先接收到 END_STREAM,再发送 END_STREAM,half closed(remote) 代表 peer 想 close。

local/remote 的含义需要自行体会,希望现在有把它讲清楚。另外需要说明的是,RST_STREAM 是一种特殊的 frame 类型,而 END_STREAM 则是 HEADERS 或者 DATA 带的 flag。前面说的发送 END_STREAM,指的是发送 set 了 END_STREAM flag 的 HEADERS 或 DATA。

本来想把两个 workflow 一起讲的,不过篇幅所限,server push 的内容就放到下次好了。


top