淺談TCP(1):狀態機與重傳機制

monkeysayhi發表於2018-04-03

TCP協議比較複雜,接下來分兩篇文章淺要介紹TCP中的一些要點。

本文介紹TCP的狀態機與重傳機制,下文講解流量控制與擁塞控制。

本文大部分內容基於TCP 的那些事兒(上)修改而來,部分觀點與原文不同,重要地方增加了解釋。

前置知識

一些網路基礎

TCP在網路OSI的七層模型中的第四層——Transport層,IP在第三層——Network層,ARP在第二層——Data Link層,在第二層上的資料,我們叫Frame,在第三層上的資料叫Packet,第四層的資料叫Segment。

應用層的資料首先會打到TCP的Segment中,然後TCP的Segment會打到IP的Packet中,然後再打到乙太網Ethernet的Frame中,傳到對端後,各個層解析自己的協議,然後把資料交給更高層的協議處理。

TCP頭格式

在正式討論之前,先來看一下TCP頭的格式: [圖片上傳中...(image.png-eed30f-1522722733998-0)]

image.png

注意:

  • TCP的包是沒有IP地址的,那是IP層上的事。但是有源埠和目標埠。
  • 一個TCP連線需要四個元組來表示是同一個連線(src_ip, src_port, dst_ip, dst_port)(準確說是五元組,還有一個是協議,但因為這裡只是說TCP協議,所以,這裡我只說四元組)。
  • 注意上圖中的四個非常重要的東西:
    • Sequence Number,包的序號Seq,用於解決網路包亂序(reordering)。
    • Acknowledgement Number,Ack用於確認收到Seq(Ack = Seq + 1,表示收到了Seq及Seq之前的資料包,期待Seq + 1),用於解決丟包
    • Window,又叫Advertised Window,可以近似理解為滑動視窗(Sliding Window)的大小,用於流控
    • TCP Flag ,區分包的型別,如SYN包、FIN包、RST包等,主要_用於操控TCP狀態機_。

其他欄位參考下圖:

image.png

TCP的狀態機

其實,網路傳輸是沒有連線的——TCP所謂的“連線”,其實只不過是在通訊的雙方維護一個“連線狀態”,讓它看上去好像有連線一樣。所以,TCP的狀態轉換非常重要。

下面是簡化的“TCP協議狀態機” 和 “TCP三次握手建連線 + 傳資料 + 四次揮手斷連線” 的對照圖,兩張圖本質上都描述了TCP協議狀態機,但場景略有不同。這兩個圖非常重要,一定要記牢

TCP協議狀態機,不區分client、server:

image.png

下圖是經典的“TCP三次握手建連線 + 傳資料 + 四次揮手斷連線”,client發起握手,向server傳輸資料(server不向client傳),最後發起揮手:

image.png

三次握手與四次揮手

很多人會問,為什麼建連線要三次握手,斷連線需要四次揮手?

三次握手建連線

主要是要_初始化Sequence Number 的初始值_。

通訊的雙方要同步對方ISN(初始化序列號,Inital Sequence Number)——所以叫SYN(全稱Synchronize Sequence Numbers)。也就是上圖中的 x 和 y。這個號在以後的資料通訊中,在client端按傳送順序遞增,在server端按遞增順序重新組織,以保證應用層接收到的資料不會因為網路問題亂序。

四次揮手斷連線

其實是_雙方各自進行2次揮手_。

因為TCP是全雙工的,client與server都佔用各自的資源傳送segment(同一通道,同時雙向傳輸seq和ack),所以,雙方都需要關閉自己的資源(向對方傳送FIN)並確認對方資源已關閉(回覆對方Ack);而雙方可以同時主動關閉,也可以由一方主動關閉帶動另一方被動關閉。只不過,通常以一方主動另一方被動舉例(如圖,client主動server被動),所以看上去是所謂的4次揮手。

如果兩邊同時主動斷連線,那麼雙方都會進入CLOSING狀態,然後到達TIME_WAIT狀態,最後超時轉到CLOSED狀態。下圖是雙方同時主動斷連線的示意圖(對應TCP狀態機中的Simultaneous Close分支):

image.png

握手過程中的其他問題

建連線時SYN超時

server收到client發的SYN並回復Ack(SYN)(此處稱為Ack1)後,如果client掉線了(或網路超時),那麼server將無法收到client回覆的Ack(Ack(SYN))(此處稱為Ack2),連線處於一個中間狀態(非成功非失敗)。

為了解決中間狀態的問題,server如果在一定時間內沒有收到Ack2,會重發Ack1(不同於資料傳輸過程中的重傳機制)。Linux下,預設重試5次,加上第一次最多共傳送6次;重試間隔從1s開始翻倍增長(一種指數回退策略,Exponential Backoff),5次的重試時間分別為1s, 2s, 4s, 8s, 16s,第5次發出後還要等待32s才能判斷第5次也超時。所以,至多共傳送6次,經過1s + 2s + 4s+ 8s+ 16s + 32s = 2^6 -1 = 63s,TCP才會認為SYN超時斷開這個連線

SYN Flood攻擊

可以利用建連線時的SYN超時機制發起SYN Flood攻擊——給server發一個SYN就立即下線,於是伺服器預設需要佔用資源63s才會斷開連線。發SYN的速度是很快的,這樣,攻擊者很容易將server的SYN佇列資源耗盡,使server無法處理正常的新連線。

針對該問題,Linux提供了一個tcp_syncookies引數解決這個問題——當SYN佇列滿了後,TCP會通過源地址埠、目標地址埠和時間戳構造一個特別的Sequence Number發回去,稱為SYN Cookie,如果是攻擊者則不會有響應,如果是正常連線,則會把這個SYN Cookie發回來,然後server端可以通過SYN Cookie建連線(即使你不在SYN佇列中)。至於SYN佇列中的連線,則不做處理直至超時關閉。請注意,不要用tcp_syncookies引數來處理正常的大負載連線情況,因為SYN Cookie本質上也破壞了建連線的SYN超時機制,是妥協版的TCP協議。

對於正常的連線請求,有另外三個引數可供選擇:

  • tcp_synack_retries引數設定SYN超時重試次數
  • tcp_max_syn_backlog引數設定最大SYN連線數(SYN佇列容量)
  • tcp_abort_on_overflow引數使SYN請求處理不過來的時候拒絕連線

ISN的同步

  • 首先,不能選擇靜態的ISN。例如,如果連線建好後始終用1來做ISN,如果client發了30個segment(假設一個位元組一個segment)過去,但是網路斷了,於是 client重連,又用了1做ISN,但是舊連線的那些segment(稱為“迷途的重複分組”)到了,由於區分連線的五元組相同(稱該新連線為舊連線的“化身”),server會把它們當做新連線中的segment。
  • 然後,從上例還能夠得知,需要使ISN隨時鐘動態增長,以保證新連線的ISN大於舊連線。
  • 最後,從安全等角度考慮,也不能使ISN的增長呈現規律性(如簡單隨時鐘正比例增長)。這很容易理解,如果增長規律過於簡單,則很容偽造ISN對網路兩端發起攻擊。

最終,設計了多種ISN增長演算法,普遍_使ISN隨時鐘動態增長,並具有一定的隨機性_。RFC793中描述了一種簡單的ISN增長演算法:ISN會和一個假的時鐘綁在一起,這個時鐘會在每4微秒對ISN做加一操作,直到超過2^32,又從0開始。這樣,一個ISN的週期大約是4.55(我算的4.77???)個小時。定義segment在網路上的最大存活時間為MSL(Maximum Segment Lifetime),網路中存活時間超過MSL的分組將被丟棄。因此,如果使用RFC793中的ISN增長演算法,則MSL的值必須小於4.55小時,以保證不會在相鄰的連線中重用ISN(TIME_WAIT也有該作用)。同時,這間接限制了網路的大小(當然,4.55小時的MSL已經能構造非常大的網路了)。

MSL應大於IP協議TTL換算的時間,RFC793建議MSL設定為2分鐘,Linux遵循伯克利習慣設定為30s。

揮手過程中的其他問題

關於TIME_WAIT

為什麼需要TIME_WAIT

在TCP狀態機中,從TIME_WAIT狀態到CLOSED狀態,有一個超時時間 2 * MSL。為什麼需要TIME_WAIT狀態,且超時時間為2 * MSL?主要有兩個原因:

  • 2 * MSL確保有足夠的時間讓被動方收到了ACK或主動方收到了被動發超時重傳的FIN。即,如果被動方沒有收到Ack,就會觸發被動方重傳FIN,傳送Ack+接收FIN正好2個MSL,TIME_WAIT狀態的連線收到重傳的FIN後,重傳Ack,再等待2 * MSL時間。
  • 確保有足夠的時間讓“迷途的重複分組”過期丟棄。這隻需要1 * MSL即可,超過MSL的分組將被丟棄,否則很容易同新連線的資料混在一起(僅僅依靠ISN是不行的)。

大規模出現TIME_WAIT

一個常見問題是大規模出現TIME_WAIT,通常是在高併發短連線的場景中,會消耗很多資源。

網上大部分文章都是教你開啟兩個引數,tcp_tw_reusetcp_tw_recycle。這兩個引數預設都是關閉的,tcp_tw_recycletcp_tw_reuse更為激進;要想使用二者,還需要開啟tcp_timestamps(預設開啟),否則無效。不過,開啟這兩個引數可能會讓TCP連線出現詭異的問題:如上所述,如果不等待超時就重用連線的話,新舊連線的資料可能會混在一起,比如新連線握手期間收到了舊連線的FIN,則新連線會被重置。因此,使用這兩個引數時應格外小心

各引數詳細如下:

  • tcp_tw_reuse:官方文件上說tcp_tw_reuse加上tcp_timestamps可以保證客戶端(僅客戶端)在協議角度的安全,但是需要在兩端都開啟tcp_timestamps
  • tcp_tw_recycle:如果是tcp_tw_recycle被開啟了話,會假設對端開啟了tcp_timestamps,然後會去比較時間戳,如果時間戳變大了,就可以重用連線(NAT網路有可能建連線失敗,出現"connection time out"的錯誤)。

補充一個引數:

  • tcp_max_tw_buckets:控制併發的TIME_WAIT的數量(預設180000),如果超限,系統會把多餘的TIME_WAIT連線destory掉,然後在日誌裡打一個警告(如“time wait bucket table overflow”)。官網文件說這個引數是用來對抗DDoS攻擊的,需要根據實際情況考慮。

關於TIME_WAIT的建議

總之,TIME_WAIT出現在主動發起揮手的一方,即,誰發起揮手誰就要犧牲資源維護那些等待從TIME_WAIT轉換到CLOSED狀態的連線。TIME_WAIT的存在是必要的,因此,與其通過上述引數破協議來逃避TIME_WAIT,不如好好優化業務(如改用長連線等),針對不同業務優化TIME_WAIT問題。

對於HTTP伺服器,可以設定HTTP的KeepAlive引數,在應用層重用TCP連線來處理多個HTTP請求(需要瀏覽器配合),讓client端(即瀏覽器)發起揮手,這樣TIME_WAIT只會出現在client端。

示例

下圖是我從Wireshark中截了個我在訪問coolshell.cn時的有資料傳輸的圖,可以參照理解Seq與Ack是怎麼變的(使用Wireshark選單中的Statistics ->Flow Graph… ):

image.png

可以看到,Seq與Ack的增加和傳輸的位元組數相關。上圖中,三次握手後,來了兩個Len:1440的包,因此第一個包為Seq(1),第二個包為Seq(1441)。然後收到第一個Ack(1441),表示1~1440的資料已經收到了,期待Seq(1441)。另外,可以看到一個包可以同時充當Ack與Seq,在一次傳輸中攜帶資料與響應。

如果你用Wireshark抓包程式看3次握手,你會發現ISN總是為0。不是這樣的,Wireshark為了顯示更友好,使用了Relative Seq——相對序號。你只要在右鍵選單中的protocol preference中取消掉就可以看到“Absolute Seq”了。

TCP重傳機制

TCP協議通過重傳機制保證所有的segment都可以到達對端,通過滑動視窗允許一定程度的亂序和丟包(滑動視窗還具有流量控制等作用,暫不討論)。注意,此處重傳機制特指資料傳輸階段,握手、揮手階段的傳輸機制與此不同。

TCP是面向位元組流的,Seq與Ack的增長均以位元組為單位。在最樸素的實現中,為了減少網路傳輸,接收端只回復最後一個連續包的Ack,並相應移動視窗。比如,傳送端傳送1,2,3,4,5一共五份資料(假設一份資料一個位元組),接收端快速收到了Seq 1, Seq 2,於是回Ack 3,並移動視窗;然後收到了Seq 4,由於在此之前未收到過Seq 3(亂序),如果仍在視窗內,則只填充視窗,但不傳送Ack 5,否則丟棄Seq 3(與丟包的效果相似);假設在視窗內,則等以後收到Seq 3時,發現Seq 4及以前的資料包都收到了,則回Ack 5,並移動視窗。

超時重傳機制

當傳送方發現等待Seq 3的Ack(即Ack 4)超時後,會認為Seq 3傳送“失敗”,重傳Seq 3。一旦接收方收到Seq 3,會立即回Ack 4。

傳送方無法區分是Seq 3丟包、接收方故障、還是Ack 4丟包,本文統一表述為Seq傳送“失敗”。

這種方式有些問題:假設目前已收到了Seq 4;由於未收到Seq 3,導致傳送方重傳Seq 3,在收到重傳的Seq 3之前,包括新收到的Seq 5和剛才收到的Seq 4都不能回覆Ack,很容易引發傳送方重傳Seq 4、Seq5。接收方之前已經將Seq 4、Seq 5儲存到視窗中,此時重傳Seq 4、Seq 5明顯造成浪費。

也就是說,超時重傳機制面臨“重傳一個還是重傳所有”的問題,即:

  • 重傳一個:僅重傳timeout的包(即Seq 3),後續包等超時後再重傳。節省資源,但效率略低。
  • 重傳所有:每次都重傳timeout包及之後所有的資料(即Seq 3、4、5)。效率更高(如果頻寬未打滿),但浪費資源。

可知,兩種方法都屬於超時重傳機制,各有利弊,但二者都需要等待timeout,是基於時間驅動的,效能與timeout的長度密切相關。如果timeout很長(普遍情況),則兩種方法的效能都會受到較大影響。

快速重傳機制

最理想的方案是:在超時之前,通過某種機制要求傳送方儘快重傳timeout的包(即Seq 3),如快速重傳機制(Fast Retransmit)。這種方案浪費資源(浪費多少取決於“重傳一個還是重傳所有”,見下),但效率非常高(因為不需要等待timeout了)。

快速重傳機制不基於時間驅動,而基於資料驅動如果包沒有連續到達,就Ack最後那個可能被丟了的包;如果傳送方連續收到3次相同的Ack,就重傳對應的Seq

比如:假設傳送方仍然傳送1,2,3,4,5共5份資料;接收方先收到Seq 1,回Ack 2;然後Seq 2因網路原因丟失了,正常收到Seq 3,繼續回Ack 2;後面Seq 4和Seq 5都到了,最後一個可能被丟了的包還是Seq 2,繼續回Ack 2;現在,傳送方已經連續收到4次(大於等於3次)相同的Ack(即Ack 2),知道最大序號的未收到包是Seq 2,於是重傳Seq 2,並清空Ack 2的計數器;最後,接收方收到了Seq 2,檢視視窗發現Seq 3、4、5都收到了,回Ack 6。示意圖如下:

image.png

快速重傳解決了timeout的問題,但依然面臨“重傳一個還是重傳所有”的問題。對於上面的示例來說,是隻重傳Seq 2呢還是重傳Seq 2、3、4、5呢?

如果只使用快速重傳,則必須重傳所有:因為傳送方並不清楚上述連續的4次Ack 2是因為哪些Seq傳回來的。假設傳送方發出了Seq 1到Seq 20供20份資料,只有Seq 1、6、10、20到達了接收方,觸發重傳Ack 2;然後傳送方重傳Seq 2,接收方收到,回覆Ack 3;接下來,傳送方與接收方都不會再傳送任何資料,兩端陷入等待。因此,傳送方只能選擇“重傳所有”,這也是某些TCP協議的實際實現,對於頻寬未滿時重傳效率的提升非常明顯。

一個更完美的設計是:將超時重傳與快速重傳結合起來,觸發快速重傳時,只重傳區域性的一小段Seq(區域性性原理,甚至只重傳一個Seq),其他Seq超時後重傳


參考:


本文連結:淺談TCP(1):狀態機與重傳機制
作者:猴子007
出處:monkeysayhi.github.io
本文基於 知識共享署名-相同方式共享 4.0 國際許可協議釋出,歡迎轉載,演繹或用於商業目的,但是必須保留本文的署名及連結。

相關文章