TCP 的那些事兒(上)

FreeeLinux發表於2017-01-31


TCP是一個巨複雜的協議,因為他要解決很多問題,而這些問題又帶出了很多子問題和陰暗面。所以學習TCP本身是個比較痛苦的過程,但對於學習的過程卻能讓人有很多收穫。關於TCP這個協議的細節,我還是推薦你去看W.Richard Stevens的《TCP/IP 詳解 卷1:協議》(當然,你也可以去讀一下RFC793以及後面N多的RFC)。另外,本文我會使用英文術語,這樣方便你通過這些英文關鍵詞來查詢相關的技術文件。

之所以想寫這篇文章,目的有三個,

  • 一個是想鍛鍊一下自己是否可以用簡單的篇幅把這麼複雜的TCP協議描清楚的能力。
  • 另一個是覺得現在的好多程式設計師基本上不會認認真真地讀本書,喜歡快餐文化,所以,希望這篇快餐文章可以讓你對TCP這個古典技術有所瞭解,並能體會到軟體設計中的種種難處。並且你可以從中有一些軟體設計上的收穫。
  • 最重要的希望這些基礎知識可以讓你搞清很多以前一些似是而非的東西,並且你能意識到基礎的重要。

所以,本文不會面面俱到,只是對TCP協議、演算法和原理的科普。

我本來只想寫一個篇幅的文章的,但是TCP真TMD的複雜,比C++複雜多了,這30多年來,各種優化變種爭論和修改。所以,寫著寫著就發現只有砍成兩篇。

  • 上篇中,主要向你介紹TCP協議的定義和丟包時的重傳機制。
  • 下篇中,重點介紹TCP的流迭、擁塞處理。

廢話少說,首先,我們需要知道TCP在網路OSI的七層模型中的第四層——Transport層,IP在第三層——Network層,ARP在第二層——Data Link層,在第二層上的資料,我們叫Frame,在第三層上的資料叫Packet,第四層的資料叫Segment。

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

TCP頭格式

接下來,我們來看一下TCP頭的格式

TCP頭格式(圖片來源

你需要注意這麼幾點:

  • TCP的包是沒有IP地址的,那是IP層上的事。但是有源埠和目標埠。
  • 一個TCP連線需要四個元組來表示是同一個連線(src_ip, src_port, dst_ip, dst_port)準確說是五元組,還有一個是協議。但因為這裡只是說TCP協議,所以,這裡我只說四元組。
  • 注意上圖中的四個非常重要的東西:
    • Sequence Number是包的序號,用來解決網路包亂序(reordering)問題。
    • Acknowledgement Number就是ACK——用於確認收到,用來解決不丟包的問題
    • Window又叫Advertised-Window,也就是著名的滑動視窗(Sliding Window),用於解決流控的
    • TCP Flag ,也就是包的型別,主要是用於操控TCP的狀態機的

關於其它的東西,可以參看下面的圖示

圖片來源

TCP的狀態機

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

下面是:“TCP協議的狀態機”(圖片來源) 和 “TCP建連結”、“TCP斷連結”、“傳資料” 的對照圖,我把兩個圖並排放在一起,這樣方便在你對照著看。另外,下面這兩個圖非常非常的重要,你一定要記牢。(吐個槽:看到這樣複雜的狀態機,就知道這個協議有多複雜,複雜的東西總是有很多坑爹的事情,所以TCP協議其實也挺坑爹的)

 

很多人會問,為什麼建連結要3次握手,斷連結需要4次揮手?

  • 對於建連結的3次握手,主要是要初始化Sequence Number 的初始值。通訊的雙方要互相通知對方自己的初始化的Sequence Number(縮寫為ISN:Inital Sequence Number)——所以叫SYN,全稱Synchronize Sequence Numbers。也就上圖中的 x 和 y。這個號要作為以後的資料通訊的序號,以保證應用層接收到的資料不會因為網路上的傳輸的問題而亂序(TCP會用這個序號來拼接資料)。
  • 對於4次揮手,其實你仔細看是2次,因為TCP是全雙工的,所以,傳送方和接收方都需要Fin和Ack。只不過,有一方是被動的,所以看上去就成了所謂的4次揮手。如果兩邊同時斷連線,那就會就進入到CLOSING狀態,然後到達TIME_WAIT狀態。下圖是雙方同時斷連線的示意圖(你同樣可以對照著TCP狀態機看):


兩端同時斷連線(圖片來源

 

另外,有幾個事情需要注意一下:

  • 關於建連線時SYN超時。試想一下,如果server端接到了clien發的SYN後回了SYN-ACK後client掉線了,server端沒有收到client回來的ACK,那麼,這個連線處於一箇中間狀態,即沒成功,也沒失敗。於是,server端如果在一定時間內沒有收到的TCP會重發SYN-ACK。在Linux下,預設重試次數為5次,重試的間隔時間從1s開始每次都翻售,5次的重試時間間隔為1s, 2s, 4s, 8s, 16s,總共31s,第5次發出後還要等32s都知道第5次也超時了,所以,總共需要 1s + 2s + 4s+ 8s+ 16s + 32s = 2^6 -1 = 63s,TCP才會把斷開這個連線。
  • 關於SYN Flood攻擊。一些惡意的人就為此製造了SYN Flood攻擊——給伺服器發了一個SYN後,就下線了,於是伺服器需要預設等63s才會斷開連線,這樣,攻擊者就可以把伺服器的syn連線的佇列耗盡,讓正常的連線請求不能處理。於是,Linux下給了一個叫tcp_syncookies的引數來應對這個事——當SYN佇列滿了後,TCP會通過源地址埠、目標地址埠和時間戳打造出一個特別的Sequence Number發回去(又叫cookie),如果是攻擊者則不會有響應,如果是正常連線,則會把這個 SYN Cookie發回來,然後服務端可以通過cookie建連線(即使你不在SYN佇列中)。請注意,請先千萬別用tcp_syncookies來處理正常的大負載的連線的情況。因為,synccookies是妥協版的TCP協議,並不嚴謹。對於正常的請求,你應該調整三個TCP引數可供你選擇,第一個是:tcp_synack_retries 可以用他來減少重試次數;第二個是:tcp_max_syn_backlog,可以增大SYN連線數;第三個是:tcp_abort_on_overflow 處理不過來乾脆就直接拒絕連線了。
  • 關於ISN的初始化。ISN是不能hard code的,不然會出問題的——比如:如果連線建好後始終用1來做ISN,如果client發了30個segment過去,但是網路斷了,於是 client重連,又用了1做ISN,但是之前連線的那些包到了,於是就被當成了新連線的包,此時,client的Sequence Number 可能是3,而Server端認為client端的這個號是30了。全亂了。RFC793中說,ISN會和一個假的時鐘綁在一起,這個時鐘會在每4微秒對ISN做加一操作,直到超過2^32,又從0開始。這樣,一個ISN的週期大約是4.55個小時。因為,我們假設我們的TCP Segment在網路上的存活時間不會超過Maximum Segment Lifetime(縮寫為MSL – Wikipedia語條),所以,只要MSL的值小於4.55小時,那麼,我們就不會重用到ISN。
  • 關於 MSL 和 TIME_WAIT。通過上面的ISN的描述,相信你也知道MSL是怎麼來的了。我們注意到,在TCP的狀態圖中,從TIME_WAIT狀態到CLOSED狀態,有一個超時設定,這個超時設定是 2*MSL(RFC793定義了MSL為2分鐘,Linux設定成了30s)為什麼要這有TIME_WAIT?為什麼不直接給轉成CLOSED狀態呢?主要有兩個原因:1)TIME_WAIT確保有足夠的時間讓對端收到了ACK,如果被動關閉的那方沒有收到Ack,就會觸發被動端重發Fin,一來一去正好2個MSL,2)有足夠的時間讓這個連線不會跟後面的連線混在一起(你要知道,有些自做主張的路由器會快取IP資料包,如果連線被重用了,那麼這些延遲收到的包就有可能會跟新連線混在一起)。你可以看看這篇文章《TIME_WAIT and its design implications for protocols and scalable client server systems
  • 關於TIME_WAIT數量太多。從上面的描述我們可以知道,TIME_WAIT是個很重要的狀態,但是如果在大併發的短連結下,TIME_WAIT 就會太多,這也會消耗很多系統資源。只要搜一下,你就會發現,十有八九的處理方式都是教你設定兩個引數,一個叫tcp_tw_reuse,另一個叫tcp_tw_recycle的引數,這兩個引數預設值都是被關閉的,後者recyle比前者resue更為激進,resue要溫柔一些。另外,如果使用tcp_tw_reuse,必需設定tcp_timestamps=1,否則無效。這裡,你一定要注意,開啟這兩個引數會有比較大的坑——可能會讓TCP連線出一些詭異的問題(因為如上述一樣,如果不等待超時重用連線的話,新的連線可能會建不上。正如官方文件上說的一樣“It should not be changed without advice/request of technical experts”)。
    • 關於tcp_tw_reuse。官方文件上說tcp_tw_reuse 加上tcp_timestamps(又叫PAWS, for Protection Against Wrapped Sequence Numbers)可以保證協議的角度上的安全,但是你需要tcp_timestamps在兩邊都被開啟(你可以讀一下tcp_twsk_unique的原始碼 )。我個人估計還是有一些場景會有問題。
    • 關於tcp_tw_recycle。如果是tcp_tw_recycle被開啟了話,會假設對端開啟了tcp_timestamps,然後會去比較時間戳,如果時間戳變大了,就可以重用。但是,如果對端是一個NAT網路的話(如:一個公司只用一個IP出公網)或是對端的IP被另一臺重用了,這個事就複雜了。建連結的SYN可能就被直接丟掉了(你可能會看到connection time out的錯誤)(如果你想觀摩一下Linux的核心程式碼,請參看原始碼 tcp_timewait_state_process)。
    • 關於tcp_max_tw_buckets。這個是控制併發的TIME_WAIT的數量,預設值是180000,如果超限,那麼,系統會把多的給destory掉,然後在日誌裡打一個警告(如:time wait bucket table overflow),官網文件說這個引數是用來對抗DDoS攻擊的。也說的預設值180000並不小。這個還是需要根據實際情況考慮。

Again,使用tcp_tw_reuse和tcp_tw_recycle來解決TIME_WAIT的問題是非常非常危險的,因為這兩個引數違反了TCP協議(RFC 1122) 

其實,TIME_WAIT表示的是你主動斷連線,所以,這就是所謂的“不作死不會死”。試想,如果讓對端斷連線,那麼這個破問題就是對方的了,呵呵。另外,如果你的伺服器是於HTTP伺服器,那麼設定一個HTTP的KeepAlive有多重要(瀏覽器會重用一個TCP連線來處理多個HTTP請求),然後讓客戶端去斷連結(你要小心,瀏覽器可能會非常貪婪,他們不到萬不得已不會主動斷連線)。

資料傳輸中的Sequence Number

下圖是我從Wireshark中截了個我在訪問coolshell.cn時的有資料傳輸的圖給你看一下,SeqNum是怎麼變的。(使用Wireshark選單中的Statistics ->Flow Graph… )

你可以看到,SeqNum的增加是和傳輸的位元組數相關的。上圖中,三次握手後,來了兩個Len:1440的包,而第二個包的SeqNum就成了1441。然後第一個ACK回的是1441,表示第一個1440收到了。

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

TCP重傳機制

TCP要保證所有的資料包都可以到達,所以,必需要有重傳機制。

注意,接收端給傳送端的Ack確認只會確認最後一個連續的包,比如,傳送端發了1,2,3,4,5一共五份資料,接收端收到了1,2,於是回ack 3,然後收到了4(注意此時3沒收到),此時的TCP會怎麼辦?我們要知道,因為正如前面所說的,SeqNum和Ack是以位元組數為單位,所以ack的時候,不能跳著確認,只能確認最大的連續收到的包,不然,傳送端就以為之前的都收到了。

超時重傳機制

一種是不回ack,死等3,當傳送方發現收不到3的ack超時後,會重傳3。一旦接收方收到3後,會ack 回 4——意味著3和4都收到了。

但是,這種方式會有比較嚴重的問題,那就是因為要死等3,所以會導致4和5即便已經收到了,而傳送方也完全不知道發生了什麼事,因為沒有收到Ack,所以,傳送方可能會悲觀地認為也丟了,所以有可能也會導致4和5的重傳。

對此有兩種選擇:

  • 一種是僅重傳timeout的包。也就是第3份資料。
  • 另一種是重傳timeout後所有的資料,也就是第3,4,5這三份資料。

這兩種方式有好也有不好。第一種會節省頻寬,但是慢,第二種會快一點,但是會浪費頻寬,也可能會有無用功。但總體來說都不好。因為都在等timeout,timeout可能會很長(在下篇會說TCP是怎麼動態地計算出timeout的)

快速重傳機制

於是,TCP引入了一種叫Fast Retransmit 的演算法,不以時間驅動,而以資料驅動重傳。也就是說,如果,包沒有連續到達,就ack最後那個可能被丟了的包,如果傳送方連續收到3次相同的ack,就重傳。Fast Retransmit的好處是不用等timeout了再重傳。

比如:如果傳送方發出了1,2,3,4,5份資料,第一份先到送了,於是就ack回2,結果2因為某些原因沒收到,3到達了,於是還是ack回2,後面的4和5都到了,但是還是ack回2,因為2還是沒有收到,於是傳送端收到了三個ack=2的確認,知道了2還沒有到,於是就馬上重轉2。然後,接收端收到了2,此時因為3,4,5都收到了,於是ack回6。示意圖如下:

Fast Retransmit只解決了一個問題,就是timeout的問題,它依然面臨一個艱難的選擇,就是,是重傳之前的一個還是重傳所有的問題。對於上面的示例來說,是重傳#2呢還是重傳#2,#3,#4,#5呢?因為傳送端並不清楚這連續的3個ack(2)是誰傳回來的?也許傳送端發了20份資料,是#6,#10,#20傳來的呢。這樣,傳送端很有可能要重傳從2到20的這堆資料(這就是某些TCP的實際的實現)。可見,這是一把雙刃劍。

SACK 方法

另外一種更好的方式叫:Selective Acknowledgment (SACK)(參看RFC 2018),這種方式需要在TCP頭裡加一個SACK的東西,ACK還是Fast Retransmit的ACK,SACK則是彙報收到的資料碎版。參看下圖:

這樣,在傳送端就可以根據回傳的SACK來知道哪些資料到了,哪些沒有到。於是就優化了Fast Retransmit的演算法。當然,這個協議需要兩邊都支援。在 Linux下,可以通過tcp_sack引數開啟這個功能(Linux 2.4後預設開啟)。

這裡還需要注意一個問題——接收方Reneging,所謂Reneging的意思就是接收方有權把已經報給傳送端SACK裡的資料給丟了。這樣幹是不被鼓勵的,因為這個事會把問題複雜化了,但是,接收方這麼做可能會有些極端情況,比如要把記憶體給別的更重要的東西。所以,傳送方也不能完全依賴SACK,還是要依賴ACK,並維護Time-Out,如果後續的ACK沒有增長,那麼還是要把SACK的東西重傳,另外,接收端這邊永遠不能把SACK的包標記為Ack。

注意:SACK會消費傳送方的資源,試想,如果一個攻擊者給資料傳送方發一堆SACK的選項,這會導致傳送方開始要重傳甚至遍歷已經發出的資料,這會消耗很多傳送端的資源。詳細的東西請參看《TCP SACK的效能權衡

Duplicate SACK – 重複收到資料的問題

Duplicate SACK又稱D-SACK,其主要使用了SACK來告訴傳送方有哪些資料被重複接收了RFC-2883 裡有詳細描述和示例。下面舉幾個例子(來源於RFC-2883

D-SACK使用了SACK的第一個段來做標誌,

  • 如果SACK的第一個段的範圍被ACK所覆蓋,那麼就是D-SACK
  • 如果SACK的第一個段的範圍被SACK的第二個段覆蓋,那麼就是D-SACK

示例一:ACK丟包

下面的示例中,丟了兩個ACK,所以,傳送端重傳了第一個資料包(3000-3499),於是接收端發現重複收到,於是回了一個SACK=3000-3500,因為ACK都到了4000意味著收到了4000之前的所有資料,所以這個SACK就是D-SACK——旨在告訴傳送端我收到了重複的資料,而且我們的傳送端還知道,資料包沒有丟,丟的是ACK包。

1
2
3
4
5
6
7
Transmitted  Received    ACK Sent
Segment      Segment     (Including SACK Blocks)
 
3000-3499    3000-3499   3500 (ACK dropped)
3500-3999    3500-3999   4000 (ACK dropped)
3000-3499    3000-3499   4000, SACK=3000-3500
                                    ---------

 示例二,網路延誤

下面的示例中,網路包(1000-1499)被網路給延誤了,導致傳送方沒有收到ACK,而後面到達的三個包觸發了“Fast Retransmit演算法”,所以重傳,但重傳時,被延誤的包又到了,所以,回了一個SACK=1000-1500,因為ACK已到了3000,所以,這個SACK是D-SACK——標識收到了重複的包。

這個案例下,傳送端知道之前因為“Fast Retransmit演算法”觸發的重傳不是因為發出去的包丟了,也不是因為回應的ACK包丟了,而是因為網路延時了。

1
2
3
4
5
6
7
8
9
10
11
Transmitted    Received    ACK Sent
Segment        Segment     (Including SACK Blocks)
 
500-999        500-999     1000
1000-1499      (delayed)
1500-1999      1500-1999   1000, SACK=1500-2000
2000-2499      2000-2499   1000, SACK=1500-2500
2500-2999      2500-2999   1000, SACK=1500-3000
1000-1499      1000-1499   3000
               1000-1499   3000, SACK=1000-1500
                                      ---------

 

可見,引入了D-SACK,有這麼幾個好處:

1)可以讓傳送方知道,是發出去的包丟了,還是回來的ACK包丟了。

2)是不是自己的timeout太小了,導致重傳。

3)網路上出現了先發的包後到的情況(又稱reordering)

4)網路上是不是把我的資料包給複製了。

 知道這些東西可以很好得幫助TCP瞭解網路情況,從而可以更好的做網路上的流控

Linux下的tcp_dsack引數用於開啟這個功能(Linux 2.4後預設開啟)

好了,上篇就到這裡結束了。如果你覺得我寫得還比較淺顯易懂,那麼,歡迎移步看下篇《TCP的那些事(下)

相關文章