TCP學習筆記(二) 相識篇

北冥有隻魚發表於2022-05-30

前言

很久之後,你開始學習計算機網路,不是為了面試,只是為了解決工作中遇到的問題,在遇到工作中遇到網路問題之前,你的想法是工作中作為一個CRUD仔,網路是通順的,不要擔心網路問題,但是這次碰到的網路問題很讓你頭疼,為了解決這個問題你用盡了渾身解數,最終問題被解決,這次問題解決之後,你打算徹底學習一下計算機網路。聊一下三次握手吧,我在實習準備面試題的時候,三次握手和四次握手就是高頻面試題,我選擇性的放棄了這個經典的面試題,因為大學的時候學的計算機網路並不好,其實剛開始我還是蠻有興趣的,但是後面發現越聽越聽不懂,索性就開始刷知乎,看數學書,當時聽不懂這門課的原因,我想一大部分原因在於當時沒有多少程式碼量,程式是認知計算機世界的橋樑,然後老師顯然沒有意識到這一點,當然學校也沒認識到,其實還有其他課程,也面臨這樣的問題。但是有的時候工作中會碰到,所以只好花時間重修,但是也不後悔大學將時間奉獻給了數學,我很懷念那段時光,突然懂得某一個數學定理,我當時記得花了一個月還是多久才解出了高等代數的一道課後習題,當時開心了很久。

我們來回憶一下《TCP學習筆記(二) 初遇篇》的內容:

TCP是面向連線的運輸層協議,這就是說應用程式在使用TCP協議之前,必須先建立TCP連線,在資料釋放完之後,必須釋放已經建立的TCP連線。

TCP提供可靠交付的服務。通過TCP連線傳送的資料。無差錯、不丟失、不重複,並且按序到達。

為了確保不丟失,在報文在網路環境中丟失之後,TCP建立了重傳機制,在網路擁堵的情況,報文丟失的頻率比較高,在這種情況下,進行重傳會進一步加深網路擁堵的程度,所以TCP引入了擁塞控制。

TCP提供全雙工通訊。TCP允許通訊雙方的應用程式在任何時候都能夠傳送資料

TCP是面向連線的協議,連線可以理解為通訊雙方之間的一條道路,通過連線進行運輸報文。在TCP中傳送報文分為三個階段:

  • 連線建立
  • 資料傳送
  • 連線釋放

TCP連線的建立主要是為了解決以下三個問題:

  • 要使得通訊的雙方能夠確知對方的存在
  • 要允許雙方協商一些引數
  • 能夠對運輸實體資源(快取大小、連線表中的專案)進行分配。

TCP連線的建立採取客戶和伺服器模式。主動發起連線建立的應用程式稱之為客戶,而被動等待連線建立的應用程式叫做服務端。我在寫TCP的時候想找兩個客戶端的通訊的程式碼示例,因為Java裡面使用TCP協議大致上有ServerSocket和Socket這兩個類,ServerSocket是服務端,Socket是客戶端。看到這裡才發現人家TCP本身就是客戶和伺服器模式。

我們本篇主要講連線建立和連線釋放,也就是為廣大程式設計師耳熟能詳的三次握手和四次握手。

連線建立-三報文握手

其實是三報文握手,不是三次握手,其實是一次握手中交換了三個報文,而並不是握了三次手,參見RFC 793(謝希仁的那本教材說是RFC 973,我在百度上找了好久沒找到,搜尋RFC 793就找到了,想來是不是作者手滑打錯了)。在RFC 793三次握手對應的描述如下:

The procedures to establish connections utilize the synchronize (SYN) control flag and involves an exchange of three messages. This exchange has been termed a three-way hand shake [3]

程式在建立連線使用SYN控制標誌並進行三次訊息交換,這種交換也被稱之為三報文握手。

教材中認為叫應該三報文握手的原因在於,三次握手從字面上推斷為握手握了三次,其實是一次握手交換了三個報文,像是初次見面握手上下搖晃了三次。

我同意這個觀點,早期我看到三次握手就以為是握了三次手,我們先大致介紹三報文的握手來體會為什麼,客戶端和服務端交換三個報文之後就能夠確知對方的存在和協商後續的報文傳輸的相關問題。

TCP建立連線的過程叫做握手,握手需要在客戶和伺服器之間交換是三個TCP報文段,為了討論問題,我們現在首先要引入兩臺通訊的計算機A和B,A和B的通訊程式使用TCP協議,A為客戶端,B為服務端。一開始B的伺服器程式首先建立運輸控制塊TCB(Transmission Control Block), 然後伺服器程式就開始處理Listen狀態,等待客戶端的連線請求。寫到這裡Java程式設計師有沒有想到Java中的Socket程式設計,在BIO中也是先new ServerSocket, 然後監聽某個埠,然後呼叫accept方法偵聽客戶端程式,該方法會阻塞到直到有客戶端請求進來,會返回一個Socket物件:

    public void serverSocketDemo() throws Exception{
        // 宣告該Socket佔用的程式
        ServerSocket serverSocket = new ServerSocket(8080);
        // 監聽哪個埠
        serverSocket.bind(new InetSocketAddress(9090));
        // 開始監聽,有客戶端請求進來,會返回給一個Socket物件
        // Socket中有輸入輸出流
        // 可以用來讀寫資料
        Socket socket = serverSocket.accept();
        InputStream inputStream = socket.getInputStream();
        OutputStream outPutStream = socket.getOutputStream();
    }

基本上高階語言Socket程式設計都是這個步驟,先建立ServerSocket,然後佔用埠,只不過偵聽方法有的叫accept,有的叫listen,其實都是一樣的原理。

A的TCP程式也是首先建立傳輸控制模組TCP。然後,在打算建立TCP連線時,向B發出連線請求報文段,這是首部中的同部位SYN=1,同時選擇一個初始序號seq = x。TCP規定,SYN報文段(即SYN = 1 的報文段)不能攜帶任何資料,但要消耗一個序號。這時TCP客戶程式進入SYN-SENT(同步已傳送狀態)。

B收到連線請求報文段後,如同意建立連線,則向A傳送確認。在確認報文段中應該把SYN位和ACK位置都置1,確認好是ack = x + 1,同時也會為自己選擇一個初始序號seq=y。請注意,這個報文段也不能攜帶資料,但同樣要消耗掉一個序號。這是TCP伺服器程式進入SYN-RCVD(同步收到)狀態。

TCP客戶端程式收到B的確認後,還要向B給出確認。確認報文段的ACK置為1,確認號ack = y +1,而自己的序號seq = x + 1。TCP標準規定,ACK的報文段可以攜帶資料。但如果不攜帶資料則不消耗序號,在這種情況下,下一個資料包文段的序號仍然是seq = x + 1。這時,TCP連線已經建立,A進入ESTABLISHED狀態。

上面給出的連線建立過程我們稱之為三報文握手,建立過程如下圖所示:

TCP三報文握手

那麼對於這個過程來說,第一個問題就是客戶端在收到服務端的確認報文之後,為什麼還要在向服務端傳送一個確認報文,這個答案在RFC-793中回答了這個問題:

The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.

三報文握手的原因是為了防止舊的重複連線初始化造成混亂。

我們下面來通過一個場景來解釋上面這句話,假設A傳送的第一個請求報文在某些網路結點中滯留了很久,A認為此報文已經遺失,於是又再次傳送了一個請求建立連線的報文,如果在這種情況下舊的報文比新的早到達B,如果只有兩次握手,那麼B就會認為和A建立了兩個連線,但是舊的連線中,A並不會再向B傳送資料,這樣服務端B的資源就白白被浪費了。如果是三報文握手,B收到了舊的報文之後,返回A一個SYN+ACK報文給客戶端。客戶端收到後就可以根據自身的上下文,判斷這是一個歷史連線(序列號過期或超時),那麼客戶端就會傳送請求中止連線報文給服務端,表示中止這一次的連線,可以避免服務端的資源被浪費。

再有在網路擁堵的情況下,如果只是兩報文交換就建立了TCP連線,在網路擁堵下,客戶端會傳送很多請求建立連線的報文,服務端收到一個請求連線建立的報文,就建立一個連線會造成大量服務端資源的浪費。

TCP提供可靠傳輸,傳送傳送的訊息也需要按序進行交付,在建立連線的開始,客戶端的報文中有一個序列號的欄位,由雙方共同維護,序列號是可靠傳輸的一個關鍵因素,它的作用為:

  • 接收方可以去除重複的資料。
  • 接收方可以根據資料包的序列號按序接收
  • 可以標識傳送出去的資料包中,哪些是被對方收到的。

其實B給A傳送的確認報文也可以被拆成兩次傳送,可以先傳送一個確認報文端(ACK = 1, ack = x +1), 然後再傳送一個同步報文端(SYN = 1,seq = y),這樣的過程就變成了四報文握手,也能實現一樣的效果,但是主流的實現是將這兩步合成一步。

連線釋放-四報文揮手

其實按照我最初的想法是,連線建立需要經歷三次報文握手是比較合理的,但是釋放連線要經歷四報文揮手我就有點不理解了,直接一次性掛掉不行嗎? 你可以聯想到生活中這樣的一個場景就是,倆人打電話,有一方話還沒說話,另一方就掛了,通常過早掛電話還會再給另一方打一次電話。我們還是先介紹連線釋放的過程,再去提出問題。

首先還是上面兩個已經建立連線的兩臺計算機,或者說是兩個計算機程式,然後此時A需要關閉連線了, A的應用程式首先向其TCP發出連線釋放報文段,並停止傳送資料,主動關閉TCP連線。A把連線釋放報文段首部的終止位FIN置1,其序號seq = u,它等於前面已傳送的資料的最後一個位元組的序號加1.這時A進入FIN-WAIT-1(終止等待1)狀態,等待B的確認。請注意,TCP規定FIN報文段即使不攜帶資料,它也會消耗掉一個序號。

B收到連線釋放請求後發出確認,確認好是ack = u + 1, 而這個報文段自己的序號是v,等於B前面已經傳送的資料的最後一個位元組的序號+1.然後B就進入CLOSE-WAIT狀態(等待關閉狀態),但此時的TCP連線初遇半關閉狀態,即A已經沒有資料要傳送了,但B如果要傳送資料的話,A仍然要接收,這也就是B到A這個方向的連線並未關閉,還是可能持續一段時間。

A收到B的確認之後,就進入到了FIN-WAIT-2(終止等待2)狀態,等待B發出的連線釋放報文段。若B已經沒有要向A傳送的資料了,其應用程式就通過作業系統對外暴露的TCP介面請求作業系統釋放連線。這時,B發出的連線釋放報文段必須使FIN = 1. 現假定B的序號為w(在半關閉狀態B可能又傳送了一些資料)。B還必須重複上次已傳送過的確認好ack = u + 1.這時B就進入LAST-ACK(最後確認)狀態,等待A的確認。

A在收到B的連線釋放報文段後,必須對此發出確認。在確認報文段中把ACK置為1、確認號ack = w + 1 ,而自己的序號是seq = u + 1(根據TCP標準, 前面傳送給的FIN報文段要消耗一個序號)。然後進入到TIME-WAIT(時間等待)的狀態。請注意,現在TCP連線還沒有釋放掉。必須經過時間等待計時器(TIME-WAIT timer) 設定的時間2MSL後,A才進入到CLOSED狀態。時間MSL叫做最長報文段壽命(Maxumum Segment Lifetime),RFC-793(現在看來教材確實寫錯了,這裡寫的是RFC-793,剛開始介紹TCP理論的是RFC-973),RFC-793建議設定為兩分鐘。這完全是從工程上考慮的,對於現在的網路來說,MSL = 2分鐘可能太長了一些。因此TCP允許不同的實現可根據具體情況使用更小的MSL值。因此A進入到TIME-WAIT狀態,要經過4分鐘才能進入到CLOSED狀態,當A撤銷相應的傳輸控制塊TCB後,就結束了這次的TCP連線。

我們提出的第一個問題就是為什麼回收需要四次? 其實上面的揮手過程已經給出答案了:

  • 關閉連線時,客戶端向服務端傳送FIN時,僅僅表示客戶端不再傳送資料了,但是還能接收資料。
  • 伺服器接收到客戶端的FIN報文時,先回一個ACK應答報文,而服務端可能還有資料需要處理和傳送,等服務端不再傳送資料時,才傳送FIN報文給客戶端來表示統一現在關閉連線。

從上面過程可知,服務端通常需要等待完成資料的傳送和處理,所以服務端的ACK和FIN一般都會分開傳送,從而比三次握手導致多了一次。我們畫個圖來描繪一下四次揮手的過程:

TCP四次揮手過程

從上面的過程可知,服務端通常需要等待完成資料的傳送和處理,所以服務端的ACK和FIN一般都會分開傳送,從而比三次握手導致多了三次。

下一個問題是為什麼A傳送完最後一個連線釋放報文之後,還需要等待2MSL。

  • 為了保證A傳送的最後一個ACK報文段能夠到達B。這個ACK報文有可能丟失,因而使處在LAST—ACK狀態的B收不到已傳送到確認,而A能夠在這段時間重傳一次確認,重新啟動2MSL計時器。最後,A和B都能夠正常進入關閉狀態。
  • 防止舊連線的資料包被收到。假設客戶端沒有TIME-WAIT時間或者等待時間過短,有延遲的資料包到達會發生什麼呢?
如果此時相同的埠的TCP連線被複用之後, 那麼就有可能正常接收這個過期的報文,那麼就會產生資料錯亂等嚴重問題。等待的這段時間,經過2MSL這個時間足夠讓兩個方向上的資料包被丟棄,使得原來連線的資料包在網路中自然消失,再出現的資料包一定是新連線所建立的。

TIME_WAIT等待2倍的MSL,比較合理的解釋是: 網路中可能存在來自傳送方的資料包,當這些傳送方的資料包被接收方處理後又會向對方傳送響應,所以這一來一回需要等待2倍的時間。

舉一個例子如果被動關閉方沒有收到斷開連線的最後的ACK報文,就會觸發超時重發Fin報文,另一方接收到FIN後,會重發ACK給被動關閉方,這一來一回剛好2個MSL。

其實看到這裡的疑問是TCP是如何保證最長報文段壽命,我以為TCP的報文中會有這樣一個欄位來說明自己的壽命,查閱諸多資料發現,沒有找到對應的資料說明。StackOverFlow中也有人跟我有一樣的疑問, 搜尋: What is Maximum Segment Lifetime(MSL) IN TCP相關問題就能看到答主跟我有一樣的疑問.

那如果客戶端無故重啟,那服務端的連線會關閉不了嗎?當然不會TCP還有一個保活計時器。伺服器沒收到一次客戶的資料,就重新設定保活計時器,時間的設定是通常是兩小時。若兩小時沒收到客戶的資料,伺服器就傳送一個探測報文段,以後每隔75秒鐘傳送一次。若一連傳送10個探測報文段仍無客戶的響應,伺服器就認為客戶端出了故障,接著就關閉這個連線。

處於time-wait的TCP連線不能被重用,一般有人看到這裡可能有人會問,我用多執行緒使用HTTP Client多執行緒呼叫,兩個間隔非常短的情況下,也實現重用了呀。那是因為在作業系統中提供了重用埠這個選項,即使該埠還被上一個連線所佔用還沒釋放,我們依然可以通過重用地址發起新的連線:

   public void serverSocketDemo() throws Exception{
      
        Socket socket = new Socket();
        // 允許處於time-wait中的埠,發起新的連線。
        socket.setReuseAddress(true);
    }

總結一下

我們經常談起的三次握手, 更為貼切的稱呼是三報文握手, 使用SYN相關的報文,客戶端和服務端交換三次。為什麼客戶端收到服務端的確認之後,還需要再傳送一次確認。原因在於防止舊的連線造成連線混亂和資源浪費,假如A傳送的請求建立連線報文,此報文在網路中滯留了很長時間,超過了超時間,A再次發了一個請求建立連線的報文,如果只有兩次握手,那麼滯留的報文也將建立連線,白白造成資源浪費。

那為啥關閉連線要四次,因為客戶端傳送資料傳送完畢之後,服務端還可能有資料沒處理和傳送完,所以多了一次握手。那為什麼收到服務端傳送的連線關閉報文,客戶端不立即關閉連線,而是進入TIMEWAIT狀態,有兩個原因,第一個保證連線正確的被關閉,因為A傳送的確認ACK報文可能在網路中滯留和丟失,如果A在傳送確認報文ACK之後立即關閉,假設丟失之後,服務端就可能無法正確關閉連線。第二個就是防止舊的的資料包被新的連線收到,考慮以下兩種情況:

  • 如果網路極差,B始終未收到A傳送的ACK,B會不斷重傳直到到達作業系統的重傳時間或次數,然後關閉連線;如果A如果始終未收到B重傳的FIN,會再等待2個MSL後釋放資源。最終A,B也完成了連線的斷開,資源的釋放。
  • TIME_WAIT狀態等待2MSL,考慮的是B總是能收到A的ACK,但由於擁塞,可能收到的比較慢,最慢為1個MSL,如果經過1MSL, B還是沒收到,就永遠收不到ACK了,此時退化為第一種情況。此時距離A傳送ACK過了1MSL,B已經可能多次重傳了FIN,並且因為收到ACK,之後不會再重傳FIN。此時A如果收到過新的FIN,就已經開始了重新計時,重新等待2個MSL;A如果一直沒收到新的FIN,就再等待1個MSL,讓B之前重傳的報文在網路中消逝。

參考資料

相關文章