天天看點

TCP協定解析TCP協定解析

TCP協定解析

    TCP是一個巨複雜的協定,因為它要解決很多問題,而這些問題又帶出了很多子問題和陰暗面。是以學習TCP本身是個比較痛苦的過程,但對于學習的過程卻能讓人有很多收獲。關于TCP這個協定的細節,我還是推薦你去看W.Richard Stevens的《

TCP/IP詳解 卷1:協定

》(當然,你也可以去讀一下

RFC793

以及後面N多的RFC)。另外,本文我會使用英文術語,這樣友善你通過這些英文關鍵詞來查找相關的技術文檔。

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

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

    是以,本文不會面面俱到,隻是對TCP協定、算法和原理的科普。

    我本來隻想寫一個篇幅的文章的,但是TCP真的很複雜,比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協定解析

                                                                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建立連結”、“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狀态機看):
TCP協定解析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了。全亂了。 中 說,ISN會和一個假的時鐘綁在一起,這個時鐘會在每4微秒對ISN做加一操作,直到超過2^32,又從0開始。這樣,一個ISN的周期大約是4.55個 小時。因為,我們假設我們的TCP Segment在網絡上的存活時間不會超過Maximum Segment Lifetime(縮寫為MSL),是以,隻要MSL的值小于4.55小時,那麼,我們就不會重用到ISN。
  • 關于 MSL 和 TIME_WAIT。通過上面的ISN的描述,相信你也知道MSL是怎麼來的了。我們注意到,在TCP的狀态圖中,從TIME_WAIT狀态到CLOSED狀态,有一個逾時設定,這個逾時設定是 2*MSL( 定 義了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… )

TCP協定解析TCP協定解析

    你可以看到,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。示意圖如下:

TCP協定解析TCP協定解析

    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則是彙報收到的資料碎版。參看下圖:

TCP協定解析TCP協定解析

    這樣,在發送端就可以根據回傳的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-2833 

裡有較長的描述和示例。下面舉幾個例子(來源于

RFC-2833

    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. TransmittedReceived ACK Sent

  2. SegmentSegment(Including SACK Blocks)

  3. 3000-34993000-34993500(ACK dropped)

  4. 3500-39993500-39994000(ACK dropped)

  5. 3000-34993000-34994000, SACK=3000-3500

  6. ---------

 示例二,網絡延誤

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

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

  1. TransmittedReceived ACK Sent

  2. SegmentSegment(Including SACK Blocks)

  3. 500-999500-9991000

  4. 1000-1499(delayed)

  5. 1500-19991500-19991000, SACK=1500-2000

  6. 2000-24992000-24991000, SACK=1500-2500

  7. 2500-29992500-29991000, SACK=1500-3000

  8. 1000-14991000-14993000

  9. 1000-14993000, SACK=1000-1500

  10. ---------

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

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

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

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

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

    知道這些東西可以很好得幫助TCP了解網絡情況,進而可以更好的做網絡上的流控。

    Linux下的tcp_dsack參數用于開啟這個功能(Linux 2.4後預設打開)。

TCP的RTT算法

    從前面的TCP重傳機制我們知道Timeout的設定對于重傳非常重要。

  • 設長了,重發就慢,丢了老半天才重發,沒有效率,性能差;
  • 設短了,會導緻可能并沒有丢就重發。于是重發的就快,會增加網絡擁塞,導緻更多的逾時,更多的逾時導緻更多的重發。

    而且,這個逾時時間在不同的網絡的情況下,根本沒有辦法設定一個死的值。隻能動态地設定。 為了動态地設定,TCP引入了RTT——Round Trip Time,也就是一個資料包從發出去到回來的時間。這樣發送端就大約知道需要多少的時間,進而可以友善地設定Timeout—— RTO(Retransmission TimeOut),以讓我們的重傳機制更高效。 聽起來似乎很簡單,好像就是在發送端發包時記下t0,然後接收端再把這個ack回來時再記一個t1,于是RTT = t1 – t0。沒那麼簡單,這隻是一個采樣,不能代表普遍情況。

經典算法

 中定義的經典算法是這樣的:

    1)首先,先采樣RTT,記下最近好幾次的RTT值。

    2)然後做平滑計算SRTT( Smoothed RTT)。公式為:(其中的 α 取值在0.8 到 0.9之間,這個算法英文叫Exponential weighted moving average,中文叫:權重移動平均)

   SRTT = ( α * SRTT ) + ((1- α) * RTT)

    3)開始計算RTO。公式如下:

   RTO = min [ UBOUND,  max [ LBOUND,   (β * SRTT) ]  ]

    其中:

  • UBOUND是最大的timeout時間,上限值
  • LBOUND是最小的timeout時間,下限值
  • β 值一般在1.3到2.0之間。
Karn / Partridge 算法

    但是上面的這個算法在重傳的時候會出有一個終極問題——你是用第一次發資料的時間和ack回來的時間做RTT樣本值,還是用重傳的時間和ACK回來的時間做RTT樣本值?

    這個問題無論你選那頭都是按下葫蘆起了瓢。 如下圖所示:

  • 情況(a)是ack沒回來,是以重傳。如果你計算第一次發送和ACK的時間,那麼,明顯算大了。
  • 情況(b)是ack回來慢了,但是導緻了重傳,但剛重傳不一會兒,之前ACK就回來了。如果你是算重傳的時間和ACK回來的時間的差,就會算短了。
TCP協定解析TCP協定解析

    是以1987年的時候,搞了一個叫

Karn / Partridge Algorithm

,這個算法的最大特點是——忽略重傳,不把重傳的RTT做采樣(你看,你不需要去解決不存在的問題)。

    但是,這樣一來,又會引發一個大BUG——如果在某一時間,網絡閃動,突然變慢了,産生了比較大的延時,這個延時導緻要重轉所有的包(因為之前的RTO很小),于是,因為重轉的不算,是以,RTO就不會被更新,這是一個災難。 于是Karn算法用了一個取巧的方式——隻要一發生重傳,就對現有的RTO值翻倍(這就是所謂的 Exponential backoff),很明顯,這種死規矩對于一個需要估計比較準确的RTT也不靠譜。

Jacobson / Karels 算法

    前面兩種算法用的都是“權重移動平均”,這種方法最大的毛病就是如果RTT有一個大的波動的話,很難被發現,因為被平滑掉了。是以,1988年,又有人推出來了一個新的算法,這個算法叫Jacobson / Karels Algorithm(參看

RFC6289

)。這個算法引入了最新的RTT的采樣和平滑過的SRTT的差距做因子來計算。 公式如下:(其中的DevRTT是Deviation RTT的意思)

    SRTT = SRTT + α (RTT – SRTT)  —— 計算平滑RTT

    DevRTT = (1-β)*DevRTT + β*(|RTT-SRTT|) ——計算平滑RTT和真實的差距(權重移動平均)

    RTO= µ * SRTT + ∂ *DevRTT —— 神一樣的公式

(其中:在Linux下,α = 0.125,β = 0.25, μ = 1,∂ = 4 ——這就是算法中的“調得一手好參數”,nobody knows why, it just works…) 最後的這個算法在被用在今天的TCP協定中(Linux的源代碼在:

tcp_rtt_estimator

TCP滑動視窗

    需要說明一下,如果你不了解TCP的滑動視窗這個事,你等于不了解TCP協定。我們都知道,TCP必需要解決的可靠傳輸以及包亂序(reordering)的問題,是以,TCP必需要知道網絡實際的資料處理帶寬或是資料處理速度,這樣才不會引起網絡擁塞,導緻丢包。

    是以,TCP引入了一些技術和設計來做網絡流控,Sliding Window是其中一個技術。 前面我們說過,TCP頭裡有一個字段叫Window,又叫Advertised-Window,這個字段是接收端告訴發送端自己還有多少緩沖區可以接收資料。于是發送端就可以根據這個接收端的處理能力來發送資料,而不會導緻接收端處理不過來。 為了說明滑動視窗,我們需要先看一下TCP緩沖區的一些資料結構:

TCP協定解析TCP協定解析

    上圖中,我們可以看到:

  • 接收端LastByteRead指向了TCP緩沖區中讀到的位置,NextByteExpected指向的地方是收到的連續包的最後一個位置,LastByteRcved指向的是收到的包的最後一個位置,我們可以看到中間有些資料還沒有到達,是以有資料空白區。
  • 發送端的LastByteAcked指向了被接收端Ack過的位置(表示成功發送确認),LastByteSent表示發出去了,但還沒有收到成功确認的Ack,LastByteWritten指向的是上層應用正在寫的地方。

    于是:

  • 接收端在給發送端回ACK中會彙報自己的AdvertisedWindow = MaxRcvBuffer – LastByteRcvd – 1;
  • 而發送方會根據這個視窗來控制發送資料的大小,以保證接收方可以處理。

    下面我們來看一下發送方的滑動視窗示意圖:

TCP協定解析TCP協定解析

    上圖中分成了四個部分,分别是:(其中那個黑模型就是滑動視窗)

  • #1已收到ack确認的資料。
  • #2發還沒收到ack的。
  • #3在視窗中還沒有發出的(接收方還有空間)。
  • #4視窗以外的資料(接收方沒空間)

    下面是個滑動後的示意圖(收到36的ack,并發出了46-51的位元組):

TCP協定解析TCP協定解析

    下面我們來看一個接受端控制發送端的圖示:

TCP協定解析TCP協定解析
Zero Window

    上圖,我們可以看到一個處理緩慢的Server(接收端)是怎麼把Client(發送端)的 TCP Sliding Window給降成0的。此時,你一定會問,如果Window變成0了,TCP會怎麼樣?是不是發送端就不發資料了?是的,發送端就不發資料了,你可以想 像成“Window Closed”,那你一定還會問,如果發送端不發資料了,接收方一會兒Window size 可用了,怎麼通知發送端呢?

    解決這個問題,TCP使用了Zero Window Probe技術,縮寫為ZWP,也就是說,發送端在視窗變成0後,會發ZWP的包給接收方,讓接收方來ack他的Window尺寸,一般這個值會設定成3 次,第次大約30-60秒(不同的實作可能會不一樣)。如果3次過後還是0的話,有的TCP實作就會發RST把連結斷了。

    注意:隻要有等待的地方都可能出現DDoS攻 擊,Zero Window也不例外,一些攻擊者會在和HTTP建好鍊發完GET請求後,就把Window設定為0,然後服務端就隻能等待進行ZWP,于是攻擊者會并發 大量的這樣的請求,把伺服器端的資源耗盡。(關于這方面的攻擊,大家可以移步看一下

Wikipedia的SockStress詞條

    另外,Wireshark中,你可以使用tcp.analysis.zero_window來過濾包,然後使用右鍵菜單裡的follow TCP stream,你可以看到ZeroWindowProbe及ZeroWindowProbeAck的包。

Silly Window Syndrome

    Silly Window Syndrome翻譯成中文就是“糊塗視窗綜合症”。正如你上面看到的一樣,如果我們的接收方太忙了,來不及取走Receive Windows裡的資料,那麼,就會導緻發送方越來越小。到最後,如果接收方騰出幾個位元組并告訴發送方現在有幾個位元組的window,而我們的發送方會義 無反顧地發送這幾個位元組。

    要知道,我們的TCP+IP頭有40個位元組,為了幾個位元組,要達上這麼大的開銷,這太不經濟了。

    另外,你需要知道網絡上有個MTU,對于以太網來說,MTU是1500位元組,除去TCP+IP頭的40個位元組,真正的資料傳輸可以有1460,這就是所謂的MSS(Max Segment Size)注意,TCP的RFC定義這個MSS的預設值是536,這是因為 

RFC 791

裡說了任何一個IP裝置都得最少接收576尺寸的大小(實際上來說576是撥号的網絡的MTU,而576減去IP頭的20個位元組就是536)。

   如果你的網絡包可以塞滿MTU,那麼你可以用滿整個帶寬,如果不能,那麼你就會浪費帶寬。(大于MTU的包有兩種結局,一種是直接被丢了,另一種是會被重新分塊打包發送) 你可以想像成一個MTU就相當于一個飛機的最多可以裝的人,如果這飛機裡滿載的話,帶寬最高,如果一個飛機隻運一個人的話,無疑成本增加了,也而相當二。

    是以,Silly Windows Syndrome這個現像就像是你本來可以坐200人的飛機裡隻做了一兩個人。 要解決這個問題也不難,就是避免對小的window size做出響應,直到有足夠大的window size再響應,這個思路可以同時實作在sender和receiver兩端。

  • 如果這個問題是由Receiver端引起的,那麼就會使用 David D Clark’s 方案。在receiver端,如果收到的資料導緻window size小于某個值,可以直接ack(0)回sender,這樣就把window給關閉了,也阻止了sender再發資料過來,等到receiver端處 理了一些資料後windows size 大于等于了MSS,或者,receiver buffer有一半為空,就可以把window打開讓send 發送資料過來。
  • 如果這個問題是由Sender端引起的,那麼就會使用著名的  Nagle’s algorithm 。這個算法的思路也是延時處理,他有兩個主要的條件(更多的條件可以看一下 tcp_nagle_check 函數):1)要等到 Window Size>=MSS 或是 Data Size >=MSS,2)等待時間或是逾時200ms,這兩個條件有一個滿足,他才會發資料,否則就是在攢資料。

    另外,Nagle算法預設是打開的,是以,對于一些需要小包場景的程式——比如像telnet或ssh這樣的互動性比較強的程式,你需要關閉這個算法。你可以在Socket設定TCP_NODELAY選項來關閉這個算法(關閉Nagle算法沒有全局參數,需要根據每個應用自己的特點來關閉)

setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value,sizeof(int));

    另外,網上有些文章說TCP_CORK的socket option是也關閉Nagle算法,這個還不夠準确。TCP_CORK是禁止小包發送,而Nagle算法沒有禁止小包發送,隻是禁止了大量的小包發送。最好不要兩個選項都設定。 老實說,我覺得Nagle算法其實隻加了個延時,沒有别的什麼,我覺得最好還是把他關閉,然後由自己的應用層來控制資料,我個覺得不應該什麼事都去依賴核心算法。

TCP的擁塞處理 – Congestion Handling

    上面我們知道了,TCP通過Sliding Window來做流控(Flow Control),但是TCP覺得這還不夠,因為Sliding Window需要依賴于連接配接的發送端和接收端,其并不知道網絡中間發生了什麼。TCP的設計者覺得,一個偉大而牛逼的協定僅僅做到流控并不夠,因為流控隻 是網絡模型4層以上的事,TCP的還應該更聰明地知道整個網絡上的事。

    具體一點,我們知道TCP通過一個timer采樣了RTT并計算RTO,但是,如果網絡上的延時突然增加,那麼,TCP對這個事做 出的應對隻有重傳資料,但是,重傳會導緻網絡的負擔更重,于是會導緻更大的延遲以及更多的丢包,于是,這個情況就會進入惡性循環被不斷地放大。試想一下, 如果一個網絡内有成千上萬的TCP連接配接都這麼行事,那麼馬上就會形成“網絡風暴”,TCP這個協定就會拖垮整個網絡。這是一個災難。

    是以,TCP不能忽略網絡上發生的事情,而無腦地一個勁地重發資料,對網絡造成更大的傷害。對此TCP的設計理念是:TCP不是一個自私的協定,當擁塞發生的時候,要做自我犧牲。就像交通阻塞一樣,每個車都應該把路讓出來,而不要再去搶路了。

    關于擁塞控制的論文請參看《

Congestion Avoidance and Control

》(PDF)

    擁塞控制主要是四個算法:1)慢啟動,2)擁塞避免,3)擁塞發生,4)快速恢複。這四個算法不是一天都搞出來的,這個四算法的發展經曆了很多時間,到今天都還在優化中。 備注:

  • 1988年,TCP-Tahoe 提出了1)慢啟動,2)擁塞避免,3)擁塞發生時的快速重傳
  • 1990年,TCP Reno 在Tahoe的基礎上增加了4)快速恢複
慢熱啟動算法 – Slow Start

    首先,我們來看一下TCP的慢熱啟動。慢啟動的意思是,剛剛加入網絡的連接配接,一點一點地提速,不要一上來就像那些特權車一樣霸道地把路占滿。新同學上高速還是要慢一點,不要把已經在高速上的秩序給搞亂了。

    慢啟動的算法如下(cwnd全稱Congestion Window):

    1)連接配接建好的開始先初始化cwnd = 1,表明可以傳一個MSS大小的資料。

    2)每當收到一個ACK,cwnd++; 呈線性上升

    3)每當過了一個RTT,cwnd = cwnd*2; 呈指數讓升

    4)還有一個ssthresh(slow start threshold),是一個上限,當cwnd >= ssthresh時,就會進入“擁塞避免算法”(後面會說這個算法)

    是以,我們可以看到,如果網速很快的話,ACK也會傳回得快,RTT也會短,那麼,這個慢啟動就一點也不慢。下圖說明了這個過程。

TCP協定解析TCP協定解析

    這裡,我需要提一下的是一篇Google的論文《

An Argument for Increasing TCP’s Initial Congestion Window

》Linux 3.0後采用了這篇論文的建議——把cwnd 初始化成了 10個MSS。 而Linux 3.0以前,比如2.6,Linux采用了

RFC3390

,cwnd是跟MSS的值來變的,如果MSS< 1095,則cwnd = 4;如果MSS>2190,則cwnd=2;其它情況下,則是3。

擁塞避免算法 – Congestion Avoidance

    前面說過,還有一個ssthresh(slow start threshold),是一個上限,當cwnd >= ssthresh時,就會進入“擁塞避免算法”。一般來說ssthresh的值是65535,機關是位元組,當cwnd達到這個值時後,算法如下:

    1)收到一個ACK時,cwnd = cwnd + 1/cwnd

    2)當每過一個RTT時,cwnd = cwnd + 1

    這樣就可以避免增長過快導緻網絡擁塞,慢慢的增加調整到網絡的最佳值。很明顯,是一個線性上升的算法。

擁塞狀态時的算法

    前面我們說過,當丢包的時候,會有兩種情況:

    1)等到RTO逾時,重傳資料包。TCP認為這種情況太糟糕,反應也很強烈。

  • sshthresh =  cwnd /2
  • cwnd 重置為 1
  • 進入慢啟動過程

    2)Fast Retransmit算法,也就是在收到3個duplicate ACK時就開啟重傳,而不用等到RTO逾時。

  • TCP Tahoe的實作和RTO逾時一樣。
  • TCP Reno的實作是:
    • cwnd = cwnd /2
    • sshthresh = cwnd
    • 進入快速恢複算法——Fast Recovery

    上面我們可以看到RTO逾時後,sshthresh會變成cwnd的一半,這意味着,如果cwnd<=sshthresh時出現的丢包,那麼 TCP的sshthresh就會減了一半,然後等cwnd又很快地以指數級增漲爬到這個地方時,就會成慢慢的線性增漲。我們可以看到,TCP是怎麼通過這 種強烈地震蕩快速而小心得找到網站流量的平衡點的。

快速恢複算法 – Fast Recovery

TCP Reno

    這個算法定義在

RFC5681

。快速重傳和快速恢複算法一般同時使用。快速恢複算法是認為,你還有3個Duplicated Acks說明網絡也不那麼糟糕,是以沒有必要像RTO逾時那麼強烈。 注意,正如前面所說,進入Fast Recovery之前,cwnd 和 sshthresh已被更新:

    然後,真正的Fast Recovery算法如下:

  • cwnd = sshthresh  + 3 * MSS (3的意思是确認有3個資料包被收到了)
  • 重傳Duplicated ACKs指定的資料包
  • 如果再收到 duplicated Acks,那麼cwnd = cwnd +1
  • 如果收到了新的Ack,那麼,cwnd = sshthresh ,然後就進入了擁塞避免的算法了。

    如果你仔細思考一下上面的這個算法,你就會知道,上面這個算法也有問題,那就是——它依賴于3個重複的Acks。 注意,3個重複的Acks并不代表隻丢了一個資料包,很有可能是丢了好多包。但這個算法隻會重傳一個,而剩下的那些包隻能等到RTO逾時,于是,進入了惡 夢模式——逾時一個視窗就減半一下,多個逾時會超成TCP的傳輸速度呈級數下降,而且也不會觸發Fast Recovery算法了。

    通常來說,正如我們前面所說的,SACK或D-SACK的方法可以讓Fast Recovery或Sender在做決定時更聰明一些,但是并不是所有的TCP的實作都支援SACK(SACK需要兩端都支援),是以,需要一個沒有 SACK的解決方案。而通過SACK進行擁塞控制的算法是FACK(後面會講)

TCP New Reno

    于是,1995年,TCP New Reno(參見 

RFC 6582

 )算法提出來,主要就是在沒有SACK的支援下改進Fast Recovery算法的——

  • 當sender這邊收到了3個Duplicated Acks,進入Fast Retransimit模式,開發重傳重複Acks訓示的那個包。如果隻有這一個包丢了,那麼,重傳這個包後回來的Ack會把整個已經被sender傳輸 出去的資料ack回來。如果沒有的話,說明有多個包丢了。我們叫這個ACK為Partial ACK。
  • 一旦Sender這邊發現了Partial ACK出現,那麼,sender就可以推理出來有多個包被丢了,于是乎繼續重傳sliding window裡未被ack的第一個包。直到再也收不到了Partial Ack,才真正結束Fast Recovery這個過程

    我們可以看到,這個“Fast Recovery的變更”是一個非常激進的玩法,他同時延長了Fast Retransmit和Fast Recovery的過程。

算法示意圖

    下面我們來看一個簡單的圖示以同時看一下上面的各種算法的樣子:

TCP協定解析TCP協定解析
FACK算法

    FACK全稱Forward Acknowledgment 算法,論文位址在這裡(PDF)

Forward Acknowledgement: Refining TCP Congestion Control

 這個算法是其于SACK的,前面我們說過SACK是使用了TCP擴充字段Ack了有哪些資料收到,哪些資料沒有收到,他比Fast Retransmit的3 個duplicated acks好處在于,前者隻知道有包丢了,不知道是一個還是多個,而SACK可以準确的知道有哪些包丢了。 是以,SACK可以讓發送端這邊在重傳過程中,把那些丢掉的包重傳,而不是一個一個的傳,但這樣的一來,如果重傳的包資料比較多的話,又會導緻本來就很忙 的網絡就更忙了。是以,FACK用來做重傳過程中的擁塞流控。

  • 這個算法會把SACK中最大的Sequence Number 儲存在snd.fack這個變量中,snd.fack的更新由ack帶秋,如果網絡一切安好則和snd.una一樣(snd.una就是還沒有收到ack的地方,也就是前面sliding window裡的category #2的第一個地方)
  • 然後定義一個awnd = snd.nxt – snd.fack(snd.nxt指向發送端sliding window中正在要被發送的地方——前面sliding windows圖示的category#3第一個位置),這樣awnd的意思就是在網絡上的資料。(所謂awnd意為:actual quantity of data outstanding in the network)
  • 如果需要重傳資料,那麼,awnd = snd.nxt – snd.fack + retran_data,也就是說,awnd是傳出去的資料 + 重傳的資料。
  • 然後觸發Fast Recovery 的條件是: ( ( snd.fack – snd.una ) > (3*MSS) ) || (dupacks == 3) ) 。這樣一來,就不需要等到3個duplicated acks才重傳,而是隻要sack中的最大的一個資料和ack的資料比較長了(3個MSS),那就觸發重傳。在整個重傳過程中cwnd不變。直到當第一次 丢包的snd.nxt<=snd.una(也就是重傳的資料都被确認了),然後進來擁塞避免機制——cwnd線性上漲。

    我們可以看到如果沒有FACK在,那麼在丢包比較多的情況下,原來保守的算法會低估了需要使用的window的大小,而需要幾個RTT的時間才會完 成恢複,而FACK會比較激進地來幹這事。 但是,FACK如果在一個網絡包會被 reordering的網絡裡會有很大的問題。

其它擁塞控制算法簡介

TCP Vegas 擁塞控制算法

    這個算法1994年被提出,它主要對TCP Reno 做了些修改。這個算法通過對RTT的非常重的監控來計算一個基準RTT。然後通過這個基準RTT來估計目前的網絡實際帶寬,如果實際帶寬比我們的期望的帶 寬要小或是要多的活,那麼就開始線性地減少或增加cwnd的大小。如果這個計算出來的RTT大于了Timeout後,那麼,不等ack逾時就直接重傳。 (Vegas 的核心思想是用RTT的值來影響擁塞視窗,而不是通過丢包) 這個算法的論文是《

TCP Vegas: End to End Congestion Avoidance on a Global Internet

》這篇論文給了Vegas和 New Reno的對比:

TCP協定解析TCP協定解析

    關于這個算法實作,你可以參看Linux源碼:

/net/ipv4/tcp_vegas.h

, 

/net/ipv4/tcp_vegas.c
HSTCP(High Speed TCP) 算法

    這個算法來自

RFC 3649

Wikipedia詞條

)。其對最基礎的算法進行了更改,他使得Congestion Window漲得快,減得慢。其中:

  • 擁塞避免時的視窗增長方式: cwnd = cwnd + α(cwnd) / cwnd
  • 丢包後視窗下降方式:cwnd = (1- β(cwnd))*cwnd

    注:α(cwnd)和β(cwnd)都是函數,如果你要讓他們和标準的TCP一樣,那麼讓α(cwnd)=1,β(cwnd)=0.5就可以了。 對于α(cwnd)和β(cwnd)的值是個動态的變換的東西。 關于這個算法的實作,你可以參看Linux源碼:

/net/ipv4/tcp_highspeed.c
 TCP BIC 算法

    2004年,産内出BIC算法。現在你還可以查得到相關的新聞《Google:

美科學家研發BIC-TCP協定 速度是DSL六千倍

》 BIC全稱

Binary Increase Congestion control

, 在Linux 2.6.8中是預設擁塞控制算法。BIC的發明者發這麼多的擁塞控制算法都在努力找一個合适的cwnd – Congestion Window,而且BIC-TCP的提出者們看穿了事情的本質,其實這就是一個搜尋的過程,是以BIC這個算法主要用的是Binary Search——二分查找來幹這個事。 關于這個算法實作,你可以參看Linux源碼:

/net/ipv4/tcp_bic.c
TCP WestWood算法

    westwood采用和Reno相同的慢啟動算法、擁塞避免算法。westwood的主要改進方面:在發送端做帶寬估計,當探測到丢包時,根據帶寬值來設定擁塞視窗、慢啟動門檻值。 那麼,這個算法是怎麼測量帶寬的?每個RTT時間,會測量一次帶寬,測量帶寬的公式很簡單,就是這段RTT内成功被ack了多少位元組。因為,這個帶寬和用 RTT計算RTO一樣,也是需要從每個樣本來平滑到一個值的——也是用一個權重移平均的公式。 另外,我們知道,如果一個網絡的帶寬是每秒可以發送X個位元組,而RTT是一個資料發出去後确認需要的時候,是以,X * RTT應該是我們緩沖區大小。是以,在這個算法中,ssthresh的值就是est_BD * min-RTT(最小的RTT值),如果丢包是Duplicated ACKs引起的,那麼如果cwnd > ssthresh,則 cwin = ssthresh。如果是RTO引起的,cwnd = 1,進入慢啟動。   關于這個算法實作,你可以參看Linux源碼: 

/net/ipv4/tcp_westwood.c
其它

    更多的算法,你可以從Wikipedia的 

TCP Congestion Avoidance Algorithm

 詞條中找到相關的線索

後記

    好了,到這裡我想可以結束了,TCP發展到今天,裡面的東西可以寫上好幾本書。本文主要目的,還是把你帶入這些古典的基礎技術和知識中,希望本文能讓你了解TCP,更希望本文能讓你開始有學習這些基礎或底層知識的興趣和信心。

    當然,TCP東西太多了,不同的人可能有不同的了解,而且本文可能也會有一些荒謬之言甚至錯誤,還希望得到您的回報和批評。

謀膽并重

繼續閱讀