【分享】TCP 的那些事兒

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

之因此想寫這篇文章,目的有三個,linux

  • 一個是想鍛鍊一下本身是否能夠用簡單的篇幅把這麼複雜的TCP協議描清楚的能力。
  • 另外一個是以爲如今的好多程序員基本上不會認認真真地讀本書,喜歡快餐文化,因此,但願這篇快餐文章可讓你對TCP這個古典技術有所瞭解,並能體會到軟件設計中的種種難處。而且你能夠從中有一些軟件設計上的收穫。
  • 最重要的但願這些基礎知識可讓你搞清不少之前一些似是而非的東西,而且你能意識到基礎的重要。

因此,本文不會面面俱到,只是對TCP協議、算法和原理的科普。程序員

我原本只想寫一個篇幅的文章的,可是TCP真TMD的複雜,比C++複雜多了,這30多年來,各類優化變種爭論和修改。因此,寫着寫着就發現只有砍成兩篇。算法

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

廢話少說,首先,咱們須要知道TCP在網絡OSI的七層模型中的第四層——Transport層,IP在第三層——Network層,ARP在第二層——Data Link層,在第二層上的數據,咱們叫Frame,在第三層上的數據叫Packet,第四層的數據叫Segment。shell

首先,咱們須要知道,咱們程序的數據首先會打到TCP的Segment中,而後TCP的Segment會打到IP的Packet中,而後再打到以太網Ethernet的Frame中,傳到對端後,各個層解析本身的協議,而後把數據交給更高層的協議處理。c#

TCP頭格式windows

接下來,咱們來看一下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-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包。

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包丟了,而是由於網絡延時了。

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的那些事(下)

 

 

 

 

 

 

 

這篇文章是下篇,因此若是你對TCP不熟悉的話,還請你先看看上篇《TCP的那些事兒(上)》 上篇中,咱們介紹了TCP的協議頭、狀態機、數據重傳中的東西。可是TCP要解決一個很大的事,那就是要在一個網絡根據不一樣的狀況來動態調整本身的發包的速度,小則讓本身的鏈接更穩定,大則讓整個網絡更穩定。在你閱讀下篇以前,你須要作好準備,本篇文章有好些算法和策略,可能會引起你的各類思考,讓你的大腦分配不少內存和計算資源,因此,不適合在廁所中閱讀。

TCP的RTT算法

從前面的TCP的重傳機制咱們知道Timeout的設置對於重傳很是重要,

  • 設長了,重發就慢,沒有效率,性能差;
  • 設短了,重發的就快,會增長網絡擁塞,致使更多的超時,更多的超時致使更多的重發。

並且,這個超時時間在不一樣的網絡的狀況下,有不一樣的時間,根本沒有辦法設置一個死的。只能動態地設置。 爲了動態地設置,TCP引入了RTT——Round Trip Time,也就是一個數據包從發出去到回來的時間。這樣發送端就大約知道須要多少的時間,從而能夠方便地設置Timeout——RTO(Retransmission TimeOut),以讓咱們的重傳機制更高效。 聽起來彷佛很簡單,好像就是在發送端發包時記下t0,而後接收端再把這個ack回來時再記一個t1,因而RTT = t1 – t0。沒那麼簡單,這只是一個採樣,不能表明廣泛狀況。

經典算法

RFC793中定義的經典算法是這樣的: 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回來的時間,就會短了。

因此1987年的時候,搞了一個叫Karn / Partridge Algorithm,這個算法的最大特色是——忽略重傳,不把重傳的RTT作採樣(你看,你不須要去解決不存在的問題)。可是,這樣一來,又會引起一個大BUG——若是在某一時間,網絡閃動,忽然變慢了,產生了比較大的延時,這個延時致使要重轉全部的包(由於以前的RTO很小),因而,由於重轉的不算,因此,RTO就不會被更新,這是一個災難。 因而Karn算法用了一個取巧的方式——只要一發生重傳,就對現有的RTO值翻倍(這就是所謂的Exponential backoff)

Jacobson / Karels 算法

前面兩種算法用的都是「加權移動平均」,這種方法最大的毛病就是若是RTT有一個大的波動的話,很難被發現,由於被平滑掉了。因此,1988年,又有人推出來了一個新的算法,這個算法叫Jacobson / Karels Algorithm(參看RFC6289)。這個算法引入了最新的RTT的採樣和平滑過的SRTT的差距作因子來計算。 公式以下:(其中的DevRTT是Deviation RTT的意思)SRTT= SRTT+ α(RTT– SRTT)DevRTT= (1-β)*DevRTT+ β*(|RTT-SRTT|)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必須要解決的可靠傳輸以及包亂續的問題,因此,TCP必須要知道網絡實際的數據處理帶寬或是數據處理速度,這樣纔不會引發網絡擁塞,致使丟包。因此,TCP引入了一些技術和設計來作網絡流控,Sliding Window是其中一個技術。 前面咱們說過,TCP頭裏有一個字段叫Window,又叫Advertised-Window,這個字段是接收端告訴發送端本身還有多少緩衝區能夠接收數據。因而發送端就能夠根據這個接收端的處理能力來發送數據,而不會致使接收端處理不過來。 爲了說明滑動窗口,咱們須要先看一下TCP緩衝區的一些數據結構:

上圖中,咱們能夠看到:

  • 接收端LastByteRead指向了TCP緩衝區中讀到的位置,NextByteExpected指向的地方是收到的連續包的最後一個位置,LastByteRcved指向的是收到的包的最後一個位置,咱們能夠看到中間有些數據尚未到達,因此有數據空白區。
  • 發送端的LastByteAcked指向了被接收端Ack過的位置(表示成功發送確認),LastByteSent表示發出去了,但尚未收到成功確認的Ack,LastByteWritten指向的是上層應用正在寫的地方。

因而:

  • 接收端在給發送端回ACK中會彙報本身的AdvertisedWindow = MaxRcvBuffer – LastByteRcvd – 1;
  • 而發送方會根據這個窗口來控制發送數據的大小,以保證接收方能夠處理。

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

圖片來源

上圖中分紅了四個部分,分別是:(其中那個黑模型就是滑動窗口)

  • #1已收到ack確認的數據。
  • #2發還沒收到ack的。
  • #3在窗口中尚未發出的(接收方還有空間)。
  • #4窗口之外的數據(接收方沒空間)

下面是個滑動後的示意圖(收到36的ack,併發出了46-51的字節):

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

圖片來源

Zero Window

上圖,咱們能夠看到一個處理緩慢的Server是怎麼把TCP Sliding Window給降成0的。此時,你必定會問,若是Window變成0了,TCP會怎麼樣?是否是發送端就不發數據了?是的,發送端就不發數據了,你能夠想像成「Window Closed」,那你必定還會問,若是發送端不發數據了,接收方一下子Window size 可用了,怎麼通知發送端呢?

解決這個問題,TCP使用了Zero Window Probe技術,縮寫爲ZWP,也就是說,發送端會發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)。若是你的網絡包能夠塞滿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選項來關閉這個算法

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

另外,網上有些文章說TCP_CORK的socket option是也關閉Nagle算法,這個還不夠準確。TCP_CORK是禁止小包發送,而沒有禁止小包發送,只是禁止了大量的小包發送。最好不要兩個選項都設置。老實說,我以爲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也會短,那麼,這個慢啓動就一點也不慢。下圖說明了這個過程。

這裏,我須要提一下的是一篇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已被更新:

  • cwnd = cwnd /2
  • sshthresh = cwnd

而後,真正的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的過程。

算法示意圖

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

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的對比:

關於這個算法實現,你能夠參看Linux源碼:/net/ipv4/tcp_vegas.h/net/ipv4/tcp_vegas.c

HSTCP(High Speed TCP) 算法

這個算法來自RFC 3649Wikipedia詞條)。其對最基礎的算法進行了更改,他使得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東西太多了,不一樣的人可能有不一樣的理解,並且本文可能也會有一些荒謬之言甚至錯誤,還但願獲得您的反饋和批評。

相關文章
相關標籤/搜索