TCP是一個面向連接的、可靠的、基于字節流的傳輸層協議。文次我們會通過介紹TCP的報頭并通過分析各字段的用途來進一步解釋其核心特性:
可靠傳輸:?有確認應答、超時重傳、確保有序。
流量控制和擁塞控制:?動態調節發送速率,防止丟包與擁塞。
面向連接:?通過“三次握手”建立連接,“四次揮手”斷開連接。
TCP報頭
源/目的端口號:
標識唯一的發送方和接收方進程?
32 位序號Seq:
體現基于字節流特性和可靠性:表示本報文段所攜帶數據的第一個字節在整個字節流中的編號。TCP將應用層交付的數據視為連續的字節流,并為每個字節編號。接收端TCP利用序列號將可能亂序到達的報文段重新排序,確保數據按正確的順序交付給應用層。
32 位確認號Ack:?
(1)體現可靠性 (確認機制):?當ACK
標志位為1時,該字段才有效。它表示接收方期望收到的下一個字節的序列號。
(2)也用于流量控制
4 位 TCP 報頭長度:
表示該 TCP 頭部有多少個 4 字節,最小值為5(對應20字節標準報頭),最大值為15(對應60字節報頭)
4位保留未用:必須置為0
6 位標志位:
○ URG: 緊急指針是否有效,一般沒用
○ ACK: 確認號是否有效,絕大多數報文段都攜帶ACK
○ PSH: 提示接收端應用程序立刻從 TCP 緩沖區把數據讀走
○ RST: 對方要求重新建立連接,通常在發生嚴重錯誤或拒絕連接請求時使用; 我們把攜帶 RST 標識的稱為復位報文段
○ SYN: 請求建立連接,在三次握手中用于同步序列號; 我們把攜帶 SYN 標識的稱為同步報文段
○ FIN: 表示發送方數據已發送完畢,請求終止連接,用于四次揮手關閉連接; 我們稱攜帶 FIN 標識的為結束報文段
16 位窗口大小:
用于流量控制:?表示接收方當前愿意接收的數據量(以字節為單位),即接收窗口的大小。發送方根據這個值動態調整自己發送數據的速率,確保不會淹沒接收方,防止接收緩沖區溢出。這是TCP實現端到端流量控制的關鍵機制。
16 位校驗和:
發送端填充, CRC 校驗.
接收端校驗不通過, 則認為數據有問題
此處的檢驗和不光包含 TCP 首部, 也包含 TCP 數據部分.
16 位緊急指針:
標識哪部分數據是緊急數據;
40 字節頭部選項: 暫時忽略;
可靠傳輸
確認應答
在進行tcp通信時,發送方每發送一個報文,接收方就必須給接收方發一個ACK應答報文,表示“Ack序號之前的數據我收到了”(ACK是一個標識位;Ack就是確認序號,可以認為是緩沖區上的一個指針,而序號Seq同樣是一個指針),這保證了發送方能掌握接收方的接收狀態,避免盲目發送(如接收方網絡中斷,發送方還在發送),保證了通信時的可靠性,同時還與下文的滑動窗口、超時重傳、快速重傳有密切關系(通過Ack來判斷哪一部分數據丟包)
快速重傳
如果連續三次接收方都給發送方應答同一個ACK,那么接收方就會認為該ACK對應的報文丟失,會進行重發
超時重傳
如果發送方發送報文后一段時間內接收方沒有應答,接收方就會認為發送的報文丟失,會進行重復發送。那么如何確定該過多長時間沒收到應答才認為是丟包呢,這個時間如果太短會導致網絡環境較差時頻繁發送重復數據,太長又降低了整體的重傳效率,因此一般會動態計算超時時間:
Linux 中(BSD Unix 和 Windows 也是如此), 超時以 500ms 為一個單位進行控制, 每次判定超時重發的超時時間都是 500ms 的整數倍.
如果重發一次之后, 仍然得不到應答, 等待 2*500ms 后再進行重傳.
如果仍然得不到應答, 等待 4*500ms 進行重傳. 依次類推, 以指數形式遞增.
累計到一定的重傳次數, TCP 認為網絡或者對端主機出現異常, 強制關閉連接
滑動窗口
一方面,由于發送方每發送一個報文都需要接收方進行應答,這使得高延遲網絡下雙方通信效率較低;另一方面,如果發送方發送數據過快,接收方來不及將新報文放入接收緩沖區,只能將其丟棄。為此就需要一種機制動態地調節傳輸速率——滑動窗口
滑動窗口首先允許發送方連續發送多個報文(無需逐個等待ACK),顯著提高了傳輸效率。
這個過程中即使應答報文丟失了也沒關系,因為還會有后續的應答報文。
隨后又將發送/接收緩沖區進行劃分:
發送方:
已確認發送|發送窗口|未發送
SND.WND: 表示發送窗口的大小, 上圖虛線框的格子數是 10 個,即發送窗口大小是 10。
SND.NXT:下一個發送的位置,它指向未發送但可以發送的第一個字節的序列號。
SND.UNA: 一個絕對指針,它指向的是已發送但未確認的第一個字節的序列號。
發送方在收到應答報文時,需要將確認號Ack與自己的窗口范圍進行對比:
若?
Ack
?不在?[SND.UNA,SND.NXT]
?范圍內→?無效ACK,直接忽略。若?
Ack > SND.UNA
?→?新數據被確認,更新?SND.UNA = Ack。
若?
Ack == SND.UNA
?→?重復ACK(可能數據丟失或亂序),累計重復次數:若重復ACK ≥ 3次?→ 觸發快速重傳(重傳?
SND.UNA
?對應的數據包)。若?
Ack < SND.UNA
→?過期的ACK(確認已確認的數據),直接忽略。
那么接收方的Ack又是如何更新的呢:
接收方:
已確認收到|接收窗口|未收到
REV.WND: 表示接收窗口的大小, 上圖虛線框的格子就是 9 個。
REV.NXT: 下一個接收的位置,它指向未收到但可以接收的第一個字節的序列號。
當Seq<RCV.NXT時(重復數據),直接丟棄報文,窗口不變,Ack不變
當Seq==RCV.NXT時(按序到達),Ack=RCV.NXT=RCV.NXT+len,窗口縮小
當Seq>RCV.NXT時(亂序數據),RCV.NXt和Ack不變,將數據緩存,窗口縮小,REV.WND-=len
而當應用層將數據從緩沖區取出時,窗口則會變大
流量控制
有了滑動窗口機制,我們就可以通過控制窗口大小來限制傳輸速率
接收方通過 TCP 報頭的窗口大小字段,動態告知發送方其剩余接收緩沖區容量;
窗口大小字段越大, 說明網絡的吞吐量越高;
接收端一旦發現自己的緩沖區快滿了, 就會將窗口大小設置成一個更小的值;
發送端接收到這個窗口之后, 就會減慢自己的發送速度;
如果接收端緩沖區滿了, 就會將窗口置為 0;
這時發送方不再發送數據, 但是需要定期發送一個窗口探測數據段, 強制接收方應答并把接窗口大小告訴發送端
擁塞控制
由于可能同時有大量的計算機在網絡上進行通信,大家同時發送大量的數據, 很有可能導致甚至加重網絡擁堵;為此,TCP引入慢啟動機制, 先發少量的數據,摸清當前的網絡擁堵狀態, 再決定按照多大的速度傳輸數據;
此處引入一個概念稱為擁塞窗口
發送開始的時候, 定義擁塞窗口大小為 1;
每次收到一個 ACK 應答, 擁塞窗口大小乘2;
每次發送數據包的時候, 將擁塞窗口和接收端主機反饋的窗口大小做比較, 取較小的值作為實際發送的窗口
像上面這樣的擁塞窗口增長速度, 是指數級別的
"慢啟動" 只是指初使時慢, 但是增長速度非常快.
為了不增長的那么快, 因此不能使擁塞窗口單純的加倍.
此處引入一個叫做慢啟動的閾值
當擁塞窗口超過這個閾值的時候, 不再按照指數方式增長, 而是按照線性方式增長
當 TCP 開始啟動的時候, 慢啟動閾值等于窗口最大值;
在每次超時重發的時候, 慢啟動閾值會變成原來的一半, 同時擁塞窗口置回1;
少量的丟包, 我們僅僅是觸發超時重傳;
大量的丟包, 我們就認為網絡擁塞;
當 TCP 通信開始后, 網絡吞吐量會逐漸上升;
隨著網絡發生擁堵, 吞吐量會立刻下降;
擁塞控制, 歸根結底是 TCP 協議想盡可能快的把數據傳輸給對方, 但是又要避免給網絡造成太大壓力的折中方案.
應答的優化策略
延遲應答
由于接收方收到消息時,窗口會變小,此時如果立刻應答,會使得發送方發過來的報文變小(進行流量控制),可是很多時候,接收方的處理速度很快,可能窗口雖然在接收報文時變小了,但很快就會恢復。因此,實際上發送方可能會低估接收方的接收能力,進而降低傳輸效率。
所以,為了提高傳輸效率,采用延遲應答機制:減少應答次數和接收到消息后過一定時間才應答
那么這個延遲應答時間應該是多少呢:顯然不能超過500ms,因為這會與超時重傳產生沖突,導致接收方明明收到消息了卻被發送方認為是丟包了。一般來說,這個時間是200ms。
捎帶應答
很多時候tcp通信不是一方發送一方接收,而是雙方都在發送都在接受,這時候雙方不僅要發自己的消息,還要頻繁應答對方(而發送的信息僅僅是一個ACK和確認號和窗口大小,這顯然有些浪費),因此,為了提高通信效率,當進行雙向通信時,發送方(同時也是接收方)會把這次要發的報文和上次接收數據的應答報文合二為一發送給對方,這也是為什么大多數報文都會攜帶ACK應答
面向連接
三次握手
tcp通信在連接時需要先進行“三次握手”,其目的為:
1.確保雙方的接收能力和發送能力正常
2.同步雙方序列號seq
第一次握手前,雙方均處于CLOSED狀態,表示斷開;服務端調用listen()后進入LISTEN狀態,表示等待連接
第一次握手:
客戶端調用connect()向指定服務端發起連接請求并向服務端發送SYN報文請求連接并發送Seq序(假設值為x,因為并沒有發送任何數據,所以這個是隨機生成的)
客戶端進入SYNC-SENT狀態
此時服務端已知曉自己的接收功能正常,對方的發送功能正常,服務端進入SYNC-RCVD狀態
第二次握手:
服務端向客戶端發送SYN報文表示同意連接,并捎帶應答ACK報文;發送Seq序號(假設值為y,由于沒有發送任何數據,也是隨機生成的)和Ack確認序號(值為x+1,你可能疑惑不是沒有發送數據嗎,為什么確認序號還要加1,這是因為TCP 協議規定SYN報文雖然不攜帶數據, 但是也要消耗1個序列號)
此時,客戶端已知曉自己的發送和接收功能正常(因為收到了服務端應答,說明自己的消息成功發出,也說明自己能收到服務端的消息),也知曉服務端的發送和接收功能正常(因為服務端收到了自己的連接請求并成功應答);
但服務端尚不知道自己的發送功能和對方的接收功能是否正常(因為對方還沒有應答)因此需要第三次握手:
第三次握手:
客戶端發送ACK報文應答,并發送Seq序號(值為x+1)和Ack確認序號(值為y+1)
至此服務端收到消息,確認了自己的發送功能和對方的接收功能,雙方都進入ESTABLISHED狀態,表示已連接可以正常通信
四次揮手
tcp在斷開連接時,要進行四次揮手,其目的為:確保雙方數據完整傳輸并安全釋放資源
其中FIN信號表示不再發送數據,但仍可以接收數據
由于更多情況下是客戶端主動斷開連接(如關閉瀏覽器),所以這里我們認為客戶端是主動斷開連接的一方,服務端是被動斷開連接的一方,當然服務端也有可能是主動都斷開的連接的一方,下文會提到
第一次揮手:
客戶端調用close()函數,向服務端發送FIN報文表示準備斷開連接,并發送Seq序號(由于沒有數據,因此是隨機生成的,假設值為u)
客戶端進入FIN_WAIT-1狀態,關閉應用層動作(不再發送應用層的數據)
第二次揮手:
服務端發送ACK應答報文,并發送Ack確認序號(值為u+1,你可能疑惑不是沒有發送數據嗎,為什么確認序號還要加1,這是因為TCP 協議規定FIN報文雖然不攜帶數據, 但是也要消耗1個序列號)和Seq序號(由于沒有數據,因此是隨機生成的,假設值為v)
服務端進入CLOSE_WAIT狀態,關閉內核動作(無法讀取緩沖區中的數據)
客戶端進入FIN_WAIT_2狀態(這是一個半關閉狀態,不能發送,但可以接收)
第三次揮手:
服務端調用close()函數,向服務端發送FIN報文,并發送Ack確認序號(值為u+1)和Seq序號(假設值為w,若第二次揮手后到第三次揮手前,服務端向客戶端發送了數據,則w>v;否則w=v)
服務端進入LAST_ACK狀態,關閉應用層動作(不再發送應用層的數據)
第四次揮手:
客戶端發送ACK應答報文并發送確認序號Ack(值為w+1)和序號Seq(值為u+1)
客戶端進入TIME_WAIT狀態,關閉內核動作(無法讀取緩沖區中的數據),并等待一段時間確保對方收到應答報文
服務端收到報文后進入CLOSE狀態,連接斷開
TIME_WAIT
為什么客戶端在第四次揮手后,還要等待一段時間呢:這是因為,如果該報文丟失,服務端會超時重發第三次揮手的報文,客戶端收到后又會發送第四次揮手的報文;這樣確保了服務端能收到第四次報文
這里還存在一種情況:大量TIME_WAIT狀態堆積
服務器需要處理非常大量的客戶端的連接(每個連接的生存時間可能很短, 但是每秒都有很大數量的客戶端來請求)
這個時候如果由服務器端主動關閉連接(比如某些客戶端不活躍, 就需要被服務端主動清理掉), 就會產生大量 TIME_WAIT 連接
由于我們的請求量很大, 就可能導致 TIME_WAIT 的連接數很多, 每個連接都會占用一個通信五元組(源 ip, 源端口, 目的 ip, 目的端口, 協議). 其中服務器的 ip 和端口和協議是固定的
如果新來的客戶端連接的 ip 和端口號和 TIME_WAIT 占用的重復了, 就會出現問題,造成服務端bind失敗,一個解決方法使用 setsockopt()設置 socket 描述符的 選項 SO_REUSEADDR 為 1, 表示允許創建端口號相同但 IP 地址不同的多個 socket 描述符
CLOSE_WAIT
CLOSE_WAIT表示:?本地(已經收到了對端發來的?FIN
?包(關閉請求),并且已經回復了?ACK
(確認收到)。本地已經知道對端沒有數據要發送了。
它在等待:?本地應用程序(通常是服務器端的服務進程)執行?close()
?系統調用,關閉自己的套接字。只有應用程序調用了?close()
,操作系統內核才會發送?FIN
?包給對端。
這里可能存在一個問題:大量CLOSE_WAIT狀態堆積
如果服務端無法正常調用close函數關閉連接,可能會導致大量CLOSE_WAIT狀態堆積,占用文件描述符和內存,這時就需要調試尋找沒有正常關閉連接的原因。
為什么是四次揮手
你可能會疑惑,為什么斷開連接時,第二次揮手(服務端應答)和第三次揮手(服務端發送FIN報文)為什么不能合二為一(像三次握手中的第二次握手一樣,發送SYN報文的同時捎帶應答ACK報文)。這是因為客戶端發送FIN報文是表示自己不想再發送數據了,但此時服務端可能還有數據沒有發完,需要一些時間。因此,第二次揮手和第三次揮手有一定時間差,不能合二為一;同時,如果在這期間服務端又向客戶端發送了數據的話,兩次的確認序號也可能不同。
面向字節流
TCP的面向字節流是通過發送緩沖區和接收緩沖區來實現的,這使得其相較于UDP具有以下優勢:
有序性:盡管字節流在傳輸過程中會被分割成多個報文段,并且這些報文段可能亂序到達、丟失、重傳,TCP 協議保證了接收緩沖區中的字節流順序與發送緩沖區中的字節流順序完全一致。接收方應用讀取到的字節順序就是發送方寫入的字節順序。
可靠性:TCP 保證,只要連接沒有異常中斷,發送方寫入發送緩沖區的每一個字節最終都會按順序出現在接收方的接收緩沖區中,不會丟失、不會重復、不會出錯(通過校驗和、確認、重傳、序列號等機制實現)。
緩沖區機制平滑了應用層讀寫速度與網絡傳輸速度之間的差異(可以進行流量控制)。
但這也導致其沒有消息邊界(UDP是面向報文發送,有天然的消息邊界),進而催生了粘包和拆包兩個問題:
粘包問題:發送方多次寫入的較小數據塊,可能被 TCP 合并成一個較大的報文段發送
拆包問題:發送方寫入的一個較大數據塊,可能被 TCP 拆分成多個較小的報文段發送
這里舉一個例子:
發送方分別發送"Hello"和"World"
接收方讀取緩沖區中的內容,?返回 “HelloWorld”。
又或者是發送方分別發送 “HelloWorld
接收方讀取緩沖區中的內容,?返回”"Hello"和"World"。
為了解決這一問題,需要明確兩個包之間的邊界:
對于定長的包, 保證每次都按固定大小讀取即可;
對于變長的包, 可以在包頭的位置, 約定一個包總長度的字段, 從而就知道了包的結束位置;
對于變長的包, 還可以在包和包之間使用明確的分隔符(應用層協議來定, 只要保證分隔符不和正文沖突即可);