上次瞭解了 TCP 創建鏈接與斷開鏈接的過程,咱們發現,TCP 會經過各類「套路」來保證傳輸數據的安全。除此以外,咱們還大概瞭解了 TCP 包頭格式所對應解決的五個問題:順序問題、丟包問題、鏈接維護、流量控制、擁塞控制。今天,咱們就來看下 TCP 又是用怎樣的套路去解決這五個問題的。html
在解決問題以前,我們先來看看 TCP 是怎麼成爲一個「靠譜」的協議的。算法
TCP 爲了保證順序性,每一個包都有一個 ID。這創建鏈接的時候,會商定起始 ID 的值,而後按照 ID一個個發送。緩存
爲了保證不丟包,對於發送的包都要進行應答。可是這個應答不是一個一個來的,而是會應答某個以前的 ID,表示都收到了,這種模式稱爲累計確認和累計應答。安全
爲了記錄全部發送的包和接收的包,TCP 也須要發送端和接收端分別用緩存來保存這些記錄。發送端的緩存裏是按照包的 ID 一個個排列,根據處理的狀況分紅四個部分:網絡
因而,發送端須要保持這樣的數據結構:數據結構
對於接收端來說,它緩存記錄的內容要簡單一些,分爲如下三個部分:ssh
對應的數據結構就像這樣:ide
第二部分的窗口有多大呢?優化
NextByteExpected 和 LastByteRead 的差起始是還沒被應用層讀取的部分佔用掉的 MaxRcvBuffer 的量,咱們定義爲 A,即:A = NextByteExpected - LastByteRead - 1。ui
那麼,窗口大小,AdvertisedWindow = MaxRcvBuffer - A。
也就是:AdvertisedWindow = MaxRcvBuffer - (NextByteExpected - LastByteRead - 1)
而第二部分和三部分的分界線 = NextByteExpected + AdvertisedWindow - 1 = MaxRcvBuffer + LastByteRead。
接下來,咱們結合上述圖例,用一個例子來看下 TCP 如何處理順序與丟包問題的。
仍是剛纔的圖,在發送端看來:
而在接收端看來:
發送端和接收端當前的狀態以下:
根據這個例子,咱們能夠知道,順序問題和丟包問題都有了能發送,因此咱們先來看確認與重發的機制。
假設 4 的確認到了,不幸的是,5 的 ACK 丟了,而且 六、7 的數據包也丟了,這時候會怎麼處理呢?
一種方法是超時重試,也就是對每個發送了,可是沒有 ACK 的包,都有設一個定時器,一旦超過了必定的時間,就從新嘗試。這個超時時間不宜太短,時間必須大於往返時間 RTT,不然就會引發沒必要要的重傳也不宜過長,這樣超時時間變長,訪問就變慢了。
估計往返時間,須要 TCP 經過採樣 RTT 的時間,而後進行加權平均,算出一個值,並且這個值仍是要不斷變化的,由於網絡情況不斷的變化。
除了採樣 RTT,還要採樣 RTT 的波動範圍,計算出一個估計的超時時間。因爲重傳時間是不斷變化的,咱們稱爲自適應重傳算法(Adaptive Retransmission Algorithm)。
若是過一段時間,五、六、7 都超時了,就會從新發送。接收方發現 5 原來接收過,因而就丟棄5。收到了6,發送 ACK,要求下一個是 7,7 不幸又丟了。
當 7 再次超時的時候,若是有須要重傳,TCP 的策略就是超時間隔加倍。每當遇到一次超時重傳的實時,都會將下一次超時時間間隔設置爲先前值的兩倍。兩次超時,就說明網絡環境差,不宜頻繁發送。
能夠看出,超時重發存在的問題是,超時週期可能較長。那是否是能夠有更快的方式呢?
有一個能夠快速重傳的機制。當接收方收到一個序號大於下一個所指望的報文段時,就檢測到了數據流中的一個間格,因而發送三個冗餘的ACK,客戶端收到後,就在定時器過時以前,重傳丟失的報文段。
例如,接收方發現 六、八、9 都已經接收了,可是 7 沒來。因而發送三個 6 的 ACK,要求下一個是 7。客戶端收到三個,就會發現 7 的確丟了,不等超時,就立刻重發。
除此以外,還有一種方式稱爲 Selective Acknowledgment(SACK)。這種方式須要在 TCP 頭裏加一個 SACK 的東西,能夠將緩存的地圖發格發送方。例如發送 ACK六、SACK八、SACK9,有了地圖,發送方一會兒就能看出來是 7 丟了,而後快速重發。
接下來,咱們再來看看流量控制機制。在對於包的確認中,會同時攜帶一個窗口大小的字段。
咱們先假設窗口不變的狀況,發送端窗口始終爲 9。4 的確認來的時候,LastByteAcked 會右移一個,這個時候,第 13 個包就能夠發送了。
這個時候,假設發送端發送過猛,將第三部分中的 十、十一、十二、13 所有發送,以後就中止發送,則此時未發送可發送部分爲 0。
當對於包 5 的確認到達的時候,在客戶端至關於窗口再滑動了一格,這個時候,才能夠有更多的包能夠發送了,例如第 14 個包才能夠發送。
若是接收方處理的太慢,致使緩存中沒有空間了,能夠經過確認信息修改窗口的大小,甚至能夠設置爲 0,則發送方將暫時中止發送。
咱們能夠假設一個極端狀況,接收端的應用一直不讀取緩存中的數據,當數據包 6 確認後,窗口大小就不會再是 9,而是減小一個變爲了 8。
爲何會變爲 8?你看,下圖中,當 6 的確認消息到達發送端的時候,左邊的 LastByteAcked 右移一位,而右邊的未發送可發送區域由於已經變爲 0,所以左邊的 LastByteSend 沒有移動,所以,窗口大小就從 9 變成了 8。
而若是接收端一直不處理數據,則隨着確認的包愈來愈多,窗口愈來愈小,直到爲 0。
當這個窗口大小經過包 14 的確認到達發送端的時候,發送端的窗口也調整爲 0,因而,發送端中止發送。
當發生這樣的狀況時,發送方會定時發送窗口探測數據包,看是否有機會調整窗口的大小。對於接收方來講,當接收比較慢的時候,要防止低能窗口綜合徵,別空出一個字節就趕忙告訴發送方,結果又被填滿了。能夠在窗口過小的時候,不更新窗口大小,直到達到必定大小,或者緩衝區一半爲空,才更新窗口大小。
這就是咱們常說的流量控制。
最後,咱們來看一下擁塞控制的問題。
這個問題,也是靠窗口來解決的。前面的滑動窗口 rwnd 是怕發送方把接收方緩存塞滿,而擁塞窗口 cwnd,是怕把網絡塞滿。
這裏有一個公式:
LastByteSent - LastByteAcked <= min{cwnd, rwnd}
能夠看出,是擁塞窗口和滑動窗口共同控制發送的速度。
那發送方怎麼判斷網絡是否是滿呢?這實際上是個挺難的事情。由於對於 TCP 協議來說,它壓根不知道整個網絡路徑都會經歷什麼。TCP 發送包常被比喻爲往一個水管裏灌水,而 TCP 的擁塞控制就是在不堵塞、不丟包的狀況下,儘可能發揮帶寬。
水管有粗細,網絡有帶寬,也就是每秒鐘可以發送多少數據;
水管有長度,端到端有時延。在理想狀況下:
水管裏的水量 = 水管粗細 x 水管長度
而對於網絡來說:
通道的容量 = 帶寬 x 往返延遲
若是咱們設置發送窗口,使得發送但未確認的包的數量爲通道的容量,就可以撐滿整個管道。
如上圖所示:
假設往返時間爲 8s,去 4s,回 4s,每秒發送一個包,每一個包 1024 byte。
那麼在 8s 後,就發出去了 8 個包。其中前 4 個包已經到達接收端,可是 ACK 尚未返回,不能算髮送成功。而 5-8 後四個包還在路上,沒被接收。
這個時候,整個管道正好撐滿。在發送端,已發送未確認的爲 8 個包,也就是:
帶寬 = 1024byte/s x 8s(來回時間)
若是咱們在這個基礎上再調大窗口,使得單位時間內更多的包能夠發送,會出現什麼現象呢?
原來發送一個包,從一端到另外一端,假設一共通過四個設備,每一個設備處理一個包耗時 1s,因此到達另外一端須要耗費 4s。若是發送的更加快速,則單位時間內,會有更多的包到達這些中間設備,這些設備仍是隻能每秒處理一個包的話,多出來的包就會被丟棄,這不是咱們但願看到的。
這個時候,咱們能夠想其餘的辦法。例如,這四個設備原本每秒處理一個包,可是咱們在這些設備上加緩存,處理不過來的就在隊列裏面排着,這樣包就不會丟失,可是缺點也是顯而易見的,增長了時延。這個緩存的包,4s 確定到達不了接收端,若是時延達到必定程度,就會超時,這也不是咱們但願看到的。
針對上述兩種現象:包丟失和超時重傳。一旦出現了這些現象就說明,發送速度太快了,要慢一點。可是一開始,發送端怎麼知道速度多快呢?怎麼知道把窗口調整到合適大小呢?
若是咱們經過漏斗往瓶子裏灌水,咱們就知道,不能一桶水一會兒全倒進去,確定會溢出來。一開始要慢慢的倒,而後發現都可以倒進去,就加快速度。這叫作慢啓動。
一個 TCP 鏈接開始
從上面這個過程能夠看出,這是指數性的增加。
可是漲到何時是個頭呢?一個值 ssthresh 爲 65535 個字節,當超過這個值的時候,就會將將增加速度降下來。
此時,每收到一個確認後,cwnd 增長 1/cwnd。一次發送 8 個,當 8 個確認到來的時候,每一個確認增長 1/8,8個確認一共增長 1,因而一次就可以發送 9 個,變成了線性增加。
即便增加變成了線性增加,仍是會出現「溢出」的狀況,出現擁塞。這時候通常就會直接下降倒水的速度,等待溢出的水慢慢滲透下去。
擁塞的一種變現形式是丟包,須要超時重傳。這個時候,將 ssthresh 設爲 cwnd/2,將 cwnd 設爲 1,從新開始慢啓動。也就是,一旦超時重傳,立刻「從零開始」。
很明顯,這種方式太激進了,將一個高速的傳輸速度一會兒停了下來,會形成網絡卡頓。
前面有提過快速重傳算法。當接收端發現丟了一箇中間包的時候,發送三次前一個包的 ACK,告訴發送端要趕忙給我發下一個包,別等超時再重傳。TCP 認爲這種狀況不嚴重,由於大部分沒丟,只丟了一小部分,cwnd 變爲 cwnd/2,而後 sshthresh = cwnd。當三個包返回的時候,cwnd = sshthresh + 3。
能夠看出這種狀況降低速沒有那麼激進,cwnd 仍是在一個比較高的值,呈線性增加。下圖是二者的對比。
就像前面說的同樣,正是這種知進退,使得時延在很重要的狀況下,反而下降了速度。可是,咱們仔細想想,TCP 的擁塞控制主要用來避免的兩個現象都是有問題的。
第一個問題是丟包。丟包並不必定表示通道滿了,也多是管子原本就」漏水」。就像公網上帶寬不滿也會丟包,這個時候就認爲擁塞,而下降發送速度實際上是不對的。
第二個問題是 TCP 的擁塞控制要等到將中間設備都填滿了,才發送丟包,從而下降速度。但其實,這時候下降速度已經晚了,在將管道填滿後,不該該接着填,直到發生丟包才降速。
爲了優化這兩個問題,後來就有了 TCP BBR 擁塞算法。它企圖找到一個平衡點,就是經過不斷的加快發送速度,將管道填滿,可是不要填滿中間設備的緩存,由於這樣時延會增長,在這個平衡點能夠很好的達到高帶寬和低時延的平衡。
下圖是 BBR 算法與普通 TCP 的對比:
參考: