TCP協議中的核心知識點,SYN Flood?ISN?滑動窗口?數據重傳?拆包粘包?單tcp連接多請求?擁塞管理?(個人收藏學習筆記)

1.前言

上週和同事互水了一下 TCP協議中的三次握手和四次揮手,一個同同事互水的技術話題(經典面試題目),這周我們討論了一下 TCP協議中的核心知識點:

  • 滑動窗口
  • 可靠的數據重傳
  • 拆包粘包
  • 單tcp連接多請求
  • TCP重傳

好記性不如爛筆頭,筆者還是覺得查閱各種資料並且整理成筆記,方便以後查閱(其實也是爲了以後面試做筆記)。

2.TCP/IP四層結構

首先,無時無刻都需要有TCP/IP的四層概念。
在這裏插入圖片描述

  • 應用層(跟具體用戶應用相關,包括HTTP、MQTT等應用層協議)
  • 傳輸層(負責維持正常的數據通信過程,主要代表是TCP、UDP
  • 網絡層(負責邏輯地址以及路由分發,包括IP、ICMP、ARP、RARP等協議)
  • 網絡訪問層(相當於電腦設備的網卡以及驅動程序,管理物理地址(MAC))

分析網絡協議,絕對不要忽略某一層,每一層都非常重要,是一個整體鏈路。

在這裏插入圖片描述
就以我們最常用的PC機訪問網頁來說,會經歷:

  • HTTP(業務相關,比如我們需要訪問什麼網頁,需要網頁返回什麼內容)
  • TCP(負責把上層協議內容(HTTP協議內容)正確地發送到目標服務端口)
  • IP(負責路由分發,把上層協議內容(TCP協議內容)轉發到目標服務IP)
  • 數據鏈路層(負責把數據轉發到最終的網卡)

HTTP依賴TCP,TCP又依賴IP,IP最終會把數據扔給數據鏈路層,數據鏈路層在把數據轉成電信號發送到目標地址。

3. TCP

3.1 TCP 協議頭

在這裏插入圖片描述
關鍵點

  • TCP的包是沒有IP地址的,那是IP層上的事。但是有源端口目標端口
  • 一個TCP連接需要四個元組來表示是同一個連接(src_ip, src_port, dst_ip, dst_port)
  • Sequence Number是包的序號,用來解決網絡包亂序(reordering)問題,也就是數據最終會通過它來重組成正確的順序。
  • Acknowledgement Number就是ACK——用於確認收到,用來解決不丟包的問題。
  • Window又叫Advertised-Window,也就是著名的滑動窗口(Sliding Window),用於解決流控的。
  • TCP Flag ,也就是包的類型,主要是用於操控TCP的狀態機(在上篇內容中,博主介紹了TCP通信過程中的狀態轉換)的。

在這裏插入圖片描述

3.2 TCP通信過程

此部分內容從 TCP 的那些事兒 搬運過來,加上自己的一些理解。
在這裏插入圖片描述
整個TCP通信過程分爲了三個階段:

  • 建立連接的三次握手階段
  • 數據傳輸過程(涉及到我們本篇標題的知識點)
  • 斷開連接的四次揮手階段

3.2.1 建立連接的三次握手階段

具體內容參考:TCP協議中的三次握手和四次揮手,一個同同事互水的技術話題(經典面試題目)

三次握手的過程主要是爲了初始化 Sequence Number 的初始值。通信的雙方要互相通知對方自己的初始化的Sequence Number(縮寫爲ISN:Inital Sequence Number)——所以叫SYN,全稱Synchronize Sequence Numbers。也就上圖中的 x 和 y。這個號要作爲以後的數據通信的序號,以保證應用層接收到的數據不會因爲網絡上的傳輸的問題而亂序(TCP會用這個序號來拼接數據)。

個人感覺,其實三次握手就類似於對暗號的過程(交換彼此的初始化序列號,只有序列號對上了數據才進入後續傳輸環節),在電影《智取威虎山3D》中就有這麼一句經典對白:

A:天王蓋地虎
B:寶塔鎮河妖
A:暗號對上了,咱們喝酒去。

三次握手的過程也有不少需要知道的知識點。

  • 關於建連接時SYN超時
  • 關於SYN Flood攻擊
  • 關於ISN的初始化
3.2.1.1 關於建連接時SYN超時

試想一下,如果server端接到了client發的SYN後回了SYN-ACKclient掉線了,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纔會把斷開這個連接。

3.2.1.2 關於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 處理不過來乾脆就直接拒絕連接了。

3.2.1.3 關於ISN的初始化

一個TCP session 由 source IP + source Port + destination IP + destination Port 唯一決定,一般也稱爲 TCP socket,所以即使每個TCP session 的ISN(Initial Sequence Number )都是相同的也無妨,TCP可以將不同socket的數據提交給不同的應用進程,而不會造成混淆。

初始建立TCP連接的時候的系列號(ISN)是隨機選擇的,那麼這個系列號爲什麼不採用一個固定的值呢?主要有兩方面的原因:

  • Bogus TCP Reset
    大型防火牆的工作原理就是一旦發現有訪問blocked website,則需要重置這個連接,步驟如下:僞造一個TCP reset,包含IP字段、TCP字段的僞造,發送給客戶端,因爲所有字段都是可以接受的,所以客戶端接受這個reset消息並重置TCP連接。因爲流量途徑防火牆,所以包括IP Header 、TCP Header的信息都能得到,所以很容易僞造。
    對於不能接觸到流量的任何第三方能否也可以僞造一個TCP Reset呢?理論上也是可以的,對於靜態的字段如 source/destination IP/Port 這個比較容易僞造,最難的就是 TCP sequence number ,至少僞造的序列號位於對方的 slide window 窗口之內,而如果採用靜態ISN,則相對容易構造一個TCP Reset,然後將一個TCP session 重置了,這很顯然不利於安全。

  • Ambignity of TCP Port Reused
    由於允許一個剛釋放的TCP Port重用,如果已釋放的TCP session 與 新建立的TCP session 四原組完全一致,則存在老的session 的數據依然在路上,新的session 也在路上,這樣對方就會被弄迷糊,而無法判斷誰是真正的合法數據。如採用動態增長的ISN,則避免相鄰的兩個TCP session 的 sequence number 的重疊,不會造成誤會。

TCP初始化序列號不能設置爲一個固定值,因爲這樣容易被攻擊者猜出後續序列號,從而遭到攻擊。
RFC1948中提出了一個較好的初始化序列號ISN隨機生成算法(其實就是爲了不讓僞造攻擊者能預測到我們的序列號)。

ISN = M + F(localhost, localport, remotehost, remoteport).
M是一個計時器,這個計時器每隔4毫秒加1。
F是一個Hash算法,根據源IP、目的IP、源端口、目的端口生成一個隨機數值。要保證hash算法不能被外部輕易推算得出,用MD5算法是一個比較好的選擇。

3.2.2 斷開連接的四次揮手階段

具體內容參考:TCP協議中的三次握手和四次揮手,一個同同事互水的技術話題(經典面試題目)

在這裏插入圖片描述

四次揮手的過程也有不少需要知道的知識點。

  • 關於 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”)。
    Again,使用tcp_tw_reuse和tcp_tw_recycle來解決TIME_WAIT的問題是非常非常危險的,因爲這兩個參數違反了TCP協議(RFC 1122)
    其實,TIME_WAIT表示的是你主動斷連接,所以,這就是所謂的“不作死不會死”。試想,如果讓對端斷連接,那麼這個破問題就是對方的了,呵呵。另外,如果你的服務器是於HTTP服務器,那麼設置一個HTTP的KeepAlive有多重要(瀏覽器會重用一個TCP連接來處理多個HTTP請求),然後讓客戶端去斷鏈接(你要小心,瀏覽器可能會非常貪婪,他們不到萬不得已不會主動斷連接)。

3.2.3 數據傳輸

數據傳輸過程中,會涉及到幾個知識點;

  • Sequence NumberAcknowledgement Number
  • 拆包粘包
  • IP分片與重組
  • 滑動窗口 Window
  • TCP重傳
  • 擁塞窗口
3.2.3.1 數據正確傳輸的保證 —— Sequence Number、Acknowledgement Number

TCP是一種面向連接的、可靠的、基於字節流的傳輸層通信協議,它會保證數據不丟包不亂序

要想做到 數據不丟包(也就是需要知道哪些包有沒有丟、丟了重傳)、不亂序(也就是數據能重組到正確的順序給到上層),我們需要給數據進行編號。

編號的工作主要由TCP協議頭的兩個字段進行標識:

  • Sequence Number
    此序列號用來標識從TCP發送端向TCP接收端發送的數據字節流,它標識在這個報文段中的第一個數據字節。

假設,有一個400字節的數據段A,由於最大100字節的分段限制,我們就會分爲a1、a2、a3、a4:
a1:1-100 Seq = 1
a2:101-200 Seq = 101
a3:201-300 Seq = 201
a4:301-400 Seq = 301

  • Acknowledgement Number
    確認號,和序列號類似,不過它是用來確認已經收到的序號並下次想收到的序號
    需要注意的是,Acknowledgement Number 是以連續收到的數據最大序號(比如數據被依次拆分爲a1、a2、a3、a4,假設接收方收到了a1、a2、a4,沒有收到a3,那麼Ack = a2)。
    也就是告訴發送方我收到了哪些數據,這樣發送方就可以知道哪些數據需要重傳。
    這兩個序號保證了TCP傳輸過程中不亂序、不丟包的問題。

以上面的案例爲講解:
1、假設接收方收到了a1、a2,並且接收方告訴發送方 ack = 201,那麼發送方就知道了接收方收到了a1、a2,接下來繼續發送 a3、a4
2、假設a4先到達了,a3由於延遲還沒有到達,接收方還是隻會告訴發送方 ack = 201,不會說ack = 401.

知識點:

  • Acknowledgement of delay
    通常TCP在收到數據的時候不會立刻發送一個ACK確認,它會延遲發送,可以和對方需要的數據一起發送(數據捎帶ACK)或者是等待第二個數據來了直接回復第二個ACK,通常的實現採用的延遲是200ms(就是說它會等待200ms有沒有數據一起發送)
3.2.3.2 拆包粘包?—— 理解字節流協議

首先,TCP本來就是基於字節流而不是消息包的協議,它自己說的清清楚楚:我會把你的數據變成字節流發到對面去,而且保證順序不會亂,但是你要自己搞定字節流解析。

簡單概括:TCP只是一個傳輸層協議,不關心發送的具體內容,只是確保數據能正確到達接收方。也就是上層協議給我什麼數據我就發送什麼數據,不會對數據進行解析

重點內容說三遍:
TCP沒有包的概念
TCP沒有包的概念
TCP沒有包的概念

拆包粘包是TCP協議文檔中壓根沒有存在過的詞彙。而是國內開發人員在使用TCP中遇到,並吐槽成“拆包粘包”問題。其實就是“如何設計應用層協議的問題”。

先來說“拆包”:

以HTTP應用層爲例,如果HTTP報文數據大,會在TCP層進行分段Segment。
TCP 維持一個變量,它等於最大報文段長度 MSS(通常來說,爲了不讓IP繼續分片,可以設置爲路徑MTU,保證整個傳輸轉發過程中都不會再次拆分)。只要緩存中存放的數據達到 MSS 字節時,就組裝成一個 TCP 報文段發送出去。

比如HTTP報文H,傳到傳輸層時,在TCP分段爲H1、H2、H3…HN

那麼,先來第一個疑問點——接收方怎麼知道 H1、H2、H3…HN 都是屬於H這個包?

TCP 只負責同一個應用層數據的正確傳輸,而不會去區分數據段標識。也就是假設有兩個上層應用數據A和B,它們分別分段爲A1、A2…AN和B1、B2…BN,那麼如果確保A1不會混在B中呢?
當然,有人就說了,我可以先發完A得到響應之後再繼續發B,的確這是一個思路。不過我們這裏只是討論如何區分A和B。TCP區分不了,那麼我們就看看IP分片與重組。

再來說“粘包”(其實,根本不存在所謂粘包一說,不過國人爲了說明這一類問題,命名爲粘包):

  • TCP是基於數據流的協議,給我什麼數據我就發送什麼數據,不會對數據進行解析。
  • 所謂“粘包”問題在於拋開了應用層協議,直接往TCP發送數據,而數據在應用層是不具備任何含義的,就僅僅是字節流數據。

產生“粘包”的情況:

  • 當連續發送數據時,由於tcp協議的nagle算法,會將較小的內容拼接成大的內容,一次性發送到服務器端,因此造成粘包

在數據傳輸過程中,通常會遇到一些小分組的傳輸(比如 41 bit的數據分組,除去TCP首部和IP首部真正傳輸的數據只有1 bit),像這種小分組多的話,在網絡上傳輸就加大了造成網絡擁塞的可能。爲了提傳輸效率,所以提出了Nagle算法。
這個算法要求一個TCP連接最多隻能有一個未被確認的未完成的小分組,在該分組到達之前不能發送其他的小分組。然後,TCP會收集這些小分組,並在確認到來時以一個分組的方式發送出去,這樣就可以有效的減少了小分組。
在一些實時性要求比較高的場景下,採用了Nagle算法會讓用戶感覺到時延,所以我們可以選擇關閉Nagle算法,Socket API 可以用 TCP_NODELAY 選項來關閉,nginx上的 tcp_nodely也是採用的這個系統調用。

  • 當發送內容較大時,由於服務器端的recv(buffer_size)方法中的buffer_size較小,不能一次性完全接收全部內容,因此在下一次請求到達時,接收的內容依然是上一次沒有完全接收完的內容,因此造成粘包現象。

也就是說:接收方不知道該接收多大的數據纔算接收完畢,造成粘包

說白了就是解決粘包問題,就是如何定義好數據協議,嚴格區分前後兩個數據的分割線(其實,就是應用層協議的工作)。

TCP是流協議,根本不存在所謂粘包一說。
簡單地說,TCP保證發送方以什麼順序發字節流,接收方就一定能按這個順序接收到,或者因爲網絡超時返回錯誤。這個是操作系統保證的,應用程序根本不用管也控制不了。
發送方應該以什麼格式發送數據,接收方能正確解析出數據,這個叫應用層協議,你自己定,跟TCP完全無關。
如果是發文件,最簡單的你可以用http協議封裝,如果你發的http協議數據是100%正確的,無論哪個接收方(nginx/tomcat/iis)保證能一字節不差地收下,因爲http協議本身就帶header和body,header裏有Content-Length: 12345指定了body的大小,body纔是文件本身。
你不用http協議,直接發文件數據,那麼問題來了,接收方怎麼知道應該收多少字節後文件結束?

3.2.3.3 IP 分片與重組

在這裏插入圖片描述

關鍵點

  • 標識
    用來唯一地標識主機發送的每一份數據報。通過它來表示分段數據是否屬於同一個包。

  • 標誌位
    標誌一份數據報是否要求分段

  • 段偏移量
    如果一份數據報要求分段的話,此字段指明該段偏移距原始數據報開始的位置。

正常來說,如果傳輸到IP層的數據大於IP MTU,就會進行IP分片。但是如果我們在TCP上就針對MTU進行分段,是不是意味着IP就不需要分片了呢?然後利用好IP的標識來處理好分段的數據重組成一個完整的包。

得出結論:

  • IP負責整個分片的數據重組,IP區分了不同傳輸層協議之間的重組。
  • TCP負責上層數據的順序重排,TCP區分了不同應用層協議數據之間的重排。
3.2.3.4 TCP滑動窗口

首先,我們得看看滑動窗口到底是爲了解決什麼問題?
我們假設如果沒有滑動窗口會發生什麼問題。

問題一:如何保證次序?

提出問題:在我們滑動窗口協議之前,我們如何來保證發送方與接收方之間,每個包都能被收到。並且是按次序的呢?

在這裏插入圖片描述
發送方發送一個包1,這時候接收方確認包1。發送包2,確認包2。就這樣一直下去,知道把數據完全發送完畢,這樣就結束了。那麼就解決了丟包,出錯,亂序等一些情況!同時也存在一些問題。
問題:吞吐量非常非常低,在網絡慢的情況下會自閉的。
我們發完包1,一定要等確認包1.我們才能發送第二個包。

問題二:如何提高吞吐量?

提出問題:那麼我們就不能先連發幾個包等他一起確認嗎?這樣的話,我們的速度會不會更快,吞吐量更高些呢?

在這裏插入圖片描述
如圖,這個就是我們把兩個包一起發送,然後一起確認。可以看出我們改進的方案比之前的好很多,所花的時間只是一個來回的時間。從一定程度上改善了吞吐量的問題。但是還不是最優解。

問題三:如何實現最優解?

問題:我們每次需要發多少個包過去呢?發送多少包是最優解呢?

我們能不能把第一個和第二個包發過去後,收到第一個確認包就把第三個包發過去呢?而不是去等到第二個包的確認包纔去發第三個包。這樣就很自然的產生了我們"滑動窗口"的實現。

在這裏插入圖片描述
在圖中,我們可看出灰色1號2號3號包已經發送完畢,並且已經收到Ack。這些包就已經是過去式。4、5、6、7號包是黃色的,表示已經發送了。但是並沒有收到對方的Ack,所以也不知道接收方有沒有收到。8、9、10號包是綠色的。是我們還沒有發送的。這些綠色也就是我們接下來馬上要發送的包。 可以看出我們的窗口正好是11格。後面的11-16還沒有被讀進內存。要等4號-10號包有接下來的動作後,我們的包纔會繼續往下發送。

滑動窗口簡介

出自 TCP協議的滑動窗口具體是怎樣控制流量的?
在這裏插入圖片描述

  • TCP滑動窗口分爲接受窗口,發送窗口
    TCP會話的雙方都各自維護一個“發送窗口”和一個“接收窗口”。
    在這裏插入圖片描述

  • 滑動窗口協議是傳輸層進行流控的一種措施,接收方通過通告發送方自己的窗口大小,從而控制發送方的發送速度,從而達到防止發送方發送速度過快而導致自己被淹沒的目的。

對ACK的再認識,ack通常被理解爲收到數據後給出的一個確認ACK,ACK包含兩個非常重要的信息:

  • 一是期望接收到的下一字節的序號n,該n代表接收方已經接收到了前n-1字節數據,此時如果接收方收到第n+1字節數據而不是第n字節數據,接收方是不會發送序號爲n+2的ACK的。舉個例子,假如接收端收到1-1024字節,它會發送一個確認號爲1025的ACK,但是接下來收到的是2049-3072,它是不會發送確認號爲3072的ACK,而依舊發送1025的ACK。

  • 二是當前的窗口大小m,如此發送方在接收到ACK包含的這兩個數據後就可以計算出還可以發送多少字節的數據給對方,假定當前發送方已發送到第x字節,則可以發送的字節數就是y=m-(x-n).這就是滑動窗口控制流量的基本原理。

發送方根據收到ACK當中的期望收到的下一個字節的序號n以及窗口m,還有當前已經發送的字節序號x,算出還可以發送的字節數。所以需要區分收到ACK之後的滑動窗口的移動變化(原滑動窗口->現滑動窗口)

在這裏插入圖片描述

滑動窗口數據分類

發送窗口
在這裏插入圖片描述

  • Sent and Acknowledged(已發送,已收到ACK)—— 發送窗外 緩衝區外
  • Sent But Not Yet Acknowledgeed(已發送,未收到ACK)—— 發送窗內 緩衝區內
  • Not Sent,Recipient Ready To Receive(未發送,但準備發送)—— 發送窗內 緩衝區內
  • Not Sent,Recipient Not Ready To Receive(未發送,也不允許發送)—— 發送窗外 緩衝區內

其中類型 已發送,未收到ACK未發送,但準備發送 都屬於發送窗口

接收窗口
對於TCP的接收方,在某一時刻在它的接收緩存內存在3種。

  • “已接收”
  • “未接收準備接收”
  • “未接收並未準備接收”(由於ACK直接由TCP協議棧回覆,默認無應用延遲,不存在“已接收未回覆ACK”)。

其中“未接收準備接收”稱之爲接收窗口

滑動窗口原理

TCP並不是每一個報文段都會回覆ACK的,可能會對兩個報文段發送一個ACK,也可能會對多個報文段發送1個ACK【累計ACK】,比如說發送方有1/2/3 3個報文段,先發送了2,3 兩個報文段,但是接收方期望收到1報文段,這個時候2,3報文段就只能放在緩存中等待報文1的空洞被填上,如果報文1,一直不來,報文2/3也將被丟棄,如果報文1來了,那麼會發送一個ACK對這3個報文進行一次確認。

在這裏插入圖片描述
結合上圖舉一個例子來說明一下滑動窗口的原理:

  • 假設32~45 這些數據,是上層Application發送給TCP的,TCP將其分成四個Segment來發往internet
  • seg1 32~34 seg2 35~36 seg3 37~41 seg4 42~45 這四個片段,依次發送出去,此時假設接收端之接收到了seg1 seg2 seg4
  • 此時接收端的行爲是回覆一個ACK包說明已經接收到了32~36的數據,並將seg4進行緩存(保證順序,產生一個保存seg3 的hole)
  • 發送端收到ACK之後,就會將32~36的數據包從發送並沒有確認切到發送已經確認,提出窗口,這個時候窗口向右移動
  • 假設接收端通告的Window Size仍然不變,此時窗口右移,產生一些新的空位,這些是接收端允許發送的範疇
  • 對於丟失的seg3,如果超過一定時間,TCP就會重新傳送(重傳機制),重傳成功會seg3 seg4一塊被確認,不成功,seg4也將被丟棄

就是不斷重複着上述的過程,隨着窗口不斷滑動,將真個數據流發送到接收端,實際上接收端的Window Size通告也是會變化的,接收端根據這個值來確定何時及發送多少數據,從對數據流進行流控。原理圖如下圖所示:

在這裏插入圖片描述

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,於是攻擊者會併發大量的這樣的請求,把服務器端的資源耗盡。

什麼是 DDos攻擊?參考 這裏

可能我舉個例子會更加形象點。我開了一家有五十個座位的重慶火鍋店,由於用料上等,童叟無欺。平時門庭若市,生意特別紅火,而對面二狗家的火鍋店卻無人問津。二狗爲了對付我,想了一個辦法,叫了五十個人來我的火鍋店坐着卻不點菜,讓別的客人無法喫飯。
上面這個例子講的就是典型的 DDoS 攻擊,全稱是 Distributed Denial of Service,翻譯成中文就是分佈式拒絕服務。一般來說是指攻擊者利用“肉雞”對目標網站在較短的時間內發起大量請求,大規模消耗目標網站的主機資源,讓它無法正常服務。在線遊戲、互聯網金融等領域是 DDoS 攻擊的高發行業。

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。這個算法的思路也是延時處理,他有兩個主要的條件:
    1)要等到 Window Size>=MSS 或是 Data Size >=MSS,
    2)收到之前發送數據的ack回包,他纔會發數據,否則就是在攢數據。

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

另外,網上有些文章說TCP_CORK的socket option是也關閉Nagle算法,這不對。TCP_CORK其實是更新激進的Nagle算漢,完全禁止小包發送,而Nagle算法沒有禁止小包發送,只是禁止了大量的小包發送。最好不要兩個選項都設置

總結幾點

  • TCP的滑動窗口的可靠性也是建立在“確認重傳”基礎上的。
    發送窗口只有收到對端對於本段發送窗口內字節的ACK確認,纔會移動發送窗口的左邊界。
    接收窗口只有在前面所有的段都確認的情況下才會移動左邊界。
  • 滑動窗口大小會動態調整,主要是根據接收端的接收情況,動態去調整Window Size,然後來控制發送端的數據流量。
3.2.3.5 TCP重傳機制

TCP要保證所有的數據包都可以到達,所以,必需要有重傳機制。
那麼重傳機制怎麼實現呢?

普通思路

  • 每一次發送一個片段,就開啓一個重傳計時器。計時器有一個初始值並隨時間遞減。如果在片段接收到確認之前計時器超時,就重傳片段。
  • TCP使用了這一基本技術,但實現方式稍有不同。原因在於爲了提高效率需要一次處理多個未被確認的片段,以保證每一個在恰當的時間重傳。
  • 包含數據的片段一經發送,片段的一份複製就放在名爲重傳隊列的數據結構中,此時啓動重傳計時器。
  • 如果在計時器超時之前收到了確認信息,則該片段從重傳隊列中移除。如果在計時器超時之前沒有收到確認信息,則發生重傳超時,片段自動重傳。當然,相比於原片段,對於重傳片段並沒有更多的保障機制。因此,重傳之後該片段還是保留在重傳隊列裏。重傳計時器被重啓,重新開始倒計時。如果重傳之後沒有收到確認,則片段會再次重傳並重復這一過程。在某些情況下重傳也會失敗。我們不想要TCP永遠重傳下去,因此TCP只會重傳一定數量的次數,並判斷出現故障終止連接。

重傳機制又分爲:

  • 超時重傳機制
  • 快速重傳機制
  • SACK 方法
  • Duplicate SACK – 重複收到數據的問題

注意,接收端給發送端的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引入了一種叫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包。
在這裏插入圖片描述

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

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

在這裏插入圖片描述
可見,引入了D-SACK,有這麼幾個好處:

  • 可以讓發送方知道,是發出去的包丟了,還是回來的ACK包丟了。
  • 是不是自己的timeout太小了,導致重傳。
  • 網絡上出現了先發的包後到的情況(又稱reordering)
  • 網絡上是不是把我的數據包給複製了。

Linux下的tcp_dsack參數用於開啓這個功能(Linux 2.4後默認打開)

3.2.3.6 TCP TimeOut

從前面的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),很明顯,這種死規矩對於一個需要估計比較準確的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)。

3.2.3.7 TCP的擁塞處理 – Congestion Handling

上面我們知道了,TCP通過Sliding Window來做流控(Flow Control),但是TCP覺得這還不夠,因爲Sliding Window需要依賴於連接的發送端和接收端,其並不知道網絡中間發生了什麼。

TCP的設計者覺得,一個偉大而牛逼的協議僅僅做到流控並不夠,因爲流控只是網絡模型4層以上的事,TCP的還應該更聰明地知道整個網絡上的事。

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

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

爲了在發送端調節所要發送數據的量,定義了一個叫“擁塞窗口”的概念。於是在慢啓動的時候,將這個擁塞窗口的大小設置爲1個數據段發送數據,之後每收到一次確認(ACK)應答,擁塞窗口的值就加1。在發送數據包時,將擁塞窗口的大小與接收端主機通知的窗口大小做比較,然後按照它們當中較小的那個值,發送比其還要小的數據量。如果重發採用超時機制,那麼擁塞窗口的初始值可以設置爲1以後再進行慢啓動修正。不過隨着包的每次往返,擁塞窗口也會以1、2、4等指數函數的增長。

所以需要記住,擁塞窗口作用:

  • 防止發送方發的太快,使得網絡來不及處理,從而導致網絡擁塞

關於擁塞控制的論文請參看《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

這個算法定義在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算法了。

在這裏插入圖片描述

最後以一系列圖來說明:

  • 初始化階段
    在這裏插入圖片描述
  • 慢開始階段
    在這裏插入圖片描述
    在這裏插入圖片描述
    在這裏插入圖片描述
    在這裏插入圖片描述
  • 擁塞避免階段
    在這裏插入圖片描述
  • 擁塞調整階段
    在這裏插入圖片描述
    在這裏插入圖片描述
    在這裏插入圖片描述

4.總結

主要是作爲知識點的學習記錄,方便以後回來查閱。

參考資料

非常感激以下帖子,也請讀者去學習

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章