QUIC協議和HTTP3.0技術研究


一、QUIC詳解

  tcp具有3次握手、4次揮手、隊頭阻塞、擁塞控制等特點。現有HTTP2.0基於tcp,速度稍慢。為了解決速度上的問題,Http3.0基於UDP。

1.TCP的缺點和UDP的優點:

  • 基於TCP研發的設備和協議多,兼容困難

  • TCP協議棧是Linux內部的重要部分,修改和升級成本大;UDP改造成本小

  • TCP有隊頭阻塞和擁塞控制;UDP沒有

  • TCP有建立連接和斷開連接成本,三次握手和四次揮手;UDP沒有

  • QUIC協議是在UDP基礎上改造的具有TCP優點的新協議。

  QUIC提高了當前正在使用TCP的面向連接的Web應用程序的性能。它在兩個端點之間使用UDP建立多個復用連接來實現此目的。QUIC的次要目標包括減少連接和傳輸延遲,在每個方向進行帶寬估計以避免擁塞`。它還將擁塞控制算法移動到用戶空間,而不是內核空間,此外使用前向糾錯(FEC)進行擴展,以在出現錯誤時進一步提高性能。

HTTP3.0又稱為HTTP Over QUIC,其棄用TCP協議,改為使用基於UDP協議的QUIC協議來實現。

在這里插入圖片描述

QUIC協議必須要實現HTTP2.0在TCP協議上的重要功能,同時解決遺留問題,我們來看看QUIC是如何實現的。

2.隊頭阻塞問題

    隊頭阻塞 Head-of-line blocking(縮寫為HOL blocking)是計算機網絡中是一種性能受限的現象,通俗來說就是:一個數據包影響了一堆數據包,它不來大家都走不了。隊頭阻塞問題可能存在於HTTP層和TCP層,在HTTP1.x時兩個層次都存在該問題。
在這里插入圖片描述

    HTTP2.0協議的多路復用機制解決了HTTP層的隊頭阻塞問題,但是在TCP層仍然存在隊頭阻塞問題。TCP協議在收到數據包之后,這部分數據可能是亂序到達的,但是TCP必須將所有數據收集排序整合后給上層使用,如果其中某個包丟失了,就必須等待重傳,從而出現某個丟包數據阻塞整個連接的數據使用。多路復用是 HTTP2 最強大的特性 ,能夠將多條請求在一條 TCP 連接上同時發出去。但也惡化了 TCP 的一個問題,隊頭阻塞 ,如下圖示:
在這里插入圖片描述

    HTTP2 在一個 TCP 連接上同時發送 4 個 Stream。其中 Stream1 已經正確到達,並被應用層讀取。但是 Stream2 的第三個 tcp segment 丟失了,TCP 為了保證數據的可靠性,需要發送端重傳第 3 個 segment 才能通知應用層讀取接下去的數據,雖然這個時候 Stream3 和 Stream4 的全部數據已經到達了接收端,但都被阻塞住了。不僅如此,由於 HTTP2 強制使用 TLS,還存在一個 TLS 協議層面的隊頭阻塞.
在這里插入圖片描述

    QUIC 的多路復用和 HTTP2 類似。在一條 QUIC 連接上可以並發發送多個 HTTP 請求 (stream)。但是 QUIC 的多路復用相比 HTTP2 有一個很大的優勢。

    QUIC 一個連接上的多個 stream 之間沒有依賴。這樣假如 stream2 丟了一個 udp packet,也只會影響 stream2 的處理。不會影響 stream2 之前及之后的 stream 的處理。這也就在很大程度上緩解甚至消除了隊頭阻塞的影響。QUIC協議是基於UDP協議實現的,在一條鏈接上可以有多個流,流與流之間是互不影響的,當一個流出現丟包影響范圍非常小,從而解決隊頭阻塞問題.

3.0RTT 建鏈

衡量網絡建鏈的常用指標是RTT Round-Trip Time,也就是數據包一來一回的時間消耗。
在這里插入圖片描述

RTT包括三部分:往返傳播時延、網絡設備內排隊時延、應用程序數據處理時延。
在這里插入圖片描述

    一般來說HTTPS協議要建立完整鏈接包括:TCP握手和TLS握手,總計需要至少2-3個RTT,普通的HTTP協議也需要至少1個RTT才可以完成握手。然而,QUIC協議可以實現在第一個包就可以包含有效的應用數據,從而實現0RTT,但這也是有條件的。0RTT 建連可以說是 QUIC 相比 HTTP2 最大的性能優勢。那什么是 0RTT 建連呢?這里面有兩層含義:

  • 傳輸層 0RTT 就能建立連接
  • 加密層 0RTT 就能建立加密連接
    在這里插入圖片描述

比如上圖左邊是 HTTPS 的一次完全握手的建連過程,需要 3 個 RTT。就算是 Session Resumption,也需要至少 2 個 RTT。

而 QUIC 呢?由於建立在 UDP 的基礎上,同時又實現了 0RTT 的安全握手,所以在大部分情況下,只需要 0 個 RTT 就能實現數據發送,在實現前向加密 的基礎上,並且 0RTT 的成功率相比 TLS 的 Sesison Ticket 要高很多。

    簡單來說,基於TCP協議和TLS協議的HTTP2.0在真正發送數據包之前需要花費一些時間來完成握手和加密協商,完成之后才可以真正傳輸業務數據。但是QUIC則第一個數據包就可以發業務數據,從而在連接延時有很大優勢,可以節約數百毫秒的時間。

在這里插入圖片描述

    QUIC的0RTT也是需要條件的,對於第一次交互的客戶端和服務端0RTT也是做不到的,畢竟雙方完全陌生。

因此,QUIC協議可以分為首次連接和非首次連接,兩種情況進行討論:

首次連接

在這里插入圖片描述

非首次連接

    前面提到客戶端和服務端首次連接時服務端傳遞了config包,里面包含了服務端公鑰和兩個隨機數,客戶端會將config存儲下來,后續再連接時可以直接使用,從而跳過這個1RTT,實現0RTT的業務數據交互。客戶端保存config是有時間期限的,在config失效之后仍然需要進行首次連接時的密鑰交換。

4.前向糾錯

前向糾錯是通信領域的術語,看下百科的解釋:

前向糾錯也叫前向糾錯碼Forward Error Correction 簡稱FEC;是增加數據通訊可信度的方法,在單向通訊信道中,一旦錯誤被發現,其接收器將無權再請求傳輸。

FEC是利用數據進行傳輸冗余信息的方法,當傳輸中出現錯誤,將允許接收器再建數據。

就是做校驗的,看看QUIC協議是如何實現的:
    QUIC每發送一組數據就對這組數據進行異或運算,並將結果作為一個FEC包發送出去,接收方收到這一組數據后根據數據包和FEC包即可進行校驗和糾錯。

5.連接遷移

    網絡切換幾乎無時無刻不在發生。TCP協議使用五元組來表示一條唯一的連接,當我們從4G環境切換到wifi環境時,手機的IP地址就會發生變化,這時必須創建新的TCP連接才能繼續傳輸數據。

    QUIC協議基於UDP實現摒棄了五元組的概念,使用64位的隨機數作為連接的ID,並使用該ID表示連接。基於QUIC協議之下,我們在日常wifi和4G切換時,或者不同基站之間切換都不會重連,從而提高業務層的體驗。

在這里插入圖片描述

6.改進的擁塞控制

    TCP 的擁塞控制實際上包含了四個算法:慢啟動,擁塞避免,快速重傳,快速恢復。
    QUIC 協議當前默認使用了 TCP 協議的 Cubic 擁塞控制算法 ,同時也支持 CubicBytes, Reno, RenoBytes, BBR, PCC 等擁塞控制算法。

    從擁塞算法本身來看,QUIC 只是按照 TCP 協議重新實現了一遍`,那么 QUIC 協議到底改進在哪些方面呢?主要有如下幾點:

6.1 可插拔

能夠非常靈活地生效,變更和停止,體現在如下方面:

  • 應用程序層面就能實現不同的擁塞控制算法,不需要操作系統,不需要內核支持。這是一個飛躍,因為傳統的 TCP 擁塞控制,必須要端到端的網絡協議棧支持,才能實現控制效果。而內核和操作系統的部署成本非常高,升級周期很長,這在產品快速迭代,網絡爆炸式增長的今天,顯然有點滿足不了需求。
  • 即使是單個應用程序的不同連接也能支持配置不同的擁塞控制。就算是一台服務器,接入的用戶網絡環境也千差萬別,結合大數據及人工智能處理,我們能為各個用戶提供不同的但又更加精准更加有效的擁塞控制。比如 BBR 適合,Cubic 適合。
  • 應用程序不需要停機和升級就能實現擁塞控制的變更,我們在服務端只需要修改一下配置,reload 一下,完全不需要停止服務就能實現擁塞控制的切換。STGW 在配置層面進行了優化,我們可以針對不同業務,不同網絡制式,甚至不同的 RTT,使用不同的擁塞控制算法。

6.2單調遞增的 Packet Number

    TCP 為了保證可靠性,使用了基於字節序號的 Sequence Number 及 Ack 來確認消息的有序到達。QUIC同樣是一個可靠的協議,它使用 Packet Number 代替了 TCP 的 sequence number,並且每個 Packet Number 都嚴格遞增,也就是說就算 Packet N丟失了,重傳的 Packet N 的 Packet Number 已經不是 N,而是一個比 N 大的值。而 TCP 呢,重傳 segment 的 sequence number 和原始的 segment 的 Sequence Number 保持不變,也正是由於這個特性,引入了 Tcp 重傳的歧義問題。

在這里插入圖片描述

    如上圖所示,超時事件 RTO 發生后,客戶端發起重傳,然后接收到了 Ack 數據。由於序列號一樣,這個 Ack 數據到底是原始請求的響應還是重傳請求的響應呢?不好判斷。如果算成原始請求的響應,但實際上是重傳請求的響應(上圖左),會導致采樣 RTT 變大。如果算成重傳請求的響應,但實際上是原始請求的響應,又很容易導致采樣 RTT 過小。

    由於 Quic 重傳的 Packet 和原始 Packet 的 Pakcet Number 是嚴格遞增的,所以很容易就解決了這個問題。

在這里插入圖片描述

    如上圖所示,RTO 發生后,根據重傳的 Packet Number 就能確定精確的 RTT 計算。如果 Ack 的 Packet Number 是 N+M,就根據重傳請求計算采樣 RTT。如果 Ack 的 Pakcet Number 是 N,就根據原始請求的時間計算采樣 RTT,沒有歧義性。但是單純依靠嚴格遞增的 Packet Number 肯定是無法保證數據的順序性和可靠性。QUIC 又引入了一個 Stream Offset 的概念。即一個 Stream 可以經過多個 Packet 傳輸,Packet Number 嚴格遞增,沒有依賴。Packet 里的 Payload 如果是 Stream 的話,就需要依靠 Stream 的 Offset
來保證應用數據的順序。

在這里插入圖片描述

  如圖所示,發送端先后發送了 Pakcet N 和 Pakcet N+1,Stream 的Offset 分別是 x 和 x+y。假設 Packet N 丟失了,發起重傳,重傳的 Packet Number 是 N+2,但是它的 Stream 的 Offset 依然是 x,這樣就算 Packet N + 2 是后到的,依然可以將 Stream x 和 Stream x+y 按照順序組織起來,交給應用程序處理。

6.3 不允許 Reneging

  什么叫 Reneging 呢?就是接收方丟棄已經接收並且上報給 SACK 選項的內容 。TCP 協議不鼓勵這種行為,但是協議層面允許這樣的行為。主要是考慮到服務器資源有限,比如 Buffer 溢出,內存不夠等情況。Reneging 對數據重傳會產生很大的干擾。因為 Sack 都已經表明接收到了,但是接收端事實上丟棄了該數據。QUIC 在協議層面禁止 Reneging,一個 Packet 只要被 Ack,就認為它一定被正確接收,減少了這種干擾。

6.4 更多的 Ack 塊

  TCP 的 Sack 選項能夠告訴發送方已經接收到的連續 Segment 的范圍,方便發送方進行選擇性重傳。由於 TCP 頭部最大只有 60 個字節,標准頭部占用了 20 字節,所以 Tcp Option 最大長度只有 40 字節,再加上 Tcp Timestamp option 占用了 10 個字節 ,所以留給 Sack 選項的只有 30 個字節。每一個 Sack Block 的長度是 8 個,加上 Sack Option 頭部 2 個字節,也就意味着 Tcp Sack Option 最大只能提供 3 個 Block。但是 Quic Ack Frame 可以同時提供 256 個 Ack Block,在丟包率比較高的網絡下,更多的 Sack Block 可以提升網絡的恢復速度,減少重傳量。

6.5 Ack Delay 時間

  Tcp 的 Timestamp 選項存在一個問題 ,它只是回顯了發送方的時間戳,但是沒有計算接收端接收到 segment 到發送 Ack 該 segment 的時間。這個時間可以簡稱為 Ack Delay。這樣就會導致 RTT 計算誤差。如下圖:

在這里插入圖片描述

可以認為 TCP 的 RTT 計算:
在這里插入圖片描述

而 Quic 計算如下:
在這里插入圖片描述

6.6 基於 stream 和 connecton 級別的流量控制

  QUIC 的流量控制 類似 HTTP2,即在 Connection Stream 級別提供了兩種流量控制。為什么需要兩類流量控制呢?主要是因為 QUIC 支持多路復用。Stream 可以認為就是一條 HTTP 請求。Connection 可以類比一條 TCP 連接。多路復用意味着在一條 Connetion 上會同時存在多條 Stream。既需要對單個 Stream 進行控制,又需要針對所有 Stream 進行總體控制。

QUIC 實現流量控制的原理:

通過 window_update 幀告訴對端自己可以接收的字節數,這樣發送方就不會發送超過這個數量的數據。通過 BlockFrame 告訴對端由於流量控制被阻塞了,無法發送數據。QUIC 的流量控制和 TCP 有點區別,TCP 為了保證可靠性,窗口左邊沿向右滑動時的長度取決於已經確認的字節數。如果中間出現丟包,就算接收到了更大序號的 Segment,窗口也無法超過這個序列號。但 QUIC 不同,就算此前有些 packet 沒有接收到,它的滑動只取決於接收到的最大偏移字節數。

在這里插入圖片描述

  • 針對 Stream:
    在這里插入圖片描述
  • 針對 Connection:
    在這里插入圖片描述
    同樣地,STGW 也在連接和 Stream 級別設置了不同的窗口數。最重要的是,我們可以在內存不足或者上游處理性能出現問題時,通過流量控制來限制傳輸速率,保障服務可用性。

二.QIUIC編譯運行與測試

  由於go語言的簡潔性以及編譯的便捷性,本文將選用quic-go進行quic協議的分析,該庫是完全基於go語言實現,可以用於構建客戶端或服務端。

1.首先在Liunx18.04下下載golang編譯器,要求go版本為1.14+

sudo snap install go

2.接着在本地下載quic-go項目源碼

git clone https://github.com/lucas-clemente/quic-go.git

下載完成后找到quic-go文件並進入cd quic-go

測試quic-go源碼:go test -v ./...

截屏2021-01-30 上午10.12.53

項目測試正確,可以進行編譯。

3.編譯

先進入example文件夾,里面是一個quic實例

  • 服務端

    編譯:go build main.go

    運行:./main -qlog -v -tcp

    注意1:必須帶上-tcp參數。

      雖然quic是基於UDP的協議,但是因為瀏覽器第一次訪問時仍然是要通過TCP進行的。

    注意2:

      從quic-go v0.19.x開始,可能會看到有關接收緩沖區大小的警告。

      高帶寬連接上的QUIC傳輸可能受到UDP接收緩沖區大小的限制。該緩沖區保存內核已接收但應用程序尚未讀取的數據包(在這種情況下為quik-go)。一旦該緩沖區填滿,內核將丟棄任何新的傳入數據包。因此,quic-go嘗試增加緩沖區大小。要做到這一點的方式是特定於操作系統的,而我們現在有一個實現linuxwindowsdarwin。但是,僅允許應用程序將緩沖區大小增加到內核中設置的最大值。不幸的是,在Linux上,該值很小,對於高帶寬QUIC傳輸來說太小了。

      建議通過運行以下命令增加最大緩沖區大小:sysctl -w net.core.rmem_max=2500000;此命令會將最大接收緩沖區大小增加到大約2.5 MB。

    截屏2021-01-30 下午5.54.27

  • 客戶端

    進入client文件夾cd quic-go/example/client

    先選擇quic的draft-29版本,打開main.go,在60行上添加

    qconf.Versions =[]protocol.VersionNumber{protocol.VersionDraft29},因為使用了protocol,所以需要引入這個文件,在文件頭添加"github.com/lucas-clemente/quic-go/internal/protocol"

    截屏2021-01-30 下午3.25.47

    當使用-tcp后,自動使用默認設置,在默認設置中並未開啟draft-29版本的支持,因此需要手動添加版本支持,打開internal/protocol/version.go文 件,

    將第30行改為var SupportedVersions = []VersionNumber{VersionTLS, VersionDraft29}

    截屏2021-01-30 下午3.24.33

    准備工作完成了,開始編譯go build main.go

    接着使用./main -v -insecure -keylog ssl.log 即可訪問支持quic協議的網站。

4.測試

  • 客戶端測試

      使用./main -v -insecure -keylog key.log https://quic.rocks:4433/訪問測試網站,可以看見最后成功輸出了網頁的內容 “You have successfully loaded quic.rocks using QUIC!”,使用的協議為HTTP/3,並且錯誤代碼為0x100,即未發生錯誤。

    截屏2021-01-30 下午3.06.33

      在wireshark中的Edit -> Preferences -> protocol -> TLS -> (pre)-master-secret log filename設置為上面輸出的key.log文件,用來對quic的payload進行解密,之后可以看到客戶端的完整的請求過程,包括1-RTT的握手,HTTP3數據發送,斷開連接等。

    截屏2021-01-30 下午8.57.53

    可以看到,第一個QUIC包的類型為Initial,進行了0-RTT的初始化。

    注意:

    舊版wireshark檢測不到QUIC數據包,必須更新成新版wireshark。

    Ubuntu新版wireshark升級詳情:https://blog.csdn.net/bryanting/article/details/53327575

  • 服務器測試

    在example下運行./mian

    在client下運行./main https://localhost:6121/demo/tile

    截屏2021-01-30 下午9.13.16

狀態碼200表示成功,HTTP/3協議。

三、QUIC源代碼分析

客戶端源代碼:

roundTripper

在客戶端(example/main.go)代碼中,通過http3.RoundTripper建立了一個中間件,之后將roundTripper傳遞給http.Client建立了一個http客戶端,並以此來發起http請求。

roundTripper := &http3.RoundTripper{
		TLSClientConfig: &tls.Config{
			RootCAs:            pool,
			InsecureSkipVerify: *insecure,
			KeyLogWriter:       keyLog,
		},
		QuicConfig: &qconf,
	}
	defer roundTripper.Close()
	hclient := &http.Client{
		Transport: roundTripper,
	}

	var wg sync.WaitGroup
	wg.Add(len(urls))
	for _, addr := range urls {
		logger.Infof("GET %s", addr)
		go func(addr string) {
			rsp, err := hclient.Get(addr)
			if err != nil {
				log.Fatal(err)
			}
			logger.Infof("Got response for %s: %#v", addr, rsp)

			body := &bytes.Buffer{}
			_, err = io.Copy(body, rsp.Body)
			if err != nil {
				log.Fatal(err)
			}
			if *quiet {
				logger.Infof("Request Body: %d bytes", body.Len())
			} else {
				logger.Infof("Request Body:")
				logger.Infof("%s", body.Bytes())
			}
			wg.Done()
		}(addr)
	}


type RoundTripper interface { 
       RoundTrip(*Request) (*Response, error)
}

  http3.RoundTripper實現了net.RoundTripper接口,使http客戶端將發起請求的過程交由該中間件來處理當只有一個函數RoundTrip接受一個http請求,返回http響應。

  在http3.RoundTripper的實現中,將請求交給了RoundTripOpt函數來處理。該函數中首先判斷請求是否合法,如果不合法就關閉請求,合法就會通過cl, err := r.getClient(hostname, opt.OnlyCachedConn)來獲取quic客戶端。在getClient函數中,通過hash表來獲取quic的client,如果不存在就會通過newClient函數建立新client。當獲取到client之后,又就會通過client.RoundTrip函數發起請求。在client.RoundTrip中,在發起請求之前,會調用authorityAddr來確保源地址不是偽造的。當第一次發送請求時會調用dial函數進行握手,如果使用0rtt請求,就立即發送請求,否則在當握手完成后通過doRequest發出請求。

DialAddr

func DialAddr(addr string,tlsConf *tls.Config,config *Config,
) (Session, error) {
	return DialAddrContext(context.Background(), addr, tlsConf, config)
}

函數的參數列表:

addr表示服務端的地址,tlsConf表示tls的配置,最后一個config表示QUIC的配置,當填入nil的時候將使用默認配置。

config *Config常用選項:

  • HandshakeIdleTimeout:握手延遲
  • MaxIdleTimeout:雙方沒有發送消息的最大時間,超過這個時間則斷開
  • AcceptToken:令牌接收
  • MaxReceiveStreamFlowControlWindow:最大的接收流控制窗口(針對Stream)
  • MaxReceiveConnectionFlowControlWindow:最大的針對連接的可接收的數據窗口(針對一個Connection可以有多少最大的數據窗口)
  • MaxIncomingStreams:一個連接最大有多少Stream

返回值(Session, error)

  • error錯誤處理
  • Session:一個接口,表示連接會話,通過它可以調用一些方法來完成后續操作。

DialAddr只是一個封裝函數,現在向下追溯DialAddrContext,

func DialAddrContext(ctx context.Context,addr string,tlsConf *tls.Config,config *Config,
) (Session, error) {
	return dialAddrContext(ctx, addr, tlsConf, config, false)
}

相比於DialAddr,唯一的不同在於多加了一個context.Context類型。

上下文context.Context是用來設置截止日期、同步信號,傳遞請求相關值的結構體。可以理解為一個同步信號,即對信號同步以減少資源浪費。常用的context.Background()返回一個預定義的類型。

到這里發現DialAddrContext依然只是一個包裝函數,現在繼續向下追溯DialAddrContext

func dialAddrContext(
	ctx context.Context, addr string, tlsConf *tls.Config,
	config *Config, use0RTT bool,
) (quicSession, error) {
	udpAddr, err := net.ResolveUDPAddr("udp", addr)
	if err != nil {
		return nil, err
	}
	udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
	if err != nil {
		return nil, err
	}
	return dialContext(ctx, udpConn, udpAddr, addr, tlsConf, config, use0RTT, true)
}

因為QUIC基於udp,先調用net.ResolveUDPAddr("udp", addr),接着在UDP上聽

net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0}),最后根據返回的結果調用dialContext函數統一處理。繼續看dialContext函數

func dialContext(
	ctx context.Context, pconn net.PacketConn, remoteAddr net.Addr,
	host string, tlsConf *tls.Config, config *Config,
	use0RTT bool, createdPacketConn bool,
) (quicSession, error) {
	if tlsConf == nil {
		return nil, errors.New("quic: tls.Config not set")
	}
	if err := validateConfig(config); err != nil {
		return nil, err
	}
	config = populateClientConfig(config, createdPacketConn)
	packetHandlers, err := getMultiplexer().AddConn(pconn, config.ConnectionIDLength, config.StatelessResetKey, config.Tracer)
	if err != nil {
		return nil, err
	}
	c, err := newClient(pconn, remoteAddr, config, tlsConf, host, use0RTT, createdPacketConn)
	if err != nil {
		return nil, err
	}
	c.packetHandlers = packetHandlers

	if c.config.Tracer != nil {
		c.tracer = c.config.Tracer.TracerForConnection(protocol.PerspectiveClient, c.destConnID)
	}
	if err := c.dial(ctx); err != nil {
		return nil, err
	}
	return c.session, nil
}

getMultiplexer建立一個多路復用。接着返回一個新的客戶端結構newClient

session.OpenStreamSync

stream, err := session.OpenStreamSync(context.Background())
OpenStreamSync`:打開一個新的雙向的`QUIC Stream

追蹤這個函數,我們找到一個函數OpenStreamSync,這個函數里面是一些處理多線程的語句,后面有一個openStream函數。

func (m *outgoingBidiStreamsMap) openStream() streamI {
	s := m.newStream(m.nextStream)
	m.streams[m.nextStream] = s
	m.nextStream++
	return s
}

因為Session是一個connection,它包含多個Stream,這個函數就是新建一個流,然后加入這個Session,現在繼續查看newStream,它返回一個新創建的Stream

type stream struct {
	receiveStream
	sendStream

	completedMutex         sync.Mutex
	sender                 streamSender
	receiveStreamCompleted bool
	sendStreamCompleted    bool

	version protocol.VersionNumber
}

Stream里面有receiveStreamsendSatream、同步用的鎖completedMutex

receiveStream

  • StreamID:流ID
  • io.Reader:讀接口
  • CancelRead:是否禁止接收流
  • SetReadDeadline:讀超時設置

sendSatream

  • StreamID:流ID
  • io.Write:寫接口
  • CancelWrite:是否禁止寫
  • Context:上面提到過的用來同步的結構體
  • SetWriteDeadline:設置寫超時

最后返回的是stream。

服務端源代碼:

ListenAddr

ListenAddr的核心代碼如下:

listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)

進入server.go/ListenAddr:

func ListenAddr(addr string, tlsConf *tls.Config, config *Config) (Listener, error) {
	return listenAddr(addr, tlsConf, config, false)
}
func listenAddr(addr string, tlsConf *tls.Config, config *Config, acceptEarly bool) (*baseServer, error) {
	udpAddr, err := net.ResolveUDPAddr("udp", addr)
	if err != nil {
		return nil, err
	}
	conn, err := net.ListenUDP("udp", udpAddr)
	if err != nil {
		return nil, err
	}
	serv, err := listen(conn, tlsConf, config, acceptEarly)
	if err != nil {
		return nil, err
	}
	serv.createdPacketConn = true
	return serv, nil
}

這個函數返回一個baseServer,這是一個QUIClistener

該結構體添加了如下的結構體成員

  • sessionQueue:一個客戶端的Session隊列
  • sessionQueueLen:客戶端的Session隊列的長度

接着新建一個線程,不斷地監聽端口,等待一個新的客戶端連接請求

Accept

核心代碼:

sess, err := listener.Accept(context.Background())

進入Accept函數

func (s *baseServer) Accept(ctx context.Context) (Session, error) {
	return s.accept(ctx)
}

atomic.AddInt32(&s.sessionQueueLen, -1)

這里當收到一個新的請求的時候添加到sessionQueue中,返回一個客戶端Session,用這個Session可以和客戶端進行發送和接收數據。

AddConn

進入liten函數的AddConn,查看多路復用添加連接的過程。

type connMultiplexer struct {
	mutex sync.Mutex
	conns                   map[string] /* LocalAddr().String() */ connManager
	newPacketHandlerManager func(net.PacketConn, int, []byte, logging.Tracer, utils.Logger) (packetHandlerManager, error)
	logger utils.Logger
}
func (m *connMultiplexer) AddConn(
	c net.PacketConn, connIDLen int, statelessResetKey []byte, tracer logging.Tracer,
) (packetHandlerManager, error) {
	m.mutex.Lock()
	defer m.mutex.Unlock()

	addr := c.LocalAddr()
	connIndex := addr.Network() + " " + addr.String()
	p, ok := m.conns[connIndex]
	if !ok {
		manager, err := m.newPacketHandlerManager(c, connIDLen, statelessResetKey, tracer, m.logger)
		if err != nil {
			return nil, err
		}
		p = connManager{
			connIDLen:         connIDLen,
			statelessResetKey: statelessResetKey,
			manager:           manager,
			tracer:            tracer,
		}
		m.conns[connIndex] = p
	} else {
		if p.connIDLen != connIDLen {
			return nil, fmt.Errorf("cannot use %d byte connection IDs on a connection that is already using %d byte connction IDs", connIDLen, p.connIDLen)
		}
		if statelessResetKey != nil && !bytes.Equal(p.statelessResetKey, statelessResetKey) {
			return nil, fmt.Errorf("cannot use different stateless reset keys on the same packet conn")
		}
		if tracer != p.tracer {
			return nil, fmt.Errorf("cannot use different tracers on the same packet conn")
		}
	}
	return p.manager, nil
}

connMultiplexer這個結構定義了互斥鎖、連接map、新建函數和日志處理。AddConn函數首先加了互斥鎖,然后將連接信息新建了一個連接管理器connManager,加入到conns這個map當中,map以ip地址(如:"udp xx.xx.xx.xx:80")作為key,連接管理器作為value。

四、QUIC請求流程分析

時序圖

img

發送包

  握手的函數調用棧:

dial -> dialAddr -> DialAddrEarly -> DialAddrEarlyContext -> dialAddrContext -> dialContext -> newClient -> client.dial -> newClientSession -> session.run -> RunHandshake ->

conn.Handshake -> clientHandshake

  最終在Conn.clientHandshake函數中完成了握手的設置,之后通過clientHandshakeState.handshark函數完成了發送等工作。

  在newClient函數中,通過generateConnectionID和generateConnectionIDForInitial生成了srcConnIDdestConnID

  在handshark函數中,調用establishKeys函數,完成了密鑰的生成,之后調用sendFinished函數,將Client Hello幀寫入到TLS Record層,完成握手包的發送。

接收包

  在session.run中的runloop中,通過select對接收通道進行監聽,當收到數據包時,就會調用handlePacketImpl -> handleSinglePacket -> handleUnpackedPacket函數進行處理。

  在handleUnpackedPacket函數中,如果是第一個包,就會讀取其SrcConnectionID,將其設置為該連接的destination connection ID;之后對包中的幀依次進行讀取,並使用parseFrame函數進行判斷,並調用對應函數進行解析,最后調用handleFrame函數中調用相關函數進行處理。

  在握手過程中,接收的第一個Initial包為合並包(coalesced packet),第一個幀為ACK幀,通過parseAckFrame進行解析,使用handleAckFrame函數進行處理;第二個幀為Crypto幀,消息為Server Hello,通過parseCryptoFrame函數解析,handleCryptoFrame函數進行處理,該函數會通過session.cryptoStreamManager對密鑰信息進行處理。之后第二個Handshake包中只有一個Crypto幀,消息類型為Encrypted Extensions。第三個quic包中包含了一個Stream幀,stream id為3,這個幀會通過handleStreamFrameImpl進行處理,在該函數中會將數據push到frameQueue隊列中去,之后通過signalRead函數來通知數據包的到達。該幀的內容為HTTP3的SETTINGS幀。

HTTP3數據傳輸

  在第二個RTT中,client先通過Initial包發送ACK幀對收到的包進行確認,之后再通過Handshake包發送了CRYPTO幀和ACK幀,此CRYPTO幀的消息為Handshark protocol: Finished。最后再分別發送了streamid為0和2的HTTP3 HEADERS幀和SETTINGS幀。streamid為0的HEADERS包即為http請求,該包使用了QPACK方法進行壓縮,該方法與HTTP2的HPACK類似,而根據QPACK的定義,streamid為2和3的stream分別為encoder streamdecoder stream,即兩個SETTINGS幀。之后client接收到了Handshark包,其中包含一個ACK幀。
此時,1-RTT的握手過程已經結束,因此接下來收到的包的類型就變為了Short header packet,收到的第一個包HANDSHARK_DONE類型,說明握手完成。最后,服務端返回了一個HTTP3的DATA幀,該幀中即包含了請求的響應數據,在收到數據后,客戶端就發送了一個CONNECTION_CLOSE的幀關閉連接

五、總結

  雖然QUIC相比於以前的通信協議有更大的進步,能具有更低的延遲和更好的安全性,但距離廣泛應用還有很有一段時間。QUIC 作為一個新的傳輸層協議,它在設計上針對 TCP的不足進行了很多優化。它提供的多路傳輸、快速握手等新特性使得它和 TCP相比在理論上可以獲得更低的數據傳輸延時。QUIC在大部分情況下的確能比 TCP 達到更低的傳輸延時,但是仍然有部分情況下 QUIC 的表現不如 TCP。這些 QUIC 性能表現較差的場景往往是擁塞算法的選擇、服務器部署等外部因素造成的,而非QUIC本身的設計缺陷。因此,QUIC 的軟件實現仍然有很大的進步空間。受限於個人的水平,文章依然還有很多不足之處,希望讀者批評指正。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM