傳送端衝太快的兩種情況
在前面的指南裡,你看到 TCP 如何用檢查碼、確認(ACK)、序號、計時器與滑動視窗,把不可靠的網路變成一條整齊、依序的位元組串流。但可靠性只是故事的一半。即使每個位元組都正確抵達,傳送端仍可能只因為送得比別人能消化的還快而闖禍。要擔心的「別人」有兩種,截然不同,而 TCP 把它們當成兩個獨立的問題處理。
第一種「別人」是中間的網路——也就是兩台主機之間的路由器與鏈路。如果大家都全速猛灌資料,路由器內的佇列會滿溢、封包會被丟棄,而防止這件事正是壅塞控制的工作(你在上一篇已見過它)。第二種「別人」則是接收端的電腦本身。接收端把位元組從連線收進記憶體裡的一個緩衝區,應用程式則在有空時才去讀那個緩衝區。若傳送端灌進位元組的速度比應用程式排空的速度還快,緩衝區就會被填滿、溢位。保護接收端不被傳送端壓垮,正是流量控制的工作,也是本篇的主題。
接收視窗:接收端公告的一個數字
TCP 用一個漂亮又簡單的把戲解決流量控制。接收端在每一個送回的區段裡,都帶上一個 16 位元的欄位,稱為接收視窗(常寫成 rwnd)。這個數字白話地說就是:我現在還有多少空閒緩衝空間可以收。傳送端保證:在傳輸中尚未被確認的資料量,絕不超過這個視窗所允許的大小。當接收端的應用程式把位元組從緩衝區讀走、騰出空間時,接收端就公告一個更大的視窗;若應用程式卡住、緩衝區被塞滿,公告的視窗就會朝零收縮。
可以把它想成:你一邊把水從水壺倒進杯子,朋友一邊從杯子裡喝。你盯著水位,只在還有空間時才倒、且倒多快視空間而定。接收視窗就像朋友不斷告訴你杯子有多滿。這就是TCP 流量控制的運作:步調由接收端而非傳送端決定,因為只有接收端知道自己的應用程式排空緩衝區有多快。
Receiver buffer (say 64 KB total)
[#### already received, not yet read ####|...... free ......]
^
rwnd = free space
In every ACK the receiver sends: rwnd = (free bytes right now)
The sender's rule: unacked bytes in flight <= rwnd
App reads 10 KB -> free space grows -> next ACK advertises larger rwnd
App stalls -> free space = 0 -> next ACK advertises rwnd = 0零視窗,以及對話如何重新開始
當接收端公告視窗為零時會怎樣?傳送端必須停下來。它被告知沒有空間,於是把資料擱著等待。但這帶來一個隱患:接收端終究會騰出空間、公告更大的視窗,可是那個更新是搭在 ACK 裡傳回的——而 ACK 不會被重傳。若那個帶著「視窗等於 100」的區段遺失了,傳送端就會永遠等一個永遠不來的許可,接收端則永遠等一個它早已邀請進來的資料。這就是死結(deadlock)。
TCP 用一個堅持計時器(persist timer)避開這個死結。當視窗為零時,傳送端不會就此沉默——它會週期性地送出一個小小的、只有一個位元組的探測(視窗探測,window probe)。接收端用它目前的視窗回應。若視窗仍是零,沒關係,傳送端就退讓、稍後再探一次;但一旦有空間騰出,下一次探測的回應就會捎來好消息。這個探測就像傳送端輕輕敲門問:現在有空位了嗎?
回想可靠性那篇裡的往返時間(RTT):每一次視窗更新都要花一個來回才會生效。這就是為什麼一條又快又遠的鏈路——高頻寬延遲乘積的路徑——需要一個大視窗才能保持填滿。經典的 16 位元視窗上限只有 65,535 位元組,遠遠不足以餵飽一條 1 Gbps、衛星等級距離的鏈路,所以現代 TCP 會用視窗縮放(window scaling)選項把它倍增。
依序交付藏著一個代價
現在我們轉向一個瑕疵,而它就住在那個讓 TCP 如此宜人的特性裡。TCP 保證應用程式讀到位元組的順序,與送出的順序完全一致——這就是位元組串流的承諾。為了守住它,TCP 必須嚴格依序交付位元組。於是,若 5 號區段遺失,但 6、7、8 號都平安抵達,接收端還不能把 6、7、8 交給應用程式。它們完整、正確地待在緩衝區裡,卻成了人質,等著 5 號的重傳來把缺口補上。
這就是佇列前端阻塞:排在隊伍最前面的一個人卡住了,後面所有人就被攔下,即使他們其實早就準備好了。想像一條只能單行的結帳隊伍,最前面的人找不到錢包;他後面的客人手裡正握著剛好的零錢,但規則說沒人能超越最前面,於是大家一起等。這個延遲不是因為網路對後面的資料變慢——它們明明順利到了——而是排序規則本身造成的。
對單一檔案的下載而言,這正是你要的,代價也很小。痛點出現在許多彼此獨立的東西共用同一條 TCP 連線時。網頁就是最好的例子。在 HTTP/2 裡,瀏覽器透過一條 TCP 連線抓取一個頁面的圖片、腳本與樣式表,把它們當成不同的邏輯串流交錯傳送。這聽起來很聰明——直到一個遺失的封包卡住了整條位元組串流。因為所有串流都騎在同一條依序管道上,圖片那個遺失的封包也會一起凍住腳本和樣式表,即使它們的位元組早就到了。一個人絆倒,全體被擋。
QUIC:繞過阻塞,搬到 TCP 之下
修法不能靠修補 TCP,因為 TCP 的整份契約就是一條依序的位元組串流——阻塞是內建的。於是網頁的設計者繞了過去。QUIC 是一個較新的傳輸協定,建立在 UDP 之上;UDP 只提供光禿禿的盡力而為資料報,本身不強加任何排序。在這層薄薄的地基上,QUIC 重建了可靠性、流量控制與加密,但帶著一個關鍵的轉折:它承載許多彼此獨立的串流,每條串流各有自己的排序與自己的序號空間。
如今當承載圖片的封包遺失時,只有圖片那條串流要等它的重傳;腳本與樣式表的串流彼此獨立,它們的位元組會一路直送到應用程式。佇列前端阻塞被侷限在真正掉了資料的那一條串流,而不再凍結全部。HTTP/3 不過就是把 HTTP 搭在 QUIC 上傳送,這正是 HTTP/3 終於擺脫了當年限制 HTTP/2 的傳輸層阻塞的原因。
把它串起來
退一步,傳輸層的輪廓就清楚了。傳送端同時受兩道天花板節制——接收視窗讓它不致壓垮接收端,壅塞視窗讓它不致壓垮網路——它送出的速度不會快過兩者中較小者。接收視窗是接收端在說話;壅塞視窗是網路透過丟棄的封包在低語。兩者合起來,讓一條連線既能有禮貌地共享網際網路,又絕不淹沒遠端那台機器。
- 接收端在每一個 ACK 裡公告接收視窗(rwnd),說明它目前有多少空閒緩衝。
- 傳送端把尚未被確認、傳輸中的位元組量保持在 min(rwnd, 壅塞視窗) 之內——流量控制與壅塞控制一起綁住它。
- 若 rwnd 歸零,傳送端停下,但會週期性送出一個位元組的視窗探測,使得遺失的視窗更新永遠不會讓連線陷入死結。
- 因為 TCP 嚴格依序交付一條位元組串流,單一遺失的封包會對它後面的一切造成佇列前端阻塞。
- QUIC 跑在 UDP 之上、每條串流各自獨立排序,把那個阻塞侷限在單一串流——而 HTTP/3 就是 HTTP over QUIC。