高併發架構的TCP知識介紹

大愚Talk發表於2019-05-07

做為一個有追求的程式設計師,不能只滿足增刪改查,我們要對系統全方面無死角掌控。掌握了這些基本的網路知識後,相信一方面日常排錯中會事半功倍,另一方面日常架構中不得不考慮的高併發問題,理解了這些底層協議也是會如虎添翼。

本文不會單純給大家講講TCP三次握手、四次揮手就完事了。如果只是哪樣的話,我直接貼幾個連線就完事了。我希望把實際工作中的很多點能夠串起來講給大家。當然為了文章完整,我依然會從 三次握手 起頭。

再說TCP狀態變更過程

不管是三次握手、還是四次揮手,他們都是完成了TCP不同狀態的切換。進而影響各種資料的傳輸情況。下面從三次握手開始分析。

本文圖片有部分來自網路,若有侵權,告知即焚

三次握手

來看看三次握手的圖,估計大家看這圖都快看吐了,不過為什麼每次面試、回憶的時候還是想不起呢?我再來抄抄這鍋剩飯吧!

tcp-1st

首先當服務端處於 listen 狀態的時候,我們就可以再客戶端發起監聽了,此時客戶端會處於 SYN_SENT 狀態。服務端收到這個訊息會返回一個 SYN 並且同時 ACK 客戶端的請求,之後服務端便處於 SYN_RCVD 狀態。這個時候客戶端收到了服務端的 SYN&ACK,就會傳送對服務端的 ACK,之後便處於 ESTABLISHED 狀態。服務端收到了對自己的 ACK 後也會處於 ESTABLISHED 狀態。

經常在面試中可能有人提問:為什麼握手要3次,不是2次或者4次呢?

首先說4次握手,其實為了保證可靠性,這個握手次數可以一直迴圈下去;但是這沒有一個終止就沒有意義了。所以3次,保證了各方訊息有來有回就足夠了。當然這裡可能有一種情況是,客戶端傳送的 ACK 在網路中被丟了。那怎麼辦?

  1. 其實大部分時候,我們連線建立完成就會立刻傳送資料,所以如果服務端沒有收到 ACK 沒關係,當收到資料就會認為連線已經建立;
  2. 如果連線建立後不立馬傳輸資料,那麼服務端認為連線沒有建立成功會週期性重發 SYN&ACK 直到客戶端確認成功。

再說為什麼2次握手不行呢?2次握手我們可以想象是沒有三次握手最後的 ACK, 在實際中確實會出現客戶端傳送 ACK 服務端沒有收到的情況(上面的情況一),那麼這是否說明兩次握手也是可行的呢? 看下情況二,2次握手當服務端傳送訊息後,就認為建立成功,而恰巧此時又沒有資料傳輸。這就會帶來一種資源浪費的情況。比如:客戶端可能由於延時傳送了多個連線情況,當服務端每收到一個請求回覆後就認為連線建立成功,但是這其中很多求情都是延時產生的重複連線,浪費了很多寶貴的資源。

因此綜上所述,從資源節省、效率3次握手都是最合適的。話又回來三次握手的真實意義其實就是協商傳輸資料用的:序列號與視窗大小

下面我們通過抓包再來看一下真實的情況是否如上所述。

20:33:26.583598 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [S], seq 621839080, win 65535, options [mss 1460,nop,wscale 6,nop,nop,TS val 1050275400 ecr 0,sackOK,eol], length 0
20:33:26.660754 IP 103.235.46.39.80 > 192.168.0.102.58165: Flags [S.], seq 1754967387, ack 621839081, win 8192, options [mss 1452,nop,wscale 5,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,sackOK,eol], length 0
20:33:26.660819 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [.], ack 1754967388, win 4096, length 0
複製程式碼

抓包: sudo tcpdump -n host www.baidu.com -S

  • S 表示 SYN
  • . 表示 ACK
  • P 表示 傳輸資料
  • F 表示 FIN

四次揮手

揮手,就是說資料傳完了,同志們再見!

tcp-3th

這裡有個問題需要注意下,其實客戶端、服務端都能夠主動發起關閉操作,誰呼叫 close() 就先傳送關閉的請求。當然一般的流程,發起建立連線的一方會主動發起關閉請求(http中)。

關於4次揮手的過程,我就不多解釋了,這裡有兩個重要的狀態我需要解釋下,這都是我親自經歷過的線上故障,close_waittime_wait

先給大家一個命令,統計tcp的各種狀態情況。下面表格內容就來自這個命令的統計。

netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'

Tcp狀態 連線數
CLOSE_WAIT 505
ESTABLISHED 808
TIME_WAIT 3481
SYN_SENT 1
SYN_RECV 1
LAST_ACK 2
FIN_WAIT2 2
FIN_WAIT1 1

大量的CLOSE_WAIT 這個在我之前的一篇文章 線上大量CLOSE_WAIT原因分析 已經有過介紹,它會導致大量的socket無法釋放。而每個socket都是一個檔案,是會佔用資源的。這個問題主要是程式碼問題。它出現在被動關閉的一方(習慣稱為server)。

大量的TIME_WAIT 這個問題在日常中經常看到,流量一高就出現大量的該情況。該狀態出現在主動發起關閉的一方。該狀態一般等待的時間設為 2MSL後自動關閉,MSL是Maximum Segment Lifetime,報文最大生存時間,如果報文超過這個時間,就會被丟棄。處於該狀態下的socket也是不能被回收使用的。線上我就遇到這種情況,每次大流量的時候,每臺機器處於該狀態的socket就多達10w+,遠遠比處於 Established 狀態的socket多的多,導致很多時候服務響應能力下降。這個一方面可以通過調整核心引數處理,另一方面避免使用太多的短連結,可以採用連線池來提升效能。另外在程式碼層面可能是由於某些地方沒有關閉連線導致的,也需要檢查業務程式碼。

上面兩個狀態一定要牢記發生在哪一方,這方便我們快速定位問題。

最後這裡還是放上揮手時的抓包資料:

20:33:26.750607 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [F.], seq 621839159, ack 1754967720, win 4096, length 0
20:33:26.827472 IP 103.235.46.39.80 > 192.168.0.102.58165: Flags [.], ack 621839160, win 776, length 0
20:33:26.827677 IP 103.235.46.39.80 > 192.168.0.102.58165: Flags [F.], seq 1754967720, ack 621839160, win 776, length 0
20:33:26.827729 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [.], ack 1754967721, win 4096, length 0
複製程式碼

不多不少,剛好4次。

TCP狀態變更

網路上有一張TCP狀態機的圖,我覺得太複雜了,用自己的方式搞個簡單點的容易理解的。我從兩個角度來說明狀態的變更。

  • 一個是客戶端
  • 一個是服務端

看下面兩張圖的時候,請一定結合上面三次握手、四次揮手的時序圖一起看,加深理解。

客戶端狀態變更

tcp-4th

通過這張圖,大家是否能夠清晰明瞭的知道 TCP 在客戶端上的變更情況了呢?

服務端狀態變更

tcp-5th

這一張圖描述了 TCP 狀態在服務端的變遷。

TCP的流量控制與擁塞控制

我們常說TCP是面向連線的,UDP是無連線的。那麼TCP這個面向連線主要解決的是什麼問題呢?

這裡繼續把三次握手的抓包資料貼出來分析下:

20:33:26.583598 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [S], seq 621839080, win 65535, options [mss 1460,nop,wscale 6,nop,nop,TS val 1050275400 ecr 0,sackOK,eol], length 0
20:33:26.660754 IP 103.235.46.39.80 > 192.168.0.102.58165: Flags [S.], seq 1754967387, ack 621839081, win 8192, options [mss 1452,nop,wscale 5,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,sackOK,eol], length 0
20:33:26.660819 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [.], ack 1754967388, win 4096, length 0
複製程式碼

上面我們說到 TCP 的三次握手最重要的就是協商傳輸資料用的序列號。那這個序列號究竟有些什麼用呢?這個序號能夠幫助後續兩端進行確認資料包是否收到,解決順序、丟包問題;另外我們還可以看到有一個 win 欄位,這是雙方交流的視窗大小,這在每次傳輸資料過程中也會攜帶。主要是告訴對方,我視窗是這麼大,別發多了或者別發太少。

總結下,TCP的幾個特點是:

  • 順序問題,依靠序號
  • 丟包問題,依靠序號
  • 流量控制,依靠滑動視窗
  • 擁塞控制,依靠擁塞視窗+滑動視窗
  • 連線維護,三次握手/四次揮手

順序與丟包問題

這個問題其實應該很好理解。由於資料在傳輸前我們已經有序號了,這裡注意一下這個序號是隨機的,重複的概率極地,避免了程式發生亂入的可能性。

由於我們每個資料包有序號,雖然傳送與到達可能不是順序的,但是TCP層收到資料後,可以根據序號進行重新排列;另外在這個排列過程中,發現有了1,2,3,5,6這幾個包,一檢查就知道4要麼延時未到達,要麼丟包了,等待重傳。

這裡需要重要說明的一點是。為了提升效率,TCP其實並不是收到一個包就發一個ack。那是如何ACK的呢?還是以上面為例,TCP收到了1,2,3,5,6這幾個包,它可能會傳送一個 ack ,seq=3 的確認包,這樣次一次確認了3個包。但是它不會傳送 5,6 的ack。因為4沒有收到啊!一旦4延時到達或者重發到達,就會傳送一個 ack, seq=6,又一次確認了3個包。

流量控制與擁塞控制

這兩個概念說實話,讓我理解了挺長時間,主要是對它們各自控制的內容以及相互之間是否有作用一直沒有鬧清楚。

先大概說下:

  • 流量控制:是根據接收方的視窗大小來感知我這次能夠傳多少資料給對方;———— 滑動視窗
  • 擁塞控制:而擁塞控制主要是避免網路擁塞,它考慮的問題更多。根據綜合因素來覺得發多少資料給對方;———— 滑動視窗&擁塞視窗

舉個例子說下,比如:A給B傳送資料,通過握手後,A知道B一次可以收1000的資料(B有這麼大的處理能力),那麼這個時候滑動視窗就可以設定成1000。那是不是最後真的可以一次發這麼多資料給B呢?還不是,這時候得問問擁塞視窗,老兄,現在網路情況怎麼樣?一次運1000的資料有壓力嗎?擁塞視窗一通計算說不行,現在是高峰期,最多隻能有600的貨上路。最終這次傳資料的時候就是 600 的標註。大家也可以關注抓包資料的 win 值,一直在動態調整。

當然另外一種情況是滑動視窗比擁塞視窗小,雖然運輸能力強,但是接收能力有限,這時候就要取滑動視窗的值來實際發生。所以它們二者之間是有關係的。

所以具體到每次能夠傳送多少資料,有這麼一個公式:

LastByteSend - LastByteAcked <= min{cwnd,rwnd}

  • LastByteSend 是最後一個傳送的位元組的序號
  • LastByteAcked 最後一個被確認的位元組的序號

這兩個相減得到的是本次能夠傳送的資料,這個資料一定小於或等於 cwnd 與 rwnd 中最小的一個值。相信大家能夠理清楚。

那麼這部分知識對於實際工作中有什麼作用呢?指導意義就是:如果你的業務很重要、很核心一定不要混布;二是如果你的服務忽快忽慢,而確信依賴服務沒有問題,檢查下機器對應的網路情況;三是視窗這個速度控制機制,在我們進行服務設計的時候,非常具有參考意義。是不是有點訊息佇列的感覺?(很多訊息佇列都是勻速的,我們是否可以加一個視窗的概念來進行優化呢?)

是什麼限制了你的連線

到了最關鍵的地方了,精華我都是留到最後講。下面放一張網上找的socket操作步驟圖,畫的太好了我就直接用了。

tcp-6th

我們假設我的服務端就是 Nginx ,我來嘗試解讀一下。當客戶端呼叫 connect() 時候就會發起三次握手,這次握手的時候有幾個元素唯一確定了這次通訊(或者說這個socket),[源IP:源Port, 目的IP:目的Port] ,當然這個socket還不是最終用來傳輸資料的socket,一旦握手完成後,服務端會在返回一個 socket 專門用來後續的資料傳輸。這裡暫且把第一個socket叫 監聽socket,第二個叫 傳輸socket 方便後文敘述。

為什麼要這麼設計呢?大家想一想,如果監聽的socket還要負責資料的收發,請問這個服務端的效率如何提升?什麼東西、誰都往這個socket裡邊丟,太複雜!

提高連線常用套路

到了這一步,我們現在先停下來算算自己的伺服器機器能夠有多少連線呢?這個極限又是如何一步步被突破呢?

先說 監聽socket ,伺服器的prot一般都是固定的,伺服器的ip當然也是固定的(單機)。那麼上面的結構 [源IP:源Port, 目的IP:目的Port] 其實只有客戶端的ip與埠可以發生變化。假設客戶端用的是IPv4,那麼理論連線數是:2^32(ip數) * 2^16(埠數) = 2^48。

看起來這個值蠻大的。但是真的能夠有這麼多連線嗎?不可能的,因為每一個socket都需要消耗記憶體;以及每一個程式的檔案描述符是有上限的。這些都限制了最終的連線數。

那麼如何進行調和呢?我知道的操作有:多程式、多執行緒、IO多路服用、協程等手段組合使用。

多程式

也就是監聽是一個程式,一旦accept後,對於 傳輸socket 我們就fork一個新的子程式來處理。但是這種方式太重,fork一個程式、銷燬一個程式都是特別費事的。單機對程式的建立上限也是有限制的。

多執行緒

執行緒比程式要輕量級的多,它會共享父程式的很多資源,比如:檔案描述符、程式空間,它就是多了一個引用。因此它的建立、銷燬更加容易。每一個 傳輸socket 在這裡就交給了執行緒來處理。

但是不管是多程式、還是多執行緒都存在一個問題,一個連線對應一個程式或者協程。這都很難逃脫 C10K 的問題。那麼該怎麼辦呢?

IO多路複用

IO多路複用是什麼意思呢?在上面單純的多程式、多執行緒模型中,一個程式或執行緒只能處理一個連線。用了IO多路複用後,我一個程式或執行緒就能處理多個連線。

我們都知道 Nginx 非常高效,它的結構是:master + worker,worker 會在 80、443埠上來監聽請求。它的worker一般設定為 cpu 的cores數,那麼這麼少的子程式是如何解決超多連線的呢?這裡其實每個worker就採用了 epoll 模型(當然IO多路複用還有個select,這裡就不說了)。

處於監聽狀態的worker,會把所有 監聽socket 加入到自己的epoll中。當這些socket都在epoll中時,如果某個socket有事件發生就會立即被回撥喚醒(這涉及epoll的紅黑樹,講不清楚不細說了)。這種模式,大大增加了每個程式可以管理的socket數量,上限直接可以上升到程式能夠操作的最大檔案描述符。

一般機器可以設定百萬級別檔案描述符,所以單機單程式就是百萬連線,epoll是解決C10K的利器,很多開源軟體用到了它。

這裡說下,並不是所有的worker都是同時處於監聽埠的狀態,這涉及到nginx驚群、搶自旋鎖的問題,不再本文範圍內不多說。

關於ulimit

在文章的最後,補充一些單機檔案描述符設定的問題。我們常說連線數受限於檔案描述符,這是為什麼?

因為在linux上一切皆檔案,故每一個socket都是被當作一個檔案看待,那麼每個檔案就會有一個檔案描述符。在linux中每一個程式中都有一個陣列儲存了該程式需要的所有檔案描述符。這個檔案描述符其實就是這個陣列的 key ,它的 value 是一個指標,指向的就是開啟的對應檔案。

關於檔案描述符有兩點注意:

  1. 它對應的其實是一個linux上的檔案
  2. 檔案描述符本身這個值在不同程式中是可以重複的

另外補充一點,單機設定的ulimit的上線受限與系統的兩個配置:

fs.nr_open,程式級別

fs.file-max,系統級別

fs.nr_open 總是應該小於等於 fs.file-max,這兩個值的設定也不是隨意可以操作,因為設定的越大,系統資源消耗越多,所以需要根據真實情況來進行設定。


至此,本篇長文就完結了。這跟上篇 高併發架構的CDN知識介紹 屬於一個系列,高併發架構需要理解的網路基礎知識。

後面還會寫一下 HTTP/HTTPS 的知識。然後關於高併發網路相關的東西就算完結。我會開啟下一個篇章。


如果你想對網路協議瞭解更多,推薦一個課程:

tcp-7th

相關文章