目錄
- 1.TCP的連接管理機制
- (1)三次握手
- ①握手過程
- ②對握手過程的理解
- (2)四次揮手
- (3)握手和揮手的觸發
- (4)狀態切換
- ①揮手過程中狀態的切換
- ②握手過程中狀態的切換
- 2.TCP的可靠性
- (1)重傳機制
- ①超時重傳
- ②快重傳
- (2)流量控制
- ①滑動窗口
- a.滑動窗口的組成
- b.滑動窗口的運行邏輯
- ②擁塞控制
- a.慢啟動機制
- b.快恢復機制
- ③窗口探測
- 3.TCP針對復雜網絡環境的一些處理
- (1)隨機序號
- (2)延遲應答
- (3)緊急指針
- (4)粘包問題
- (5)意外斷連
- ①進程異常終止
- ②機器斷電、斷網
1.TCP的連接管理機制
(1)三次握手
三次握手和四次揮手都需要建立在標識位的基礎上,不同的標識位可以告訴對方自己的目的。 用一個故事來描述三次握手:A向B表白,B同意了并且問什么時候開始談,A告訴B就現在并遞出了一束花。
①握手過程
我們認為發出“表白”請求的就是C端,就是客戶端,接收的就是服務端,即S端。三次握手都是用標志位來表達的。第一次C端發送SYN,這是個空報文,只有報頭,這相當于A向B表白;第二次S端發送SYN + ACK,ACK是對第一次握手的回應,SYN相當于B向A表白,這也是個空報文,只有報頭;第三次C端發送ACK,并且可以不再是空報文,可以捎帶應答了,這里的ACK也是對B向A發送SYN的回應。
②對握手過程的理解
過程其實可以拆分成兩部分,C端向S端申請連接,S端向C端申請連接。申請連接的一方都會發出SYN,接收到SYN的都會進行ACK。那么更清晰地說,三次握手的本質是四次握手:C發送SYN,S回應ACK;S發送SYN,C回應ACK。只不過我們將中間S端回應ACK和發送SYN合并了而已,所以才叫三次握手,這體現出全雙工的特性。
本質上,三次握手、四次揮手是以最少次數驗證雙方全雙工信道的通暢性。
三次握手的最后一次,C端可以邊ACK邊發消息,就是使用了確認序號和序號個部分,本質就是利用了捎帶應答機制。但注意三次握手合并的兩次握手(S端的ACK和SYN)并不能說是捎帶應答,因為捎帶應答針對的是報文的捎帶,要用到序號和確認序號,而這里只是將ACK和SYN兩個標志位置為1。
(2)四次揮手
為什么是四次前面已經提及,本質就是從左到右建立共識,再從右向左建立共識。C端發起FIN,S端接收回應后再發起FIN,斷開連接和建立連接一樣,都要征得雙方的同意。 根據建立連接,我們可以推斷出,在有的情況下四次揮手可以合并成三次,即雙方都有很強的斷開連接的意愿下。
(3)握手和揮手的觸發
TCP的服務端一直處于listen狀態(一直阻塞在accept函數中),客戶端建立連接需要connect,服務端需要accpet,那么握手和揮手在宏觀上是在哪一步被觸發的呢?
C端調用connect之后,會觸發OS去握手,connect只會阻塞等待結果,這個函數并不會主動去參與握手的流程。所以C端發送的SYN、ACK這些都是調用connect之后系統自動進行的,和這個函數沒有直接關系。同理,accept也不參與三次握手,它只負責等待結果。總結起來就是:connect發起請求后,由OS自主三次握手,握手成功C端的connect和S端的accept都會收到消息,但這兩個函數不會參與握手過程。
揮手過程和握手相似,C端調用close后OS會自主發起一個FIN,S端收到后再自主發起FIN,因此close也是不會直接參與揮手過程的。但揮手有一個問題:C端close會觸發OS的揮手流程,但S端收到C端的揮手后,S端發起的FIN怎么能被C端接收到呢?C端不是已經close了嗎?其實關閉網絡通信還有個更底層接口,int shutdown(int fd, int how), 其中how是宏,有讀、寫、讀寫選項,因此調用這個函數可以實現更細粒度的通信關閉,也可實現半通信。close之后OS并不會馬上斷開讀、寫通信,而會步步關閉,這樣就解決了剛才的疑問。
(4)狀態切換
握手和揮手會伴隨一系列的狀態切換,因為建立和斷開連接不僅由雙方意愿決定,還要受網絡狀態影響,所以每一次揮手和握手都對應狀態切換,這樣在出現意外時OS能根據狀態及時判斷出錯的階段并給出異常處理方案。 下面是一些比較重要的狀態切換,涉及一些網絡環境的處理:
①揮手過程中狀態的切換
C端發起close后,會進入TIME_WAIT等待S端的FIN,收到S端的FIN后等待兩個MSL時間就會切換到CLOSED狀態。
TIME_WAIT的存在也能保證S端發起的FIN確認收到,有可能C端收到S端的FIN后發起的ACK沒有送到S端,導致S端一直FIN,TIME_WAIT下的C端在收到FIN后可以馬上再ACK,盡可能保證正常4次揮手完成。
MSL是一個報文在網絡中的最長時間(大部分在60s - 120s),保持在TIME_WAIT是為了等待網絡的游離報文消散,在這個狀態下一般bind會失敗,以防快速建立兩次連接,第二次連接后卻收到第一次連接時的報文。不過,我們可以設置套接字setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))(其中需定義int opt = 1),SO_REUSEADDR允許進行地址復用,這個時候服務器TIME_WAIT狀態也能再次bind。 S端在收到C端的FIN并ACK之后,會進入CLOSE_WAIT,S端發起FIN后會進入LAST_ACK,收到C端的ACK就直接進入CLOSED狀態。前兩次揮手是由C端調用觸發的,后兩次揮手需要由S端調用close觸發。 所以如果服務器一直不調用close,一直處于CLOSE_WAIT,那么第三次揮手就遲遲不發,這種情況下C端等待一段時間后會直接退出,而S端就一直積累,導致服務器的文件描述符、內存泄漏。所以一定要及時close。
②握手過程中狀態的切換
connect觸發三次握手后,C端就切換為SYN_SENT,S端收到SYN之后就變成SYN_RCVD。S端再發起SYN + ACK,被C端收到,C端就會變成ESTABLISHED,對C端來說連接就已經建立了,之后就會開始發消息。而S端就會在第三次握手后變成ESTABLISHED,稍晚一些。
這里會出現一個問題,S端和C端切換為ESTABLISHED的時間不一樣。如果C端變成ESTABLISHED之后S端出現意外,被快速重啟了,而C端不知道。這個時候C端照常發消息,S端就不認識,這時S端就會回應RST(重置連接),重新進行三次握手。
或者說S端發送SYN后C端的ACK沒有被S端收到,S端仍未置于ESTABLISHED,之后C端發送消息,S端也會RST重置連接。
2.TCP的可靠性
報文在網絡上傳輸要花時間,確認收到了消息需要做應答,告訴發送端已經收到了。就像上課老師問同學聽沒聽懂,同學要應答。
但服務端應答要發回一些消息,怎么保證應答收到了呢?應答再應答?這就無限遞歸了。 可以得出,長距離通信,沒有100%的可靠性,因為總有最新的一條消息是沒有應答的。 客戶端給服務器發消息,服務器要應答,只要客戶端收到了應答,就說明上一條消息的可靠性,我們關注的其實是前面發送的消息是否可靠,而不是最新的應答,應答只是個標志,而不是重要的信息。 TCP就是以這種方式為基礎,實現了可靠性。
(1)重傳機制
①超時重傳
信息丟失分為數據丟了,應答丟了。數據丟了不會應答,C端會設定一個時間間隔,超時會重傳。但應答丟了,C端確認不了是應答丟了還是數據丟了,所以C端都會超時重傳。
這里我們可以結合一下系統的知識來理解,如何實現超時功能呢?鬧鐘信號(定時器,通過軟中斷實現,本質就是count --)。
超時等待時間不是固定的,會動態計算,一般以500ms為單位控制,第一次500ms,下一次2 * 500,下一次4 * 500… 指數級增加等待時間間隔,多次不行的話發送端就會認為是網絡不行,直接執行中斷連接等操作。
其實有的時候信息根本沒丟,只是被卡在某個路由器了,但網絡太復雜了,我們只能按照最壞的打算來處理。這個時候就可能遇到兩個一模一樣的信息被接受端收到(原信息 + 重傳信息),怎么辦呢?很簡單,用序號去重即可。
②快重傳
我們已經知道,滑動窗口里面的數據隨時可以發送,如果分成四塊發送1 - 4000,第2塊先到,ACK為1,第3、4塊其次,它們回應的ACK都是1,這個時候OS連續接收到三個相同的ACK,就會馬上重傳第1塊,不論第1塊是真丟了還是傳的比較慢。如果是傳的慢,沒丟失,接受端收到兩個報文也會去重
但如果收到的順序是2、3,但1和4都還沒傳過來,這個時候就不會觸發快重傳。如果1先到,則ACK立即變為3001,之后等待4;如果4先到,那就立刻觸發快重傳;如果都沒到,那就等待超時重傳。
可以發現,快重傳只是在一方面降低時間延遲,本質上還是個概率問題,面對網絡的復雜性,很多時候的處理并不像系統那樣“優雅”,簡單來說就是“賭”。
(2)流量控制
流量控制 != 傳輸數據速率變慢,它是通過一系列考量,告訴OS當前最合適的發送數據速率是多少,在擁堵時減少發送,在空閑時能增加發送量,整體提高數據傳輸效率。
①滑動窗口
a.滑動窗口的組成
我們前面說過,TCP有自己的發送緩沖區和接受緩沖區,兩個緩沖區本質就是sk_buff的鏈表,對報文的管理本質就是對sk_buff的管理,發送和接收都是修改鏈表(生產者 - 消費者模型)。滑動窗口本質上就是建立在緩沖區之上的,它本質上是一種算法,通過類似首尾指針或下標維護窗口范圍。我們前面說過發送數據就是向緩沖區寫數據,至于數據怎么發,什么時候發是OS自主決定的。滑動窗口就屬于OS自主決定的這部分。
發送緩沖區中,滑動窗口的左側是已經確認收到的數據,中間是隨時可以發送的數據(可以不按順序發),右側是現在不能發送的數據。緩沖區邏輯結構上是環形的,因此滑動窗口不會越界,本質上就是環形隊列的生產者 - 消費者模型。
TCP報文交換過程中,雙方互相交換的報頭中就有16位窗口大小,這個滑動窗口大小不能超過16位窗口大小對應值,這樣發送的數據永遠不會超出對方的接受能力導致數據丟棄。注意這個窗口大小控制是雙方都在做的,這是全雙工通信。
其實這里還有個更底層的問題,為什么滑動窗口要劃分成一塊一塊的數據發送,直接一下子把滑動窗口里面的數據全發出去不是更省事嗎?數據鏈路層不允許發送大的報文,有大小限制,所以越往上走,就越要控制報文的大小,要保證從傳輸層向下傳輸的數據到鏈路層不會超過它的規定最大值。
b.滑動窗口的運行邏輯
窗口滑動的本質是維護窗口首尾的start和end在做修改,滑動窗口的start = 確認序號,end = start + win(接收的報文的當前窗口大小)。滑動窗口只能向右移動,畢竟確認序號是不斷增加的。
已知滑動窗口是分段發送的,假設為四等分段,丟包問題無非三種情況的組合,最左側、中間、最右側丟了。最左側丟了:后面收到的3個應答的確認序號應該都是滑動窗口原起始值,滑動窗口不移動,這個時候也會觸發快重傳。補發接收到后確認序號直接跳過第2、3、4段。中間段丟失,應答的確認序號都是第1(或2)段后面的值,窗口移動一段,這個時候轉為最左側丟失情況,補發后窗口再跳過已接收到段。其余同理,所有的丟失都會轉為最左側丟失問題,滑動窗口逐一滑動,逐一解決丟包。
為什么收到補發的數據后,窗口會直接跳過前面接收的數據序號呢?在滑動窗口發送的數據沒有被完整接收完前,先前到達的sk_buff會被暫時維護起來,直到完全接收完畢后統一上交到緩沖區,鏈入緩沖區的sk_buff鏈表中。
上述的丟包重傳,除了第一種只有最左側丟棄,其余三個都收到并ACK同一值觸發了快重傳以外,其余的都是靠超時重傳實現的。
②擁塞控制
和建立連接斷開連接一樣,雙方想干什么不僅僅是看雙方的意愿,還要看網絡的狀態。要進行流量控制,滑動窗口的大小不能僅僅是由對方的窗口大小決定,還受網絡限制,不然雖然對方能接受大量數據,但網絡極差,發出去的基本都丟了,有什么用?
a.慢啟動機制
少量報文丟失是正常情況,但大部分報文丟失就只能是網絡的問題(16位窗口大小排除了對端緩沖區的影響)。
網絡出現擁塞問題,要么重傳,要么進行其他處理。可以肯定的是,如果大量數據超時,重傳肯定不現實,這只會加重網絡擁堵。因為用全局視角來看,網絡上有很多用戶,如果每個用戶都采用這種策略,這一定會導致大量數據重復發出,造成更嚴重的網絡擁塞。
所以一旦發生擁塞,就必須使用慢啟動機制,先發少量數據,摸清網絡狀態,每收到一個ACK就再多發點,一點一點試探,發送的數據量受到擁塞窗口值的影響。一般來說,大于擁塞窗口的值就可能擁塞,小于這個值就很可能不會引發擁塞。
這又來了個窗口,怎么和前面的知識結合起來呢?僅需一個公式就能徹底理解。滑動窗口大小 = min(16位窗口大小,擁塞窗口大小),我們需要認識到,對流量的控制已經轉為了對滑動窗口大小的控制,這里取最小值就是最好的佐證。
我們還需要了解下慢啟動的算法,這個擁塞窗口的大小怎么調整的?擁塞窗口大小由每個主機單獨維護(雙方都要有,且不在報文中體現),是主機自己對網絡環境的評估。因此可以說,網絡的好壞轉為了對擁塞窗口大小的比較,這也是后續理解擁塞窗口大小調整算法的核心思想。
剛開始通信時,主機對網絡狀況一無所知,因此擁塞窗口是1字節,每收到一次ACK,窗口大小 + 1、+2、+4、+8,指數級增加。 為什么采用指數增長呢?這是為了讓主機盡快探明網絡,盡快達到正常通信速度。
但由于指數爆炸的特性,窗口大小增長速度還受慢啟動閾值限制,一旦擁塞窗口到達閾值,后面擁塞窗口就按+1、+1、+1增大。這里有一個需要注意的點,達到閾值后窗口值線性增加,如果網絡一直暢通,每收到一個ACK,那么窗口值會不斷增大,甚至到1000,10000都可以。我們要理解,擁塞窗口的值是對網絡狀態的描述,值越大,說明網絡越好,上不封頂。
如果發生擁塞,擁塞窗口大小直接變為1,慢啟動閾值 = 觸發擁塞前上一次擁塞窗口大小(擁塞前一瞬間擁塞窗口的大小) / 2 。 這里我們也能理解為什么擁塞窗口要一直變化,上不封頂。因為網絡的擁堵、健康等狀況一直是變化的,因此擁塞窗口也一定一直在變化。如果很長一段時間擁塞窗口都很大,這說明網絡狀況很好,就算發生一次擁塞后,由于慢啟動閾值高,能很快回到原來狀態。
b.快恢復機制
快恢復指的是當觸發快重傳之后,慢啟動閾值 = 觸發擁塞前上一次擁塞窗口大小(擁塞前一瞬間擁塞窗口的大小) / 2 ,這些和慢啟動一致。但區別在于擁塞窗口大小不會置為1,而是會置為變化后的慢啟動閾值。 啟動快重傳,畢竟是連續收到3個ACK的(大概率能收到消息,只不過某一塊數據出現了意外),網絡狀況還是稍好一點的,不需要極端地置為1,可以說快恢復是一種相對而言效率更優的做法,和慢啟動結合控制擁塞窗口大小的修改。
③窗口探測
滑動窗口大小可以為0,即對方剩余窗口大小為0時,就會觸發窗口探測。 這個時候每隔一段時間,C端就會試探性地發送消息,并且PSH標志位設為1,告知對方請盡快將數據交給上層。事實上,當滑動窗口大小較小時(不一定要窗口為0),C端就會開始發送PSH了。
3.TCP針對復雜網絡環境的一些處理
(1)隨機序號
在快速建立兩次連接時,揮手時要避免上一次連接的游離報文傳到下一次連接中,因此第2次連接的開始序號要處理。由于前兩次握手沒有任何報文內容交換,所以可以在兩次握手中的序號處設置一個隨機序號,雙方交換隨機序號。在后續的傳輸中,收到序號后應當減去對方握手時發給自己的隨機序號,恢復出原始序號,如果異常就丟棄。這就減少了游離報文的影響。
(2)延遲應答
S端收到了數據,一般會等一小會再應答,等待期間上層會讀取數據,這會使得S端的接收緩沖區變大,之后ACK的窗口大小也會更大一些,提高傳輸效率提高。但是我們要如何理解等一小會?S端可以每隔2個包應答一次(也可為其它值,TCP允許少量ACK丟失或者隔包應答,收到ACK表示之前的數據都收到了),或者根據時間限制(如200ms)應答,延遲的時間要控制在超時重傳內。
(3)緊急指針
TCP具有按序到達的特性,但有的時候我們想要插隊,讓后面的數據先被收到。例如,使用云盤上傳數據時,我們突然想要終止上傳,這個終止的指令就相對比較緊急,因為就算按序收到前面的大量數據,也終歸是無效的。
我們可以將URG標記設為1,這樣到達對方主機后,OS就會優先讀取并交給上層處理。 URG的緊急數據由16位緊急指針標識,其值標記有效載荷中緊急數據的偏移量。但注意緊急數據只占1個字節,可以約定設置狀態碼來進行快捷的緊急處理。
緊急指針的運用也能體現在代碼中,在應用層recv可以讀取緊急數據,其中recv的第4個參數標志位設置為MSG_OOB,這個時候recv就會去讀取緊急數據(也稱為帶外數據)。發送緊急數據同理,在send標記位設置MSG_OOB。
(4)粘包問題
UDP沒有粘包問題,因為其報頭中含有有效載荷長度,要么拿上一個完整報文,要么直接將整個報文丟棄。而TCP沒有這個字段,只有起始序號,因此在面向字節流中,不能保證讀到應用層的是完整的報文,有可能讀了兩個報文,交到應用層的數據中第2個報文只有一半,這就是粘包問題。 解決方案就是在應用層設置明確的報文邊界,例如用特殊符號隔開報文,使用序列化和反序列化,增加自描述長度字段等,這需要在應用層人為解決。
(5)意外斷連
①進程異常終止
維護連接本質上就是維護創建的文件及其緩沖區,由于文件生命周期隨進程,所以當進程異常終止時,殺掉進程的就是OS內核,相應地,OS內核也會負責地正常進行四次揮手斷開連接。 機器重啟也是如此,OS都會正常四次揮手。
②機器斷電、斷網
如果C端的機器斷電或斷網,對于S端來說,連接就無效了。但S端連接信息一直都在,它怎么知道C端的連接怎么樣呢?
如果C端長時間沒通信,S端會詢問它,如果沒回應,S端就會關閉,這叫連接保活,保活時間一般是幾分鐘。 TCP自己有連接保活機制,但一般連接的保持是應用層來做,因為TCP不能根據不同情況滿足實際需求,只是個兜底的。