在一根壞掉的管子之上許下的承諾
上一篇我們把 UDP 和 TCP 並排比較:UDP 是明信片,TCP 是有逐字稿的電話。現在我們把 TCP 撬開,問那個更深的問題——它到底怎麼讓那份逐字稿維持完美?記得它底下的網路層只提供盡力而為的傳遞。一個封包可能因佇列溢位被丟掉,可能因為兩個封包走了不同路線而亂序到達,可能被複製成兩份,或被電氣雜訊翻掉一個位元。網路什麼都不保證,TCP 卻不知怎地什麼都保證。
誠實的答案是:TCP 並沒有修好網路。它沒辦法阻止封包遺失。它做的是:只靠兩端的檢查與重試,在一個不可靠的通道之上建起可靠資料傳輸——這正是端對端原則在實際運作。訣竅是一套小工具,它們合力讓接收端偵測出每一種損傷、讓寄件者修復它。四樣工具挑大樑:檢查碼、序號、確認、計時器。第五樣,滑動視窗,讓整件事變快、而不是慢得令人發指。
抓出損毀:檢查碼
在 TCP 能從遺失中恢復之前,它得先察覺損傷。那是檢查碼的工作。當寄件者組出一個區段,它以一種定義好的方式把位元組加總,把結果寫進標頭。接收端對它收到的內容重做同一個加總並比對。若兩者不符,就是有個位元在途中被翻掉了,整個區段會被丟棄——當成它根本沒來過。接下來,丟失恢復的機制接手。
要誠實看待檢查碼能做與不能做的事。它能便宜地偵測常見錯誤,但 TCP 的檢查碼相對較弱——它可能漏掉某些損毀模式,這也是謹慎的應用程式會自己再加上更強完整性檢查的原因之一。而且偵測不等於修復:TCP 不會試圖就地補好壞掉的位元。它只是把該區段丟棄,靠重送來送一份新的副本過來。偵測加重送、而非修復,就是整套策略。
編號、確認、計時器:恢復迴路
現在談排序與丟失。TCP 替串流的每個位元組蓋上一個序號——把它想成一封拆進許多信封的長信上的頁碼。有了頁碼,接收端即使收到亂序的區段也能把位元組按正確次序擺好,還能發現重複(已經有的那一頁)或缺口(少了的那一頁)。這些號碼數的是位元組、不是封包,而且不從 1 開始:兩端在交握期間各挑一個全新、難以猜測的初始序號。
接收端用一個確認(ACK)回報進度。TCP 的 ACK 是累積的:一個 ACK 號說「我已收到直到這裡為止的每個位元組;我接下來想要的是第 N 號」。所以一個 5001 的 ACK 意思是「我手上有位元組 1 到 5000;下一個請給我 5001」。寄件者看著 ACK 一個個回來。如果預期的 ACK 一直沒回來,一個重送計時器最終會觸發,寄件者就重送那些未被確認的資料。逾時長度依量到的來回時間調整——長到不會重送只是比較慢的資料,又短到不會在真的丟失後乾等。
- 寄件者送出帶有位元組 1–1000、1001–2000、2001–3000、3001–4000 的區段。
- 1001–2000 那個區段在壅塞的佇列裡遺失了。另外三個都好好到達。
- 接收端因為少了那段缺口,持續回覆 ACK 1001——「我手上還是只到 1000;我在等 1001。」它把 2001–4000 緩衝起來,但還無法依序交付。
- 寄件者針對那段缺失位元組的計時器逾時(或者三個重複的 ACK 1001 到了——見下文),於是它重送位元組 1001–2000。
- 接收端終於拿到 1001–2000,把它嵌進缺口,現在就能把 1001 到 4000 一口氣依序交給應用程式,並回覆 ACK 4001。
為什麼一次一個太慢:滑動視窗
有個天真的可靠做法:送一個區段,等它的 ACK,再送下一個。這叫停等(stop-and-wait),它正確,卻慢得讓人受不了。想像一條來回時間 100 毫秒的鏈路。送一個小區段,然後足足等 100 毫秒才送下一個,意思是鏈路幾乎整段時間都閒置——你只用到一根快管子容量裡微不足道的一小部分。問題不在頻寬,而在你在每一個封包之間都得等地理。
解法是滑動視窗:讓許多區段同時「在途」——已送出但尚未被確認——而不是只有一個。視窗是允許在外的未確認資料量。當 ACK 回來、確認了最早那些位元組,視窗就往前滑,寄件者便獲准在前端放出新的位元組,讓管子持續滿載。這就是管線化(pipelining),也是 TCP 把一個「正確卻爬行」的方案,變成能塞滿一條又快又長鏈路的東西的方法。視窗該多大?理想上大約是頻寬延遲乘積——足以讓整根管子在一個來回內維持滿載的位元組數。
stop-and-wait (one round trip per segment): send 1 ---100ms---> ack ---100ms---> send 2 ---100ms---> ack ... link mostly IDLE; throughput tiny on a long link sliding window (many in flight at once): send 1,2,3,4,5,6,7,8 ----> ----> ----> acks streaming back |<-------- window -------->| as ack for 1 arrives, window slides: now 9 may be sent link stays FULL; throughput limited by capacity, not RTT
更快抓到丟失,以及「依序」的代價
等計時器逾時是偵測丟失的慢路徑。TCP 有更快的感官。回想一下,當接收端收到亂序資料,它會持續重送同一個累積 ACK(「還在等 1001」)。如果寄件者看到同一個位元組的三個重複 ACK,那就是個強烈暗示:下一個區段遺失了、而後面的卻都通過了——於是它立刻重送,不等逾時。這就是快速重傳,也是為什麼 TCP 通常能在大約一個來回內、而非一段漫長逾時內,從單一丟包恢復。
這一切確實很聰明,但要誠實看待代價。依序保證意味著接收端必須把「在缺口之後才到達的位元組」全部扣住,直到缺失的位元組被補上——那些後到、正確收到的位元組只能無用地待在緩衝區裡乾等。這種卡頓叫做行頭阻塞,是「無論如何都依序」無可避免的另一面。這正是為什麼像 QUIC 這種跑在 UDP 之上、能讓獨立串流各自前進的協定被創造出來,以繞過 TCP 的單一串流卡頓——這條線我們會在接下來兩篇繼續拉。