TCP 基本概念#
TCP 頭報文格式#
- 序列號:在建立連接時由計算機生成的隨機數作為其初始值,通過 SYN 包傳給接收端主機,每發送一次數據,就「累加」一次該「數據字節數」的大小。用來解決網絡包亂序問題。
- 確認應答號:指下一次「期望」收到的數據的序列號,發送端收到這個確認應答以後可以認為在這個序號以前的數據都已經被正常接收。用來解決丟包的問題。
- 控制位:
ACK:該位為 1 時,「確認應答」的字段變為有效,TCP 規定除了最初建立連接時的 SYN 包之外該位必須設置為 1 。
RST:該位為 1 時,表示 TCP 連接中出現異常必須強制斷開連接。即調用 Socket.close () 函數,不需要四次揮手。
SYN:該位為 1 時,表示希望建立連接,並在其「序列號」的字段進行序列號初始值的設定。
FIN:該位為 1 時,表示今後不會再有數據發送,希望斷開連接。當通信結束希望斷開連接時,通信雙方的主機之間就可以相互交換 FIN 位為 1 的 TCP 段。
如何唯一確定一個 TCP 連接呢?#
TCP 四元組可以唯一的確定一個連接,四元組包括如下:
源地址,源端口,目的地址,目的端口
- 源地址和目的地址的字段(32 位)是在 IP 頭部中,作用是通過 IP 協議發送報文給對方主機。
- 源端口和目的端口的字段(16 位)是在 TCP 頭部中,作用是告訴 TCP 協議應該把報文發給哪個進程。
有一個 IP 的服務端監聽了一個端口,它的 TCP 的最大連接數是多少?
服務端通常固定在某個本地端口上監聽,等待客戶端的連接請求。
因此,客戶端 IP 和端口是可變的,其理論值計算公式如下:
TCP 的最大連接數 = 客戶端 IP 數量 * 客戶端端口數量
對 IPv4,客戶端的 IP 數最多為 2 的 32 次方,客戶端的端口數最多為 2 的 16 次方,也就是服務端單機最大 TCP 連接數,約為 2 的 48 次方。
當然,服務端最大並發 TCP 連接數遠不能達到理論上限,會受以下因素影響:
-
文件描述符限制,每個 TCP 連接都是一個文件,如果文件描述符被佔滿了,會發生 Too many open files。Linux 對可打開的文件描述符的數量分別作了三個方面的限制:
- 系統級:當前系統可打開的最大數量,通過 cat /proc/sys/fs/file-max 查看;
- 用戶級:指定用戶可打開的最大數量,通過 cat /etc/security/limits.conf 查看;
- 進程級:單個進程可打開的最大數量,通過 cat /proc/sys/fs/nr_open 查看;
-
內存限制,每個 TCP 連接都要佔用一定內存,操作系統的內存是有限的,如果內存資源被佔滿後,會發生 OOM。
既然 IP 層會分片,為什麼 TCP 層還需要 MSS 呢?#
- MTU:一個網絡包的最大長度,以太網中一般為 1500 字節;
- MSS:除去 IP 和 TCP 頭部之後,一個網絡包所能容納的 TCP 數據的最大長度;
當 IP 層有一個超過 MTU 大小的數據(TCP 頭部 + TCP 數據)要發送,那麼 IP 層就要進行分片,把數據分片成若干片,保證每一個分片都小於 MTU。把一份 IP 數據報進行分片以後,由目標主機的 IP 層來進行重新組裝後,再交給上一層 TCP 傳輸層。
這看起來井然有序,但這存在隱患的,那麼當如果一個 IP 分片丟失,整個 IP 報文的所有分片都得重傳。
因為 IP 層本身沒有超時重傳機制,它由傳輸層的 TCP 來負責超時和重傳。
當某一個 IP 分片丟失後,接收方的 IP 層就無法組裝成一個完整的 TCP 報文(頭部 + 數據),也就無法將數據報文送到 TCP 層,所以接收方不會響應 ACK 給發送方,因為發送方遲遲收不到 ACK 確認報文,所以會觸發超時重傳,就會重發「整個 TCP 報文(頭部 + 數據)」。
所以,為了達到最佳的傳輸效能 TCP 協議在建立連接的時候通常要協商雙方的 MSS 值,當 TCP 層發現數據超過 MSS 時,則就先會進行分片,當然由它形成的 IP 包的長度也就不會大於 MTU,自然也就不用 IP 分片了。
經過 TCP 層分片後,如果一個 TCP 分片丟失後,進行重發時也是以 MSS 為單位,而不用重傳所有的分片,大大增加了重傳的效率。
UDP 和 TCP 有什麼區別呢?分別的應用場景是?#
TCP 和 UDP 區別:#
- 連接
TCP 是面向連接的傳輸層協議,傳輸數據前先要建立連接。
UDP 是不需要連接,即刻傳輸數據。 - 服務對象
TCP 是一對一的兩點服務,即一條連接只有兩個端點。
UDP 支持一對一、一對多、多對多的交互通信。 - 可靠性
TCP 是可靠交付數據的,數據可以無差錯、不丟失、不重複、按序到達。
UDP 是盡最大努力交付,不保證可靠交付數據。但是我們可以基於 UDP 傳輸協議實現一個可靠的傳輸協議,比如 QUIC 協議,具體可以參見這篇文章:如何基於 UDP 協議實現可靠傳輸?(opens new window) - 擁塞控制、流量控制
TCP 有擁塞控制和流量控制機制,保證數據傳輸的安全性。
UDP 則沒有,即使網絡非常擁堵了,也不會影響 UDP 的發送速率。 - 首部開銷
TCP 首部長度較長,會有一定的開銷,首部在沒有使用「選項」字段時是 20 個字節,如果使用了「選項」字段則會變長的。
UDP 首部只有 8 個字節,並且是固定不變的,開銷較小。 - 傳輸方式
TCP 是流式傳輸,沒有邊界,但保證順序和可靠。
UDP 是一個包一個包的發送,是有邊界的,但可能會丟包和亂序。 - 分片不同
TCP 的數據大小如果大於 MSS 大小,則會在傳輸層進行分片,目標主機收到後,也同樣在傳輸層組裝 TCP 數據包,如果中途丟失了一個分片,只需要傳輸丟失的這個分片。
UDP 的數據大小如果大於 MTU 大小,則會在 IP 層進行分片,目標主機收到後,在 IP 層組裝完數據,接著再傳給傳輸層。
TCP 和 UDP 應用場景:#
由於 TCP 是面向連接,能保證數據的可靠性交付,因此經常用於:
- FTP 文件傳輸;
- HTTP / HTTPS;
由於 UDP 面向無連接,它可以隨時發送數據,再加上 UDP 本身的處理既簡單又高效,因此經常用於:
- 包總量較少的通信,如 DNS 、SNMP 等;
- 視頻、音頻等多媒體通信;
- 廣播通信;
TCP 連接建立#
TCP 三次握手過程是怎樣的?#
- 一開始,客戶端和服務端都處於 CLOSE 狀態。先是服務端主動監聽某個端口,處於 LISTEN 狀態。
- 客戶端會隨機初始化序號(client_isn),將此序號置於 TCP 首部的「序號」字段中,同時把 SYN 標誌位置為 1,表示 SYN 報文。接著把第一個 SYN 報文發送給服務端,表示向服務端發起連接,該報文不包含應用層數據,之後客戶端處於 SYN-SENT 狀態。
- 服務端收到客戶端的 SYN 報文後,首先服務端也隨機初始化自己的序號(server_isn),將此序號填入 TCP 首部的「序號」字段中,其次把 TCP 首部的「確認應答號」字段填入 client_isn + 1, 接著把 SYN 和 ACK 標誌位置為 1。最後把該報文發給客戶端,該報文也不包含應用層數據,之後服務端處於 SYN-RCVD 狀態。
- 客戶端收到服務端報文後,還要向服務端回應最後一個應答報文,首先該應答報文 TCP 首部 ACK 標誌位置為 1 ,其次「確認應答號」字段填入 server_isn + 1 ,最後把報文發送給服務端,這次報文可以攜帶客戶到服務端的數據,之後客戶端處於 ESTABLISHED 狀態。
- 服務端收到客戶端的應答報文後,也進入 ESTABLISHED 狀態。
從上面的過程可以發現第三次握手是可以攜帶數據的,前兩次握手是不可以攜帶數據的,這也是面試常問的題。
一旦完成三次握手,雙方都處於 ESTABLISHED 狀態,此時連接就已建立完成,客戶端和服務端就可以相互發送數據了。
如何在 Linux 系統中查看 TCP 狀態?
在 Linux 可以通過 netstat -napt 命令查看。
第一次握手丟失了,會發生什麼?
如果客戶端遲遲收不到服務端的 SYN-ACK 報文(第二次握手),就會觸發「超時重傳」機制,重傳 SYN 報文,而且重傳的 SYN 報文的序列號都是一樣的。
在 Linux 裡,客戶端的 SYN 報文最大重傳次數由 tcp_syn_retries
內核參數控制,這個參數是可以自定義的,默認值一般是 5。每次超時的時間是上一次的 2 倍。
第二次握手丟失了,會發生什麼?
- 客戶端會重傳 SYN 報文,也就是第一次握手,最大重傳次數由 tcp_syn_retries 內核參數決定;
- 服務端會重傳 SYN-ACK 報文,也就是第二次握手,最大重傳次數由 tcp_synack_retries 內核參數決定。
在 Linux 下,SYN-ACK 報文的最大重傳次數由tcp_synack_retries
內核參數決定,默認值是 5。
第三次握手丟失了,會發生什麼?
當第三次握手丟失了,如果服務端那一方遲遲收不到這個確認報文,就會觸發超時重傳機制,重傳 SYN-ACK 報文,直到收到第三次握手,或者達到最大重傳次數。
注意,ACK 報文是無法重傳的,當 ACK 丟失了,就由對方重傳對應的報文。
為什麼需要三次握手?#
-
避免歷史連接
三次握手的首要原因是為了防止舊的重複連接初始化造成混亂。
我們考慮一個場景,客戶端先發送了 SYN(seq = 90)報文,然後客戶端宕機了,而且這個 SYN 報文還被網絡阻塞了,服務端並沒有收到,接著客戶端重啟後,又重新向服務端建立連接,發送了 SYN(seq = 100)報文(注意!不是重傳 SYN,重傳的 SYN 的序列號是一樣的)。
看看三次握手是如何阻止歷史連接的:
客戶端連續發送多次 SYN(都是同一個四元組)建立連接的報文,在網絡擁堵情況下:- 一個「舊 SYN 報文」比「最新的 SYN」報文早到達了服務端,那麼此時服務端就會回一個 SYN + ACK 報文給客戶端,此報文中的確認號是 91(90+1)。
- 客戶端收到後,發現自己期望收到的確認號應該是 100 + 1,而不是 90 + 1,於是就會回 RST 報文。
- 服務端收到 RST 報文後,就會釋放連接。
- 後續最新的 SYN 抵達了服務端後,客戶端與服務端就可以正常的完成三次握手了。
上述中的「舊 SYN 報文」稱為歷史連接,TCP 使用三次握手建立連接的最主要原因就是防止「歷史連接」初始化了連接。
在兩次握手的情況下,服務端沒有中間狀態給客戶端來阻止歷史連接,導致服務端可能建立一個歷史連接,造成資源浪費。 -
同步雙方初始序列號
序列號是可靠傳輸的一個關鍵因素,它的作用:- 接收方可以去除重複的數據;
- 接收方可以根據數據包的序列號按序接收;
- 可以標識發送出去的數據包中, 哪些是已經被對方收到的(通過 ACK 報文中的序列號知道);
所以當客戶端發送攜帶「初始序列號」的 SYN 報文的時候,需要服務端回一個 ACK 應答報文,表示客戶端的 SYN 報文已被服務端成功接收,那當服務端發送「初始序列號」給客戶端的時候,依然也要得到客戶端的應答回應,這樣一來一回,才能確保雙方的初始序列號能被可靠的同步。
-
避免資源浪費
如果只有「兩次握手」,當客戶端發生的 SYN 報文在網絡中阻塞,客戶端沒有接收到 ACK 報文,就會重新發送 SYN ,由於沒有第三次握手,服務端不清楚客戶端是否收到了自己回覆的 ACK 報文,所以服務端每收到一個 SYN 就只能先主動建立一個連接。
如果客戶端發送的 SYN 報文在網絡中阻塞了,重複發送多次 SYN 報文,那麼服務端在收到請求後就會建立多個冗餘的無效鏈接,造成不必要的資源浪費。
總結:不使用「兩次握手」和「四次握手」的原因:
「兩次握手」:無法防止歷史連接的建立,會造成雙方資源的浪費,也無法可靠的同步雙方序列號;
「四次握手」:三次握手就已經理論上最少可靠連接建立,所以不需要使用更多的通信次數。
為什麼每次建立 TCP 連接時,初始化的序列號都要求不一樣呢?#
為了防止歷史報文被下一個相同四元組的連接接收(主要方面);
為了安全性,防止黑客偽造的相同序列號的 TCP 報文被對方接收;
過程如下:
- 客戶端和服務端建立一個 TCP 連接,在客戶端發送數據包被網絡阻塞了,然後超時重傳了這個數據包,而此時服務端設備斷電重啟了,之前與客戶端建立的連接就消失了,於是在收到客戶端的數據包的時候就會發送 RST 報文。
- 緊接著,客戶端又與服務端建立了與上個連接相同四元組的連接;
- 在新連接建立完成後,上個連接中被網絡阻塞的數據包正好抵達了服務端,剛好該數據包的序列號正好是在服務端的接收窗口內,所以該數據包會被服務端正常接收,就會造成數據錯亂。
可以看到,如果每次建立連接,客戶端和服務端的初始化序列號都是一樣的話,很容易出現歷史報文被下一個相同四元組的連接接收的問題。
初始序列號 ISN 是如何隨機產生的?
起始 ISN 是基於時鐘的,每 4 微秒 + 1,轉一圈要 4.55 個小時。
RFC793 提到初始化序列號 ISN 隨機生成算法:ISN = M + F (localhost, localport, remotehost, remoteport)。
- M 是一個計時器,這個計時器每隔 4 微秒加 1。
- F 是一個 Hash 算法,根據源 IP、目的 IP、源端口、目的端口生成一個隨機數值。要保證 Hash 算法不能被外部輕易推算得出,用 MD5 算法是一個比較好的選擇。
可以看到,隨機數是會基於時鐘計時器遞增的,基本不可能會隨機成一樣的初始化序列號。
什麼是 SYN 攻擊?如何避免 SYN 攻擊?#
我們都知道 TCP 連接建立是需要三次握手,假設攻擊者短時間偽造不同 IP 地址的 SYN 報文,服務端每接收到一個 SYN 報文,就進入 SYN_RCVD 狀態,但服務端發送出去的 ACK + SYN 報文,無法得到未知 IP 主機的 ACK 應答,久而久之就會佔滿服務端的半連接隊列,使得服務端不能為正常用戶服務。
半連接隊列和全連接隊列?
也稱 SYN 隊列和 accept 隊列;
正常流程:
- 當服務端接收到客戶端的 SYN 報文時,會創建一個半連接的對象,然後將其加入到內核的「 SYN 隊列」;
- 接著發送 SYN + ACK 給客戶端,等待客戶端回應 ACK 報文;
服務端接收到 ACK 報文後,從「 SYN 隊列」取出一個半連接對象,然後創建一個新的連接對象放入到「 Accept 隊列」;- 應用通過調用 accpet () socket 接口,從「 Accept 隊列」取出連接對象。
不管是半連接隊列還是全連接隊列,都有最大長度限制,超過限制時,默認情況下都會丟棄報文。
SYN 攻擊方式最直接的表現就會把 TCP 半連接隊列打滿,這樣當 TCP 半連接隊列滿了,後續再在收到 SYN 報文就會丟棄,導致客戶端無法和服務端建立連接。
避免 SYN 攻擊方式,可以有以下四種方法:
1. 調大 netdev_max_backlog
當網卡接收數據包的速度大於內核處理的速度時,會有一個隊列保存這些數據包。控制該隊列的最大值如下參數,默認值是 1000,我們要適當調大該參數的值,比如設置為 10000:
2. 增大 TCP 半連接隊列
增大 TCP 半連接隊列,要同時增大下面這三個參數:
- 增大 net.ipv4.tcp_max_syn_backlog
- 增大 listen () 函數中的 backlog
- 增大 net.core.somaxconn
3. 開啟 net.ipv4.tcp_syncookies‘
開啟 syncookies 功能就可以在不使用 SYN 半連接隊列的情況下成功建立連接,相當於繞過了 SYN 半連接來建立連接。
可以看到,當開啟了 tcp_syncookies 了,即使受到 SYN 攻擊而導致 SYN 隊列滿時,也能保證正常的連接成功建立。
net.ipv4.tcp_syncookies 參數主要有以下三個值:
0 值,表示關閉該功能;
1 值,表示僅當 SYN 半連接隊列放不下時,再啟用它;
2 值,表示無條件開啟功能;
那麼在應對 SYN 攻擊時,只需要設置為 1 即可。
4. 減少 SYN+ACK 重傳次數
當服務端受到 SYN 攻擊時,就會有大量處於 SYN_REVC 狀態的 TCP 連接,處於這個狀態的 TCP 會重傳 SYN+ACK ,當重傳超過次數達到上限後,就會斷開連接。
那麼針對 SYN 攻擊的場景,我們可以減少 SYN-ACK 的重傳次數,以加快處於 SYN_REVC 狀態的 TCP 連接斷開。
SYN-ACK 報文的最大重傳次數由 tcp_synack_retries 內核參數決定(默認值是 5 次),比如將 tcp_synack_retries 減少到 2 次:
TCP 連接斷開#
TCP 四次揮手過程#
- 客戶端打算關閉連接,此時會發送一個 TCP 首部 FIN 標誌位被置為 1 的報文,也即 FIN 報文,之後客戶端進入 FIN_WAIT_1 狀態。
- 服務端收到該報文後,就向客戶端發送 ACK 應答報文,接著服務端進入 CLOSE_WAIT 狀態。
- 客戶端收到服務端的 ACK 應答報文後,之後進入 FIN_WAIT_2 狀態。
- 等待服務端處理完數據後,也向客戶端發送 FIN 報文,之後服務端進入 LAST_ACK 狀態。
- 客戶端收到服務端的 FIN 報文後,回一個 ACK 應答報文,之後進入 TIME_WAIT 狀態。
- 服務端收到了 ACK 應答報文後,就進入了 CLOSE 狀態,至此服務端已經完成連接的關閉。
- 客戶端在經過 2MSL 一段時間後,自動進入 CLOSE 狀態,至此客戶端也完成連接的關閉。
你可以看到,每個方向都需要一個 FIN 和一個 ACK,因此通常被稱為四次揮手。
這裡一點需要注意是:主動關閉連接的,才有 TIME_WAIT 狀態。
為什麼揮手需要四次?
服務端通常需要等待完成數據的發送和處理,所以服務端的 ACK 和 FIN 一般都會分開發送,因此是需要四次揮手。
第一次揮手丟失了,會發生什麼?
如果第一次揮手丟失了,那麼客戶端遲遲收不到被動方的 ACK 的話,也就會觸發超時重傳機制,重傳 FIN 報文,重發次數由 tcp_orphan_retries 參數控制。
當客戶端重傳 FIN 報文的次數超過 tcp_orphan_retries 後,就不再發送 FIN 報文,則會在等待一段時間(時間為上一次超时时間的 2 倍),如果還是沒能收到第二次揮手,那麼直接進入到 close 狀態。
第二次揮手丟失了,會發生什麼?
ACK 報文是無法重傳的,所以如果服務端的第二次揮手丟失了,客戶端就會觸發超時重傳機制,重傳 FIN 報文,直到收到服務端的第二次揮手,或者達到最大的重傳次數。
第三次揮手丟失了,會發生什麼?
當客戶端收到第二次揮手,也就是收到服務端發送的 ACK 報文後,客戶端就會處於 FIN_WAIT2 狀態,在這個狀態需要等服務端發送第三次揮手,也就是服務端的 FIN 報文。
對於 close 函數關閉的連接,由於無法再發送和接收數據,所以 FIN_WAIT2 狀態不可以持續太久,而 tcp_fin_timeout 控制了這個狀態下連接的持續時長,默認值是 60 秒。
這意味著對於調用 close 關閉的連接,如果在 60 秒後還沒有收到 FIN 報文,客戶端(主動關閉方)的連接就會直接關閉。
但是注意,如果主動關閉方使用 shutdown 函數關閉連接,指定了只關閉發送方向,而接收方向並沒有關閉,那麼意味著主動關閉方還是可以接收數據的。
此時,如果主動關閉方一直沒收到第三次揮手,那麼主動關閉方的連接將會一直處於 FIN_WAIT2 狀態。
當服務端(被動關閉方)收到客戶端(主動關閉方)的 FIN 報文後,內核會自動回覆 ACK,同時連接處於 CLOSE_WAIT 狀態。
服務端處於 CLOSE_WAIT 狀態時,調用了 close 函數,內核就會發出 FIN 報文,同時連接進入 LAST_ACK 狀態,等待客戶端返回 ACK 來確認連接關閉。
如果遲遲收不到這個 ACK,服務端就會重發 FIN 報文,重發次數仍然由 tcp_orphan_retries 參數控制,這與客戶端重發 FIN 報文的重傳次數控制方式是一樣的。
第四次揮手丟失了,會發生什麼?
當客戶端收到服務端的第三次揮手的 FIN 報文後,就會回 ACK 報文,也就是第四次揮手,此時客戶端連接進入 TIME_WAIT 狀態。
在 Linux 系統,TIME_WAIT 狀態會持續 2MSL 後才會進入關閉狀態。
然後,服務端(被動關閉方)沒有收到 ACK 報文前,還是處於 LAST_ACK 狀態。
如果第四次揮手的 ACK 報文沒有到達服務端,服務端就會重發 FIN 報文,重發次數仍然由前面介紹過的 tcp_orphan_retries 參數控制。
TIME_WAIT 狀態相關#
為什麼 TIME_WAIT 等待的時間是 2MSL?#
MSL 是 Maximum Segment Lifetime
,報文最大生存時間,它是任何報文在網絡上存在的最長時間,超過這個時間報文將被丟棄。因為 TCP 報文基於 IP 協議的,而 IP 頭中有一個 TTL 字段,是 IP 數據報可以經過的最大路由數,每經過一個處理他的路由器此值就減 1,當此值為 0 則數據報將被丟棄,同時發送 ICMP 報文通知源主機。
MSL 與 TTL 的區別: MSL 的單位是時間,而 TTL 是經過路由跳數。所以 MSL 應該要大於等於 TTL 消耗為 0 的時間,以確保報文已被自然消亡。
TTL 的值一般是 64,Linux 將 MSL 設置為 30 秒,意味著 Linux 認為數據報文經過 64 個路由器的時間不會超過 30 秒,如果超過了,就認為報文已經消失在網絡中了。
TIME_WAIT 等待 2 倍的 MSL,比較合理的解釋是: 網絡中可能存在來自發送方的數據包,當這些發送方的數據包被接收方處理後又會向對方發送響應,所以一來一回需要等待 2 倍的時間。
為什麼需要 TIME_WAIT 狀態?#
主要是兩個原因:
- 防止歷史連接中的數據,被後面相同四元組的連接錯誤的接收;
- 保證「被動關閉連接」的一方,能被正確的關閉;
TIME_WAIT 過多有什麼危害?#
服務端是佔用系統資源,比如文件描述符、內存資源、CPU 資源、線程資源等;
客戶端是佔用端口資源,端口資源也是有限的,一般可以開啟的端口為 32768~61000,也可以通過 net.ipv4.ip_local_port_range 參數指定範圍。
如何優化 TIME_WAIT?#
- 打開 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 選項;
可以復用處於 TIME_WAIT 的 socket 為新的連接所用,有一點需要注意的是,tcp_tw_reuse 功能只能用客戶端(連接發起方),因為開啟了該功能,在調用 connect () 函數時,內核會隨機找一個 time_wait 狀態超過 1 秒的連接給新的連接復用。 - net.ipv4.tcp_max_tw_buckets
這個值默認為 18000,當系統中處於 TIME_WAIT 的連接一旦超過這個值時,系統就會將後面的 TIME_WAIT 連接狀態重置,這個方法比較暴力。 - 程序中使用 SO_LINGER ,應用強制使用 RST 關閉。
如果 l_onoff 為非 0, 且 l_linger 值為 0,那麼調用 close 後,會立刻發送一個 RST 標誌給對端,該 TCP 連接將跳過四次揮手,也就跳過了 TIME_WAIT 狀態,直接關閉。
服務器出現大量 TIME_WAIT 狀態的原因有哪些?#
TIME_WAIT 狀態是主動關閉連接方才會出現的狀態,所以如果服務器出現大量的 TIME_WAIT 狀態的 TCP 連接,就是說明服務器主動斷開了很多 TCP 連接。
什麼場景下服務端會主動斷開連接呢?
- 第一个场景:HTTP 沒有使用長連接
只要任意一方的 HTTP header 中有 Connection 信息,就無法使用 HTTP 長連接機制,這樣在完成一次 HTTP 請求 / 處理後,就會關閉連接。
根據大多數 Web 服務的實現,不管哪一方禁用了 HTTP Keep-Alive,都是由服務端主動關閉連接。 - 第二個場景:HTTP 長連接超時
假設設置了 HTTP 長連接的超時時間是 60 秒,nginx 就會啟動一個「定時器」,如果客戶端在完後一個 HTTP 請求後,在 60 秒內都沒有再發起新的請求,定時器的時間一到,nginx 就會觸發回調函數來關閉該連接,那麼此時服務端上就會出現 TIME_WAIT 狀態的連接。 - 第三個場景:HTTP 長連接的請求數量達到上限
nginx 的 keepalive_requests 這個參數,這個參數是指一個 HTTP 長連接建立之後,nginx 就會為這個連接設置一個計數器,記錄這個 HTTP 長連接上已經接收並處理的客戶端請求的數量。如果達到這個參數設置的最大值時,則 nginx 會主動關閉這個長連接,那麼此時服務端上就會出現 TIME_WAIT 狀態的連接。
服務器出現大量 CLOSE_WAIT 狀態的原因有哪些?
當服務端出現大量 CLOSE_WAIT 狀態的連接的時候,說明服務端的程序沒有調用 close 函數關閉連接,通常都是代碼的問題。
Socket 編程#
針對 TCP 應該如何 Socket 編程?#
- 服務端和客戶端初始化 socket,得到文件描述符;
- 服務端調用 bind,將 socket 綁定在指定的 IP 地址和端口;
- 服務端調用 listen,進行監聽;
- 服務端調用 accept,等待客戶端連接;
- 客戶端調用 connect,向服務端的地址和端口發起連接請求;
- 服務端 accept 返回用於傳輸的 socket 的文件描述符;
- 客戶端調用 write 寫入數據;服務端調用 read 讀取數據;
- 客戶端斷開連接時,會調用 close,那麼服務端 read 讀取數據的時候,就會讀取到了 EOF,待處理完數據後,服務端調用 close,表示連接關閉。
這裡需要注意的是,服務端調用 accept 時,連接成功了會返回一個已完成連接的 socket,後續用來傳輸數據。
所以,監聽的 socket 和真正用來傳送數據的 socket,是「兩個」 socket,一個叫作監聽 socket,一個叫作已完成連接 socket。
成功連接建立之後,雙方開始通過 read 和 write 函數來讀寫數據,就像往一個文件流裡面寫東西一樣。
listen 时候参数 backlog 的意义?#
Linux 內核中會維護兩個隊列:
- 半連接隊列(SYN 隊列):接收到一個 SYN 建立連接請求,處於 SYN_RCVD 狀態;
- 全連接隊列(Accpet 隊列):已完成 TCP 三次握手過程,處於 ESTABLISHED 狀態;
在早期 Linux 內核 backlog 是 SYN 隊列大小,也就是未完成的隊列大小。
在 Linux 內核 2.2 之後,backlog 變成 accept 隊列,也就是已完成連接建立的隊列長度,所以現在通常認為 backlog 是 accept 隊列。
但是上限值是內核參數 somaxconn 的大小,也就是說 accpet 隊列長度 = min (backlog, somaxconn)。
TCP 半連接隊列和全連接隊列滿了會發生什麼?
TCP 全連接隊列溢出
當超過了 TCP 最大全連接隊列,服務端則會丟掉後續進來的 TCP 連接。
TCP 半連接隊列溢出
如果半連接隊列滿了,並且沒有開啟 tcp_syncookies,則會丟棄;
若全連接隊列滿了,且沒有重傳 SYN+ACK 包的連接請求多於 1 個,則會丟棄;
如果沒有開啟 tcp_syncookies,並且 max_syn_backlog 減去 當前半連接隊列長度小於 (max_syn_backlog>> 2),則會丟棄;
accept 發生在三次握手的哪一步?#
客戶端 connect 成功返回是在第二次握手,服務端 accept 成功返回是在三次握手成功之後。
客戶端調用 close 了,連接斷開的流程是什麼?#
- 客戶端調用 close,表明客戶端沒有數據需要發送了,則此時會向服務端發送 FIN 報文,進入 FIN_WAIT_1 狀態;
- 服務端接收到了 FIN 報文,TCP 協議棧會為 FIN 包插入一個文件結束符 EOF 到接收緩衝區中,應用程序可以通過 read 調用來感知這個 FIN 包。這個 EOF 會被放在已排隊等候的其他已接收的數據之後,這就意味著服務端需要處理這種異常情況,因為 EOF 表示在該連接上再無額外數據到達。此時,服務端進入 CLOSE_WAIT 狀態;
- 接著,當處理完數據後,自然就會讀到 EOF,於是也調用 close 關閉它的套接字,這會使得服務端發出一個 FIN 包,之後處於 LAST_ACK 狀態;
- 客戶端接收到服務端的 FIN 包,並發送 ACK 確認包給服務端,此時客戶端將進入 TIME_WAIT 狀態;
- 服務端收到 ACK 確認包後,就進入了最後的 CLOSE 狀態;
- 客戶端經過 2MSL 時間之後,也進入 CLOSE 狀態;
沒有 accept,能建立 TCP 連接嗎?#
可以的。
accpet 系統調用並不參與 TCP 三次握手過程,它只是負責從 TCP 全連接隊列取出一個已經建立連接的 socket,用戶層通過 accpet 系統調用拿到了已經建立連接的 socket,就可以對該 socket 進行讀寫操作了。
沒有 listen,能建立 TCP 連接嗎?#
可以的。
客戶端是可以自己連自己的形成連接(TCP 自連接),也可以兩個客戶端同時向對方發出請求建立連接(TCP 同時打開),這兩個情況都有個共同點,就是沒有服務端參與,也就是沒有 listen,就能 TCP 建立連接。
如果有服務端參與,服務端沒有調用 listen 函數,找不到監聽該端口的 socket,發送 RST 中止這個連接。
TCP 的可靠性機制#
重傳機制#
超時重傳#
重傳機制的其中一個方式,就是在發送數據時,設定一個定時器,當超過指定的時間後,沒有收到對方的 ACK 確認應答報文,就會重發該數據,也就是我們常說的超時重傳。
TCP 會在以下兩種情況發生超時重傳:
- 數據包丟失
- 確認應答丟失
如果超時重發的數據,再次超時的時候,又需要重傳的時候,TCP 的策略是超時間隔加倍。
也就是每當遇到一次超時重傳的時候,會將下一次超時的時間間隔設為先前值的兩倍。兩次超時,就說明網絡環境差,不宜頻繁反復發送。
超時觸發重傳存在的問題是,超時周期可能相對較長。
快速重傳#
TCP 還有另外一種快速重傳(Fast Retransmit)機制,它不以時間為驅動,而是以數據驅動重傳。
在上圖,發送方發出了 1,2,3,4,5 份數據:
- 第一份 Seq1 先送到了,於是就 Ack 回 2;
- 結果 Seq2 因為某些原因沒收到,Seq3 到達了,於是還是 Ack 回 2;
- 後面的 Seq4 和 Seq5 都到了,但還是 Ack 回 2,因為 Seq2 還是沒有收到;
- 發送端收到了三個 Ack = 2 的確認,知道了 Seq2 還沒有收到,就會在定時器過期之前,重傳丟失的 Seq2。
- 最後,收到了 Seq2,此時因為 Seq3,Seq4,Seq5 都收到了,於是 Ack 回 6 。
所以,快速重傳的工作方式是當收到三個相同的 ACK 報文時,會在定時器過期之前,重傳丟失的報文段。
快速重傳機制只解決了一個問題,就是超時的時間問題,但是它依然面臨著另外一個問題。就是重傳的時候,是重傳一個,還是重傳所有的問題。
SACK 方法#
SACK( Selective Acknowledgment), 选择性确认。這種方式需要在 TCP 頭部「選項」字段裡加一個 SACK 的東西,它可以將已收到的數據的信息發送給「發送方」,這樣發送方就可以知道哪些數據收到了,哪些數據沒收到,知道了這些信息,就可以只重傳丟失的數據。
發送方收到了三次同樣的 ACK 確認報文,於是就會觸發快速重發機制,通過 SACK 信息發現只有 200~299 這段數據丟失,則重發時,就只選擇了這個 TCP 段進行重複。
Duplicate SACK#
Duplicate SACK 又稱 D-SACK,其主要使用了 SACK 來告訴「發送方」有哪些數據被重複接收了。
ACK 丟包:
- 「接收方」發給「發送方」的兩個 ACK 確認應答都丟失了,所以發送方超時後,重傳第一個數據包(3000 ~ 3499)
- 於是「接收方」發現數據是重複收到的,於是回了一个 SACK = 3000~3500,告訴「發送方」 3000~3500 的數據早已被接收了,因為 ACK 都到了 4000 了,已經意味著 4000 之前的所有數據都已收到,所以這個 SACK 就代表著 D-SACK。
- 這樣「發送方」就知道了,數據沒有丟,是「接收方」的 ACK 確認報文丟了。
網絡延遲:
- 數據包(1000~1499) 被網絡延遲了,導致「發送方」沒有收到 Ack 1500 的確認報文。
- 而後面報文到達的三個相同的 ACK 確認報文,就觸發了快速重傳機制,但是在重傳後,被延遲的數據包(1000~1499)又到了「接收方」;
- 所以「接收方」回了一个 SACK=1000~1500,因為 ACK 已經到了 3000,所以這個 SACK 是 D-SACK,表示收到了重複的包。
- 這樣發送方就知道快速重傳觸發的原因不是發出去的包丟了,也不是因為回應的 ACK 包丟了,而是因為網絡延遲了。
可見,D-SACK 有這麼幾個好處:
- 可以讓「發送方」知道,是發出去的包丟了,還是接收方回應的 ACK 包丟了;
- 可以知道是不是「發送方」的數據包被網絡延遲了;
- 可以知道網絡中是不是把「發送方」的數據包給複製了;
滑動窗口#
TCP 引入了窗口這個概念,解決數據包的往返時間越長,通信的效率就越低的問題。 窗口大小就是指無需等待確認應答,而可以繼續發送數據的最大值。
窗口的實現實際上是操作系統開辟的一個緩存空間,發送方主機在等到確認應答返回之前,必須在緩衝區中保留已發送的數據。如果按期收到確認應答,此時數據就可以從緩存區清除。
圖中的 ACK 600 確認應答報文丟失,也沒關係,因為可以通過下一個確認應答進行確認,只要發送方收到了 ACK 700 確認應答,就意味著 700 之前的所有數據「接收方」都收到了。這個模式就叫累計確認或者累計應答。
窗口大小由哪一方決定?
TCP 頭裡有一個字段叫 Window,也就是窗口大小。
這個字段是接收端告訴發送端自己還有多少緩衝區可以接收數據。於是發送端就可以根據這個接收端的處理能力來發送數據,而不會導致接收端處理不過來。
所以,通常窗口的大小是由接收方的窗口大小來決定的。
發送方發送的數據大小不能超過接收方的窗口大小,否則接收方就無法正常接收到數據。
發送窗
發送出去的數據流可以被分為以下四部分:已發送且被確認部分 | 已發送未被確認部分 | 未發送但可發送部分 | 不可發送部分,其中發送窗 = 已發送未確認部分 + 未發但可發送部分。
接收窗
接收到的數據流可分為:已接收 | 未接收但準備接收 | 未接收不準備接收。接收窗 = 未接收但準備接收部分。
接收窗口和發送窗口的大小是相等的吗?
並不是完全相等,接收窗口的大小是約等於發送窗口的大小的。
因為滑動窗口並不是一成不變的。比如,當接收方的應用進程讀取數據的速度非常快的話,這樣的話接收窗口可以很快的就空缺出來。那么新的接收窗口大小,是通過 TCP 報文中的 Windows 字段來告訴發送方。那么這個傳輸過程是存在時延的,所以接收窗口和發送窗口是約等於的關係。
流量控制#
發送方不能無腦的發數據給接收方,要考慮接收方處理能力。
如果一直無腦的發數據給對方,但對方處理不過來,那麼就會導致觸發重發機制,從而導致網絡流量的無端的浪費。
為了解決這種現象發生,TCP 提供一種機制可以讓「發送方」根據「接收方」的實際接收能力控制發送的數據量,這就是所謂的流量控制。
操作系統緩衝區與滑動窗口的關係
發送窗口和接收窗口中所存放的字節數,都是放在操作系統內存緩衝區中的,而操作系統的緩衝區,會被操作系統調整。
當應用進程沒辦法及時讀取緩衝區的內容時,也會對我們的緩衝區造成影響。
如果發生了先減少緩存,再收縮窗口,就會出現丟包的現象。
為了防止這種情況發生,TCP 規定是不允許同時減少緩存又收縮窗口的,而是採用先收縮窗口,過段時間再減少緩存,這樣就可以避免了丟包情況。
窗口關閉
如果窗口大小為 0 時,就會阻止發送方給接收方傳遞數據,直到窗口變為非 0 為止,這就是窗口關閉。
接收方向發送方通告窗口大小時,是通過 ACK 報文來通告的。
那麼,當發生窗口關閉時,接收方處理完數據後,會向發送方通告一個窗口非 0 的 ACK 報文,如果這個通告窗口的 ACK 報文在網絡中丟失了,這會導致發送方一直等待接收方的非 0 窗口通知,接收方也一直等待發送方的數據,如不採取措施,這種相互等待的過程,會造成了死鎖的現象。
為了解決這個問題,TCP 為每個連接設有一個持續定時器,只要 TCP 連接一方收到對方的零窗口通知,就啟動持續計時器。
如果持續計時器超時,就會發送窗口探測 (Window probe) 報文,而對方在確認這個探測報文時,給出自己現在的接收窗口大小。
窗口探測的次數一般為 3 次,每次大約 30-60 秒(不同的實現可能會不一樣)。如果 3 次過後接收窗口還是 0 的話,有的 TCP 實現就會發 RST 報文來中斷連接。
糊塗窗口綜合症
如果接收方太忙了,來不及取走接收窗口裡的數據,那麼就會導致發送方的發送窗口越來越小。
到最後,如果接收方騰出幾個字節並告訴發送方現在有幾個字節的窗口,而發送方會義無反顧地發送這幾個字節,這就是糊塗窗口綜合症。
要知道,我們的 TCP + IP 頭有 40 個字節,為了傳輸那幾個字節的數據,要搭上這麼大的開銷,這太不經濟了。
要解決糊塗窗口綜合症,就要同時解決兩個問題就可以了:
- 讓接收方不通告小窗口給發送方
接收方通常的策略如下:
當「窗口大小」小於 min (MSS,緩存空間 / 2) ,也就是小於 MSS 與 1/2 緩存大小中的最小值時,就會向發送方通告窗口為 0,也就阻止了發送方再發數據過來。
等到接收方處理了一些數據後,窗口大小 >= MSS,或者接收方緩存空間有一半可以使用,就可以把窗口打開讓發送方發送數據過來。 - 讓發送方避免發送小數據
發送方通常的策略如下:
使用 Nagle 算法,該算法的思路是延時處理,只有滿足下面兩個條件中的任意一個條件,才可以發送數據:
條件一:要等到窗口大小 >= MSS 並且 數據大小 >= MSS;
條件二:收到之前發送數據的 ack 回包;
只要上面兩個條件都不滿足,發送方一直在囤積數據,直到滿足上面的發送條件。
擁塞控制#
在網絡出現擁堵時,如果繼續發送大量數據包,可能會導致數據包時延、丟失等,這時 TCP 就會重傳數據,但是一重傳就會導致網絡的負擔更重,於是會導致更大的延遲以及更多的丟包,這個情況就會進入惡性循環被不斷地放大....
於是,就有了擁塞控制,控制的目的就是避免「發送方」的數據填滿整個網絡。
為了在「發送方」調節所要發送數據的量,定義了一個叫做「擁塞窗口」的概念。它會根據網絡的擁塞程度動態變化的。
擁塞控制算法:
1. 慢啟動
當發送方每收到一個 ACK,擁塞窗口 cwnd 的大小就會加 1。當慢啟動漲到慢啟動門檻 ssthresh (slow start threshold)。
- 當 cwnd < ssthresh 時,使用慢啟動算法。
- 當 cwnd >= ssthresh 時,就會使用「擁塞避免算法」。
2. 擁塞避免
每當收到一個 ACK 時,cwnd 增加 1/cwnd。
3. 擁塞發生
當網絡出現擁塞,也就是會發生數據包重傳,根據重傳機制的不同,擁塞發生算法也不同。
發生超時重傳的擁塞發生算法
- ssthresh 設為 cwnd/2
- cwnd 重置為 1 (是恢復為 cwnd 初始化值,我這裡假定 cwnd 初始化值 1)
發生快速重傳的擁塞發生算法
- cwnd = cwnd/2 ,也就是設置為原來的一半;
- ssthresh = cwnd;z
- 進入快速恢復算法
4. 快速恢復
- 擁塞窗口 cwnd = ssthresh + 3
- 重傳丟失的數據包;
- 如果再收到重複的 ACK,那麼 cwnd 增加 1;
- 如果收到新數據的 ACK 後,把 cwnd 設置為第一步中的 ssthresh 的值,原因是該 ACK 確認了新的數據,說明從 duplicated ACK 時的數據都已收到,該恢復過程已經結束,可以回到恢復之前的狀態了,也即再次進入擁塞避免狀態;