TCP沒那麼難吧?一文帶你詳細瞭解
如今相當多的程式員都是“網際網路程式設計師”,按說,應該對網際網路的基礎協議相當清楚。可惜至少就我的面試經驗來看,許多人這方面缺課太多,簡單說說TCP/IP協議分層就已經難倒了不少人。至於TCP/IP的“三次握手”,能說上來的人就相當少了,如果再問問“為什麼是三次握手”,基本就沒人能答上來了。一般的回答都是“這個太難”,或者“畢業太久,這個忘記了”。
如果臨時抱佛腳,把TCP的三次握手背下來應付面試,確實能做到。但是要回答TCP為什麼是三次握手,而不是兩次或者四次握手,光靠背就不行了——不信你去網路上搜搜看,各種回答都有,眾說紛紜,不少提問者一頭霧水。
TCP相關的知識重要嗎?我覺得挺重要的,這些年來無論網際網路怎麼變化,TCP協議本身都可以承載,仔細探究會發現它的設計的確夠巧妙,有許多值得借鑑的設計思想。
那麼TCP真的很難嗎?為什麼許多人背TCP的握手流程痛苦不堪,複述起來困難重重?我覺得,原因在於大家只把它當成“既存事實”, 就像上中學時候背歷史政治那樣對待。但TCP可不是毫無邏輯的胡說,一旦 你搞清了設計思想和邏輯,就會發現理解起來一點也不困難。所以,今天我來做個簡單講解。
首先說說“三次握手”這個譯名,我確實覺得翻譯有誤(翻譯出版過一百多萬字技術資料,我自信還是有把握的)。我以前總記不住“三次握手”的過程,因為總覺得“握了三次手”,“握手”是雙方共同往中間湊的過程,這明顯和建連流程不符合。後來才發現,“三次握手”的說法大概有問題。
“三次握手”的原文是three-way handshake,three-way更合適的翻譯恐怕是“三步”,所以整個名詞的意思是“需要三個步驟才能建立握手的機制”。這麼解釋的好處是,“步”給人感覺更形象,就是“單方面邁一步”而已。實際上,RFC 793裡說明了,握手過程也可以叫three-message handshake,通過三條訊息來建立的握手。
那麼,為什麼要三步才能建立握手呢?我們可以暫時不理這個問題,想想如果我們自己來設計握手機制,應當怎麼辦。
我們都知道,TCP是可靠的通訊協議,其“可靠性”就在於,任何一方要向另一方發資料(SYN),都必須收到確認迴應(ACK)。同時TCP也是雙向的通訊協議,所以通訊的兩方都可以主動傳送訊息。
這裡要澄清的一點,對許多“網際網路程式設計師”來說,TCP是掩蓋在HTTP之下的,大家熟悉的HTTP,它的經典通訊模式是“一問一答”的,沒有請求就沒有應答。不過這只是HTTP的特性,不是TCP的特性。在TCP協議裡,客戶端和伺服器都可以隨時主動向對方傳送資料——也正是因為如此,改用HTTP/2之後伺服器可以主動推送資訊給客戶端,而不必改動TCP協議。
回到TCP,既然它是雙向、可靠的通訊,可以想見,建立連線就必須確認雙方到對方的通訊都是可靠的,所以大概需要四步,傳送四次訊息。
如果軟體設計都這麼簡單,那就太好了。可惜,世界上沒有那麼簡單的事情。仔細觀察這幅圖,我們會發現幾個問題:
第一,網路通訊的成本是很高的,延遲往往無法預測,哪怕能少傳送一次訊息,也可以大大降低成本,提高效率。所以,建立連線的步驟上限應當是四步,下限是兩步,越少越好。
第二,兩輪SYN/ACK之間必須有關聯,因為它們的功能相對獨立,都是確認到對方的通訊可靠,卻同屬於一個“建立連線”的邏輯操作。如果兩輪完全獨立,那麼如果兩輪中間間隔了特別特別長的時間,根本不是一個正常的建立連線的操作,程式卻無法識別,這顯然是不行的。所以,第二輪SYN/ACK必須要能夠和第一輪SYN/ACK關聯起來。
再仔細看看,第二步和第三步都是從服務端給客戶端發訊息,所以是不是可以合併起來?這樣起碼可以節省了一次網路通訊。
像上面這樣直接在第二步把ACK和SYN合併起來,問題就解決了?
按照之前的分析,節省訊息傳送次數只是考慮之一,還需要考慮的是,第二輪SYN/ACK必須和第一輪SYN/ACK掛鉤。
上面是TCP的資料報,包含了許多的控制位,用來標識連線的狀態。其中最常見的是SYN、ACK、FIN:SYN表示synchronize,在建立連線時使用;ACK表示acknowledge,表示“確認”收到了訊息;FIN表示finish,在斷開連線時使用。
還要注意的兩個東西是SEQ NO和ACK NO。SEQ NO即Sequence Number,服務端和客戶端都會維護自己的SEQ NO,表示“已經發送了多少資料”,單位是位元組;ACK NO即Acknowledge Number,用來回復確認,對應SEQ NO的資料已經收到。單獨說起來,這些概念都容易理解,只是注意不要混淆控制位的ACK和ACK NO——ACK是布林值用來標識資料報的型別,ACK NO是數值用來確認已經收到的資料。
基於上面的知識我們可以知道,在建立連線之初,資料報中的控制位SYN應當設定為1,表示“新建連線”;同時應當包含SEQ NO。此時的SEQ NO有個專門的名字叫ISN,也就是Initial Sequence Number(要注意,ISN只是用來稱呼這個特殊SEQ NO,並不存在專門的ISN欄位)。
在服務端收到第一個SYN訊息的時候,它當然需要傳送ACK響應,但它如何確認其中的SEQ NO“就是”新建連線的ISN,而不是來自姍姍來遲的某個古老連線呢?所以必須向客戶端確認。恰恰因為第二步是ACK,SYN“合二為一”的獨特響應,所以收到這個訊息時,客戶端就知道,既需要響應其中的SYN,也需要核實其中的ACK(如果你仔細讀過RFC793就會知道,其中專門有一段提到了: A three way handshake is necessary because…… )
到了第三步,客戶端返回的訊息裡既包含對應SYN的ACK,表示收到了服務端的訊息,同時設定SEQ NO=ISN+1,確認核實了ISN。服務端收到這條訊息,確認無誤是要建立新連線。至此,連線建立完畢。
大流程看起來就是這樣,也不難理解。不過仔細想想,還是有不少問題得考慮的。比如狀態問題,既然TCP是網路通訊,會發生延遲,那麼在“資訊已經發送,但還沒有收到確認”的時候,應當是有個明確狀態的,否則會發生狀態的錯亂。實際上TCP也確實做到了這點,它背後有一臺完整的狀態機,確保每時每刻,每個動作發生之後,狀態都完全可控,一切盡在掌握,不會出現任何“孤點”和“斷頭路”。
上圖是TCP的狀態轉移圖的區域性,覆蓋了建立連結的狀態,感興趣的讀者可以按照自己實地走走看(說個題外話,“自己模擬在圖上走走”看起來土,其實高科技領域也挺常用。設計波音737的時候,開始大家都不知道發動機怎麼擺比較好,設計師喬·薩特就在紙上畫出機身和發動機的模型,把發動機模型剪下來在飛機各處擺放,最終發現吊在翼下最合適)。
我在之前關於軟體設計的文章裡幾次提到狀態圖、狀態轉移函式,無論是使用者生命週期、訂單流轉過程,都可以用這個工具來解決。遺憾的是,我發現還有許多設計人員不懂得或者不習慣用使用它,實在很可惜。
回到TCP建立連線的過程,我們還要注意ISN。在建立連線時必須先確定ISN,通過它把客戶端和伺服器的計數對齊。通常的教材上說,ISN是隨機生成的,這樣就保證了唯一性。 隨機的目的是保持唯一,但千萬不要以為“隨機就不會重複”,簡單的“取隨機數”是很容易碰撞的。所以傳統的“隨機”方案是維護一個時鐘和一個32位的計數器,時鐘每過4毫秒,計數器自增1。因為2^32毫秒就是差不多4個半小時(MSL,Max Segment Lifetime),這基本超出了任何資料包在網路中的可能傳輸時間,所以可以認為這種ISN是獨一無二的。
但這種方案也有風險,既然這樣的ISN是連續的,那麼中途的惡意程式可能能夠預測ISN的生成規律,從而偽造ISN…… 總之ISN的生成是個有趣的設計問題,這裡不展開了,有興趣可以自己搜尋資料閱讀。
我在開發中遇到不少程式設計師,一旦需要避免重複,就想到“生成隨機數”,根本不管隨機數也可能碰撞。更有甚者,一旦遇到類似ISN的場合,就想當然把初始值設定為0,真是讓人慾哭無淚(有沒有想過ISN為什麼不能設定為0呢,歡迎留言討論)。
說完了建立連線的握手,我們再來看終止連線的揮手。通常大家都知道,TCP是“三次握手,四次揮手”(雖然我很不贊成“次”,但既然它已經約定俗成,這裡還是延用通用的說法吧)。那麼,為什麼要四次才能揮手呢?
知道這個答案的人比能講清楚“三次握手”的要多。通常的答案都是:TCP是雙向通訊協議,要結束連線,雙方都必須傳送終止訊號,告訴對方後續再沒有資料發過來了,並等待對方確認,所以一共需要2+2=4次。
如果你之前看過建立連線的過程,大概會有這樣的疑問:既然建立連線的時候可以節省一步,把服務端返回SYN和ACK合併到一起,那麼結束連線的時候,是否也可以把服務端返回的SYN和FIN合併起來,節省一步呢?
想到了這個問題就值得恭喜,因為你不是隻滿足於“知其然”,而希望“知其所以然”。不過我們也需要想到,既然TCP連線的建立和終止都是同一批人定義的,既然他們能想到在建立連線時節省一步,那麼他們沒有理由在終止連線時不做節省。之所以沒有“節省”,一定是有理由存在的。
沒錯,確實是有理由的,而且這個理由很好理解,因為建立和終止連線的場景是不一樣的。在建立連線之前,客戶端和伺服器端都不會向對方傳送任何資料,所以在服務端返回ACK的時候帶上SYN,客戶端當然知道這是從服務端收到的第一個資料包。
而在結束連線時,客戶端向服務端傳送FIN,表示“我這邊不會繼續傳送資料過來了”,服務端響應ACK,這都沒有問題。但此時,服務端之前向客戶端傳送資料的操作可能還沒有完成,服務端仍然在向客戶端傳輸資料。如果服務端把FIN和ACK合併起來,就會出現這樣的情況:客戶端的資料還沒有接受完,忽然收到服務端的訊息“後續沒有資料了,終止連線”。顯然,這種情況不應當出現,所以不能把ACK和FIN合併在一起,所以終止連線必須要四步。
最近和實習生聊天,說起開發中遇到的各種問題,以及對應的模型,大家聽得入迷。事後有人問我:為什麼我們工作中遇不到這麼有意思的問題呢?我知道,這是個比較典型的問題。其實答案也很典型:因為你沒有去深究問題背後的原型。懂得了背後的原型,就具備了“從已知推導無知”的本領,也具備了“從無知中發現已知”的眼光。
我和朋友聊開發有個共同的判斷:TCP的握手和揮手看起來簡單,但真讓如今的開發人員去設計握手和揮手流程,估計有超過一半的人設計不出穩定、可靠、高效的握手和揮手流程。這樣說來,許多業務系統裡業務層面的通訊極不可靠,協議設計錯漏百出,也是無奈的結果了。
補充一句。我曾在面試中遇到過這樣的人,非名校畢業,已經有五年工作經驗,除了對流行的框架和熱點問題對答如流,對資料庫理論、網路基礎知識、資料結構和演算法依然如數家珍。事實充分證明,不是所有人工作之後就把大學的知識丟個精光的,事實也證明,這樣的候選人確實能擔大任。