從Chrome原始碼看HTTP/2

人人網FED發表於2019-02-28

我在《怎樣把網站升級到http/2》介紹了升級到http/2的方法,並說明了http/2的優點:

  • http頭部壓縮
  • 多路複用
  • Server Push

下面一一進行說明。

1. 頭部壓縮

為什麼要進行頭部壓縮呢?我在《WebSocket與TCP/IP》說過:HTTP頭是比較長的,如果傳送的資料比較小時,也得傳送一個很大的HTTP頭部,如下圖所示:

當這種請求數很多的時候,會導致網路的吞吐率不高。並且,比較大的HTTP頭部會迅速佔滿慢啟動過程中的擁塞視窗,導致延遲加大。所以HTTP頭的壓縮顯得很有必要,HTTP/2的前身SPDY引入了deflate的壓縮演算法,但是據說這種容易受攻擊,HTTP/2使用了新的壓縮方法,在規範RFC 7541進行了說明。關於頭部壓縮,規範的附錄舉了個很生動的例子。這裡用這個例子做為說明,解釋可以怎麼對HTTP頭部進行壓縮。

首先對常用的HTTP頭部欄位進行編號,用一個靜態表格表示:

IndexHeader NameHeader Value
1 :authority
2 :method GET
3 :method POST
4 :path /
5 :path /index.html
6 :scheme http
7 :scheme https
8 status 200
9 status 204
... ... ...
16 accept-encodinggzip, deflate
17 accept-language
... ... ...
61 www-authenticate

總共有61個,其中冒號開頭的如:method是請求行裡的,2就表示Method: POST,如果要表示Method: OPTION呢?用下面的表示:

0206OPTION

其中02表示在靜態表格的索引index,查一下這個表格可知道2表示的Header Name為:method。接著的06表示method名的長度為6,後面緊接著就是欄位的內容即method名為OPTION。那它怎麼知道02後面跟著的06不是表示index為6的":scheme http"的頭欄位呢?因為如果Header Name和Header Value都是用的這個表的,如Method POST表示為:

0x82

而不是02了,這裡就是把第8位置成了1,變成了二進位制的1000 0002,表示name/value完全匹配。而如果第8位不是1,如0000 0002那麼value值就是自定義的,後面緊跟著的一個位元組就是表示value的字元長度,然後再跟著相應長度的字元。

value字元是使用霍夫曼編碼的,規範根據字元的使用頻率高低定了一個編碼表,這個編碼表把常用的字元的大小控制在5 ~ 7位,比ASCII編碼的8位要小一些。根據編碼表:

symcode as bitscode as hexlen in bits
'O' 1101010 6a 7
'P' 1101011 6b 7
'T' 1101111 6f 7
'I' 1100100 64 7
'N' 1101001 69 7

OPTION會被編碼為:6a6b 6f64 6a69,所以Method: OPTION最終被編碼為:

0206 6a6b 6f64 6a69

一共是8個位元組,原先用字串需要14個位元組。

還有,如果有多次請求,後面的請求有一些頭部欄位和前面的一樣,那麼會用一個動態表格維護相同的頭部欄位。如果name/value是在上面說的靜態表格都有的就不會儲存到動態表格。動態表格可以用一個棧或者動態陣列來儲存。

例如,第一次請求頭部欄位"Method: OPTION"在靜態表格沒有,它會被壓到一個棧裡面去,此時棧只有一個元素,用索引為62 = 61 + 1表示這個欄位,在接下來的第二次、第三次請求如果用到了這個欄位就用index為62表示,即遇到了62就表示Method: OPTION。如果又有其它一個自定義欄位被壓到這個棧裡面,這個欄位的索引就為62,而Method: OPTION就變成了63,越臨近壓進去的編號就越往前。

靜態表格的index是從1開始,動態表格是從62開始,而index為0的表示自定義欄位名,用key長度 + key + value長度 + value表示,當把它這個自定義欄位壓到動態表格裡面之後,它就有index了。當然,可以控制是否需要把欄位壓到動態表格裡面,通過設定標誌位,這裡不展開說明。

這個演算法叫做HPACK,更詳細的過程可以檢視RFC 7541:HPACK: Header Compression for HTTP/2(可以直接拉到最後面看例子)。

Chrome是在src/net/http2/hpack這個目錄做的頭部解析,靜態表格是在這個檔案hpack_static_table_entries,如下圖所示:

根據文件,動態表格預設最多的欄位數為4096:

  // The last received DynamicTableSizeUpdate value, initialized to
  // SETTINGS_HEADER_TABLE_SIZE.
  size_t size_limit_ = 4096;  // Http2SettingsInfo::DefaultHeaderTableSize();複製程式碼

可在傳輸過程中動態改變,受對方能力的限制,因為不僅是存自己請求的欄位,還要有一個表格存對方響應的欄位。

Chrome裡的動態表格是用一個向量vector的資料結構表示的:

  const std::vector<HpackStringPair>* const table_;
複製程式碼

vector就是C++裡面的動態陣列。每次插入的時候就在陣列前面插入:

table_.push_front(entry);複製程式碼

而查詢的時候,直接用陣列的索引去定位,下面是查詢動態陣列的:

// Lookup函式
index -= kFirstDynamicTableIndex; // kFirstDynamicTableIndex等於62
if (index < table_.size()) {
  const HpackDecoderTableEntry& entry = table_[index];
  return entry;
}
return nullptr;複製程式碼

頭部壓縮最主要的內容就說到這裡了,接下來說一下更為厲害的多路複用。

2. 多路複用

傳統的HTTP/1.1為了提高併發性,得通過提高連線數,即同時多發幾個請求,因為一個連線只能發一個請求,所以需要多建立幾個TCP連線。建立TCP連線需要執行緒開銷,我們知道Chrome同一個域最多同時只能建立6個連線。所以就有了雪碧圖、合併程式碼檔案等減少請求數的解決方案。

在HTTP/2裡面,一個域只需要建立一次TCP連線就可以傳輸多個資源。多個資料流/訊號通過一條通道進行傳輸,充分地利用高速通道,就叫多路複用(Multiplexing)。

在HTTP/1.1裡面,一個資源通過一個TCP連線傳輸,一個大的資源可能會被拆成多個TCP報文段,每個報文段都有它的編號,按照從前往後依次增大的順序,接收方把收到的報文段按照順序依次拼接,就得到了完整的資源。當然,這個是TCP傳輸自然的特性,和HTTP/1.1沒有直接關係。

那麼怎麼用一個連線傳輸多個資源呢?HTTP/2把每一個資源的傳輸叫做流Stream,每個流都有它的唯一編號stream id,一個流又可能被拆成多個幀Frame,每個幀按照順序傳送,TCP報文的編號可以保證後傳送的幀的順序比先傳送的大。在HTTP/1.1裡面同一個資源順序是依次連續增大的,因為只有一個資源,而在HTTP/2裡面它很可能是離散變大的,中間會插著傳送其它流的幀,但只要保證每個流按順序拼接就好了。如下圖所示:

從Chrome原始碼看HTTP/2

為什麼叫它流呢,因為資料就像水流一樣會流動,所以叫它為流/資料流,流的特點是有序的,它是資料的一個序列。它可以從鍵盤傳到記憶體,再由記憶體傳輸到硬碟,或者傳輸到服務端。

在通訊裡面,流被分成若干幀,HTTP/2規定了11種型別的幀,包括HEADERS/DATA/SETTINGS等,HEADERS是用來傳輸http頭部的,DATA是用來傳送請求/響應資料的,SETTINGS是在傳輸過程中用來做控制的。一個幀的格式如下圖所示:

幀的頭部有9個位元組,前3個位元組(24位)表示幀有效資料(Frame Payload)的長度,所以每個幀最大能傳送的資料為2 ^ 24 = 16MB,但標準規定預設最大為2 ^ 14 = 16Kb,除非雙方通過settings幀進行控制。第4個位元組Type表示幀型別,如0x0表示data,0x1是headers,0x4是settings。Flags是每種幀用來控制一些引數的標誌位,如在data幀裡面第一個標誌位開啟0x1是表示END_STREAM,即當前資料幀是當前流的最後一個資料幀。Stream Identifier是流的標誌符即流的編號,它的首位R是保留位(留著以後用)。最後就是Payload,當前幀的有效負載。

每個請求都會建立一個流,每個流的建立都是請求方通過傳送頭部幀,即頭部幀用來開啟一個流,每個流都有它的優先順序,放在頭部幀裡面。流的頭部幀還包含了上面第1點提到的HTTP壓縮頭部欄位。

每個流都有一個半關閉的狀態,當一方收到END_STREAM的時候,當前流就處於半關閉(remote)的狀態,這個時候另一方不再傳送資料了,當前方也發一個END_STREAM給對方的時候,這個時候流就處於完全關閉的狀態。已關閉的流的編號在當前連線不能複用,避免在新的流收到延遲的相同編號的老的流的幀。所以流的編號是遞增的。

更詳細的描述可以參考這個文件:Hypertext Transfer Protocol Version 2 (HTTP/2)

我們以訪問Walking Dog這個頁面做為說明,看一下流和幀是怎麼傳輸的,這個頁面總共載入13個資源:

包括index.html、main.js、main.css和10張圖片。

Chrome解碼HTTP/2幀的目錄在src/net/http2,而編碼的目錄在src/net/spdy。

所謂解碼就是按照格式解析接收到的http/2的幀,在Chrome裡面通過列印Log觀察這個過程,如下程式碼示例:

按照列印的順序一一說明:

(1)SETTINGS(stream_id = 0; flags = 0; length = 18)

先是收到了一個SETTINGS的幀,payload內容為:

parameter=MAX_CONCURRENT_STREAMS(0x3), value=128
parameter=INITIAL_WINDOW_SIZE(0x4), value=65536
parameter=MAX_FRAME_SIZE(0x5), value=16777215

另一方即服務端(nginx)設定了max_concurrent_streams為128,表示stream的最多併發數為128,即同時最多隻能有128個請求。window_size是用來做流控制(Flow Control)的,表示對方接收的緩衝容量,分為全域性的緩衝容量和單個流的緩衝容量,如果stream_id為0則表示全域性,如果非0的話則是相應stream,上面服務設定初始化的window size為64KB,在傳送過程中可能會調整這個值,當接收方的快取空間滿了可能會置為0發給對方告訴對方不要再給我發了,這個和TCP的擁塞視窗很像,但是這個是在應用層做的控制,可以方便對每個流的接收進行控制,例如當快取空間不足時,優先順序高的流可能給的window_size會更大一點。max_frame_size表示每個幀的payload最大值,這裡設定成了最大值16MB。與此同時瀏覽器也給服務發了自己的settings。如果settings不是使用的標準規定的預設值,那麼就會傳遞settings幀。

然後收到了第二幀:

(2)WINDOW_UPDATE (stream id = 0; flag = 0; length = 4)

window_update型別的幀是用於更新window_size的,payload內容為:

window_size_increment=2147418112

這裡把window_size設定成了最大值2GB,在第一幀裡的max_frame_size也是最大值,可以說明服務沒有限制接收速度。這裡的stream id為0也是表示這個配置是全域性的。

那為什麼不直接初始化的時候直接設定window_size呢,實現上就是這樣的。可以對比一下,在連谷歌的gstatic.com的時候收到的幀是這樣的:

INITIAL_WINDOW_SIZE, value=1048576

MAX_HEADER_LIST_SIZE, value=16384

window_size_increment=983041

這樣看起來比較合理一點(另外它還設定了header頭部欄位數最大值)。

在Chrome原始碼裡面我只看到了一個地方使用到了window size,那就是當對方的window size為0時,流就會排隊:

  if (session_->IsSendStalled() || send_window_size_ <= 0) {
    return Requeue;
  }複製程式碼

而通過nginx原始碼,我們發現nginx會在每傳送一個幀的時候window size就會減掉當前幀大小:

ngx_http_v2_queue_frame(h2c, frame);
h2c->send_window -= frame_size; 
stream->send_window -= frame_size;複製程式碼

傳送成功後進行cleanup又會把它加回來,如果send_window變成0,就會進行排隊。

(3)SETTINGS (stream id = 0; flag = 1; length = 0)

第三幀也是settings,但是這次沒有內容,長度為0,設定flag為1表示ACK,表示認同瀏覽器給它發的SETTINGS。flags在不同型別的幀有不同的含義,如下程式碼所示:

enum Http2FrameFlag {
  END_STREAM = 0x01,   // DATA, HEADERS 表示當前流結束
  ACK = 0x01,          // SETTINGS, PING settings表示接受對方的設定,而ping是用來協助對方測量往返時間的
  END_HEADERS = 0x04,  // HEADERS, PUSH_PROMISE, CONTINUATION 表示header部分完了
  PADDED = 0x08,       // DATA, HEADERS, PUSH_PROMISE 表示payload後面有填充的資料
  PRIORITY = 0x20,     // HEADERS 表示有當前流有設定權重weight
};複製程式碼

接著收到第4幀:

(4)HEADERS (stream id = 1; flag = 4; length = 107)

這個是請求響應頭,是inde.html的,flag為4代表END_HEADERS,表示這個header只有一幀,如果flag為0就說明後面還有。然後Chrome會對收到的頭部進行逐位元組解析,按照上面提到的頭部壓縮的方式的逆過程。

先取出第一個位元組,判斷是什麼型別的header,indexed或者非indexed,所謂indexed就是指查表能查到的,如第一個位元組是0x82就是IndexedHeader。然後再把高位去掉,剩下0x02表示表的索引:

如果是IndexedHeader那麼就會去動態表和靜態表查:

否則的話就得去解析l字串key/value和length,有可能使用了霍夫曼,也有可能沒有,程式碼裡面做了判斷:

uint8_t h_and_prefix = db->DecodeUInt8();
bool huffman_encoded = (h_and_prefix & 0x80) == 0x80;
複製程式碼

Chrome也是維護了一個霍夫曼表:

解析完一對key/value頭部欄位之後,可能會把它壓入動態表裡面。接著繼續解析下一個,直到length完了。

(5)DATA (stream id = 1; flag = 1; length = 385)

header幀之後就是index.html的資料幀了,這裡flag為1表示END_STREAM,長度為385,因為資料比較小,一個幀就傳送完了。

我們把收到的payload直接列印出來是這樣的(我把gzip關了,不然列印出來是壓縮過的內容):

這個內容就是html文字,我們看到HTTP/2並沒有對傳送內容進行處理,只是對傳送的形式進行了控制。經常說HTTP/2是二進位制的,應該是說幀的頭部是二進位制,但是內容該怎麼樣還是怎麼樣。

(6)HEADERS(stream id = 3; flag = 4; length = 160)

接著又收到了一個頭部幀,這個是main.css的響應頭,stream id為3.

(7)DATA (stream id = 3; flag = 1; length = 827)

這個是main.css的資料幀:

(8)HEADERS (stream id = 5; flag = 4; length = 171)

這個是main.js的響應頭

(9)DATA(DATA stream id = 5; flag = 1; length = 4793)

main.js的payload,如下圖所示:

(10)HEADERS(HEADERS stream id = 7; flag = 4; length = 163)

這個是0.png的響應頭

(11)DATA (stream id = 7; flag = 0; length = 8192)

0.png的payload:

注意這裡的flag是0,不是1,說明後面還有資料幀。緊接著又收到了一幀:

(12)DATA (stream id = 7; flag = 1; length = 2843)

這個的stream id還是7,但是flag為1表示END_STREAM。說明0.png(11KB)被拆成了兩幀傳送。

我們發現stream的id都是奇數的,這是因為這些stream都是瀏覽器建立的,主動連線一方stream id使用奇數,而另一方觸發建立的stream使用偶數,主要通過Server Push。


現在把Chrome傳送的幀加進來,有了上面的基礎再來理解Chrome的應該不難。

Chrome也會傳送它的settings幀給對方,在初始化session的時候做的,程式碼是在net/spdy/chromium/spdy_session.cc的SendInitialData函式裡面。我們把感興趣的幀按順序列印出來:

(1)SETTINGS

內容如下:

HEADER_TABLE_SIZE, value = 65536

MAX_CONCURRENT_STREAMS, value = 1000

INITIAL_WINDOW_SIZE, value = 6291456

Chrome做為接收方的時候流的最高併發數為1000.

(2)WINDOW_UPDATE

新增的window size大小為:

window_size_increment = 15663105

大概為15Mb。

接著,Chrome的IO執行緒取出一個request任務,取到的第一個是請求index.html,列印如下:

../../net/spdy/chromium/spdy_http_stream.cc (95) SpdyHttpStream::InitializeStream : request url = fed.renren.com/html/walking-dog/index.html

它先建立一個HEADERS的幀

(3)HEADERS(index.html stream_id = 1; weight = 256; dependency = 0; flag = 1)

我們把幀頭部的另外兩個引數列印出來:weight權重和dependency依賴的流。權重為256,而依賴的流為0即沒有。權重用來做來優先順序的參考,值的範圍為1 ~ 256,所以當前流擁有最高的優先順序。依賴下文再提及。

在收到了html之後,Chrome解析到了main.css的link標籤和main.js的script標籤,於是又再重新初始化兩個流,流都是通過發HEADERS的幀開啟的。

(4)HEADERS(main.css stream_id = 3; weight = 256; dependency = 0; flag = 1 )

main.css也擁有最高的優先順序

(6)HEADERS (main.js stream_id = 3; weight = 220; dependency = 3;flag = 1)

main.js的權重為220,這個script檔案的權重要比html/css的小,並且它的依賴於id為3即main.css的流。

收到JS之後,解析JS,這個JS裡面又觸發載入了9張png圖片,接著Chrome一口氣初始化了9個流:

(7)~ (15)

0.png stream_id = 7, weight = 147, dependent_stream_id = 0, flags = 1

1.png stream_id = 9, weight = 147, dependent_stream_id = 7, flags = 1

2.png stream_id = 11, weight = 147, dependent_stream_id = 9, flags = 1

...

可以看到圖片的權重又比script小,並且這些圖片的流有一個依賴關係,後一個流依賴於前一個流。

依賴關係是在HEADER的payload裡面指定的,如下圖所示:

優先順序依賴priority dependencies和視窗大小window size是HTTP/2進行多路複用控制的兩個最主要的方法。這個依賴有什麼用呢?如下文件的說明:

Inside the dependency tree, a dependent stream SHOULD only be allocated resources if either all of the streams that it depends on (the chain of parent streams up to 0x0) are closed or it is not possible to make progress on them.

意思是說一個依賴的子結點只有等到它所有的父結點都處理完了才能夠給它分配資源進行處理。換句話說在這棵優先順序依賴樹裡面,父結點比子結點擁有更高的處理優先順序。文件裡面只說明瞭優先順序依賴樹的一些特性,並沒有說明應該如何實現,只是說不同的場景可以有不同的實現。Chrome又是怎麼實現的呢?

它是用一個二維陣列,第一維是priority,即用一個陣列放優先順序相同的stream id,當初始化一個流的時候就會把它放到相應priority的陣列裡面去。注意這裡的priority是指spdy3的屬性,從最高優化級的0到最低優化級7,和權重weight([1, 256])有一個轉化關係,這裡不討論是怎麼轉換的,它們表達的意思是一樣的。如下程式碼所示,把stream新增到相應優先順序陣列的後面:

id_priority_lists_[priority].push_back(std::make_pair(stream_id, priority));複製程式碼

但是這個二維陣列並沒有建立父子結點的關係,只是藉助它可以知道當前流的父結點應該是哪個。計算當前流父結點的程式碼實現邏輯是在http2_priority_dependencies.cc這個檔案裡面,為了方便理解,我把它轉成JS程式碼,它的思想是要找到比當前流的優先順序離得最近且不低於當前優先順序的一個流,程式碼如下所示:

let stream_id = 1, // 當前流的id,從外面傳進來
    priority = 0, // 當前流的優先順序,從外面傳進來的
    id_priority_lists_ = []; // 它是一個二維陣列
id_priority_lists_[0] = [];  // 這裡先初始化一下
const kV3HighestPriority = 0; // 最高優先順序為0

let dependent_stream_id = 0; // 父結點的stream id
for (let i = priority; i >= kV3HighestPriority; i--) {
    let length = id_priority_lists_[i].length;
    if (length) {
        dependent_stream_id = id_priority_lists_[i][length - 1];
        break;
    }
}
id_priority_lists_[priority].push(stream_id);複製程式碼

這段程式碼應該比較好理解,在for迴圈裡面從當前優先順序一直往高優化級找,直到找到一個,如果沒有,那麼它的parent stream就是0,表示它是一個root結點。

另外,一旦流關閉了之後,就會把當前流從二維陣列裡面移除。所以在收到html之後,html的流就被刪了,所以新建的CSS流就沒有依賴的父結點了,但是緊接著新建的JS流它的優先順序比CSS流低,所以這個JS流的父結點就是CSS流。

我們看到Chrome並沒有實際地存放這麼一棵樹,只是藉助這麼一個二維陣列找到當前stream的父結點,設定在HEADER幀的dependency,然後傳給服務端,告訴服務端這些流的優先順序依賴關係。

服務端又是怎麼利用這些優先順序依賴關係的呢?我們以nginx為例,通過nginx的原始碼,可以大致知道nginx是怎麼操作的,nginx是有真正建立一棵依賴樹的,每一個流都會對應一個node結點。每個結點都會記錄它的父結點parent,它的子結點集children,以及當前結點的weight和rank,如下程式碼所示:

struct ngx_http_v2_node_s {
    ngx_uint_t                       id;
    ngx_http_v2_node_t              *index;
    ngx_http_v2_node_t              *parent;
    ngx_queue_t                      queue;
    ngx_queue_t                      children;
    ngx_queue_t                      reuse;
    ngx_uint_t                       rank;
    ngx_uint_t                       weight;
    double                           rel_weight;
    ngx_http_v2_stream_t            *stream;
};複製程式碼

在實際計算中是用的rel_weight相對權重和rank排名,這個相對權重和排名是利用weight和dependency計算的:

// 如果當前結點沒有父結點,那麼它的排名就為第一
if (parent == null) {
    node->rank = 1;
    node->rel_weight = (1.0 / 256) * node->weight;
}
// 否則的話,它的排名就是父結點的排名加1
// 而它的相對權重就是父結點的 weight/256
else {
    node->rank = parent->rank + 1;
    node->rel_weight = (parent->rel_weight / 256) * node->weight;
}複製程式碼

可以看到子結點的相對權重rel_weight等於父結點的weight/256倍,注意weight <= 256,而子結點的排名rank是排在父結點的後一位。當前結點的weight和父結點是哪個是瀏覽器通過HEADERS幀告訴的,也就是說nginx利用這兩個引數把當前流節點插入這個依賴樹裡面,通過這棵樹計算出當前結點的排名和相對權重。

知道這兩個有什麼用呢?

當流的併發數超過最高併發數max_concurrent_streams時,或者快取空間buffer用完了,這個時候要把當前流放到waiting_queue裡面,這個佇列有一個順序,越靠前的元素就能越快處理,優先順序越高就越靠前。當把一個需要waiting的stream插入到這個佇列的時候就需要用到優先順序排名決定要插到哪個位置,如下ngx_http_v2_waiting_queue函式的實現:

// stream表示要插入的流
stream->waiting = 1;

// 從waiting列隊的最後一個元素開始,依次往前遍歷,直到完了
for (q = ngx_queue_last(&h2c->waiting);
     q != ngx_queue_sentinel(&h2c->waiting);
     q = ngx_queue_prev(q))
{
    // 取出當前元素的資料
    s = ngx_queue_data(q, ngx_http_v2_stream_t, queue);

    // 這段程式碼的核心在於這個判斷
    // 如果要插入的流的排名比當前元素的排名要靠後,
    // 或者排名相等但是相對權重比它小,就插到它後面。
    if (s->node->rank < stream->node->rank
        || (s->node->rank == stream->node->rank
            && s->node->rel_weight >= stream->node->rel_weight))
    {   
        break;
    }   
}

// 這裡執行插入,如果stream的優先順序比佇列的任何一個元素
// 都要高的話,就插到隊首去了
ngx_queue_insert_after(q, &stream->queue);複製程式碼

把當前stream插到比它優先順序稍高的一個元素的後面去,利用了rank和rel_weight. rank是由dependency決定,而rel_weight主要是由weight決定。

我們發現nginx在傳送幀佇列的時候也是用的類似的判斷來決定幀的傳送順序,如下ngx_http_v2_queue_frame函式程式碼:

if ((*out)->stream->node->rank < frame->stream->node->rank
    || ((*out)->stream->node->rank == frame->stream->node->rank
        && (*out)->stream->node->rel_weight
           >= frame->stream->node->rel_weight))
{
    break;  
}複製程式碼

HTTP/2的多路複用就介紹到這裡,上面說的流都是由瀏覽器主動開啟的,而HTTP/2的Server Push的流是由服務觸發開啟的。

3. Server Push

當我們使用HTTP/1.1的時候,Chrome最多同時載入6個:

而當我們使用HTTP/2的時候,就沒有這個限制,有的是流的最大併發數,如上面提到的100個,如下圖所示:

我們觀察到圖片的載入完成時間是從上往下的,這個就應該是上面提到的優先順序依賴影響的,後面的圖片會依賴於前面的圖片,所以前面的圖片優先順序會更高一點,優先傳遞。時間線裡面綠色的是表示Waiting TTFB(Time To First Byte)的時間,即發出請求之後到收到第一個位元組所需要的時間,藍色是內容下載時間。這裡可以看到等待時間TTFB依次增長。

雖然使用了HTTP/2沒有了6個的限制,但是我們發現css/js需要在html解析了之後才能觸發載入,而圖片是通過JS的new Image觸發載入,所以它們需要等到JS下載完並解析好了才能開始載入。

所以Server Push就是為了解決這個載入延遲問題,提前把網頁需要的資源Push給瀏覽器。Nginx 1.13.9版本開始支援,是在最近(2018/2)才有的。通過編譯一個新版本的nginx就能體驗Server Push的功能,給nginx.conf新增以下配置:

location = /html/walking-dog/index.html {
    http2_push /html/walking-dog/main.js?ver=1;
    http2_push /html/walking-dog/main.css;
    http2_push /html/walking-dog/dog/0.png;
    http2_push /html/walking-dog/dog/1.png;
    http2_push /html/walking-dog/dog/2.png;
    http2_push /html/walking-dog/dog/3.png;
    http2_push /html/walking-dog/dog/4.png;
    http2_push /html/walking-dog/dog/5.png;
    http2_push /html/walking-dog/dog/6.png;
    http2_push /html/walking-dog/dog/7.png;
    http2_push /html/walking-dog/dog/8.png;
}複製程式碼

指定需要Push的資源,然後觀察載入的時間線:

我們發現載入時間有了一個質的改變,基本上在100ms左右就載入完了。所以Sever Push用得好的話作用還是挺大的。

Server Push的流是通過Push Promise型別的幀開啟的,Promise的幀格式如下圖所示:

它其實就是一個請求的HEADER幀,但是它和HEADER又不太一樣,沒有weight/dependency那些東西。

按照上面的方式,觀察一下加上Push Promise之後,幀傳遞的過程是怎麼樣的。

瀏覽器在stream_id = 1的流裡面請求載入index.html,這個時候服務並沒有立刻響應頭部和資料,而是先連續返回了11個Push Promise的幀,stream的id分別為2、4、6等,然後瀏覽器立刻建立了相應的stream,收到了2的promise之後就建立2的stream,收到4的之後就建立4的。這個時候流建立好了就開始載入,不用等到解析到html或者js之後才開始。

在這個過程中,Chrome會先解析promised stream id,如下圖所示:

然後再去解析Hpack的頭部,再用這個頭部去建立流。Server Push我們就不再深入討論了,讀者可以開啟這個網址感受一下。


綜上,我們主要討論了HTTP/2的三大特性:

(1)頭部壓縮,通過規定頭部欄位的靜態表格和實際傳輸過程中動態建立的表格,減少多個相似請求裡面大量冗餘的HTTP頭部欄位,並且引入了霍夫曼編碼減少字串常量的長度。

(2)多路複用,只使用一個TCP連線傳輸多個資源,減少TCP連線數,為了能夠讓高優先順序的資源如CSS等更先處理,引入了優先順序依賴的方法。由於併發數很高,同時傳遞的資源很多,如果網速很快的時候,可能會導致快取空間溢位,所以又引入了流控制,雙方通過window size控制對方的傳送。

(3)Server Push,解決傳統HTTP傳輸中資源載入觸發延遲的問題,瀏覽器在建立第一個流的時候,服務告訴瀏覽器哪些資源可以先載入了,瀏覽器提前進行載入而不用等到解析到的時候再載入。

國內使用HTTP/2的還沒怎麼見到,淘寶之前是有開啟HTTP/2的,不知道為什麼現在又下掉了, 國外使用HTTP/2的網站還是很多的,像谷歌搜尋、CSS-Tricks、Twitter、Facebook等都使用了HTTP/2。如果你有自己的網站的話,可以嘗試一下。


相關閱讀:

  1. 怎樣把網站升級到http/2
  2. 從Chrome原始碼看HTTPS
  3. 從Chrome原始碼看HTTP


相關文章