深入探索 TCP TIME-WAIT
1 TIME-WAIT 狀態
主動關閉連線的一方,在四次揮手最後一次傳送 ACK 後,進入 TIME_WAIT 狀態。在這個狀態裡,主動關閉連線一方等待 2MSL(Maximum Segment Life,報文段最大生存時間,在RFC793 中定義為 2 min,而在 Linux 中定義為 30s),若這段時間內未收到被動關閉一方重發的 FIN,則由 TIME_WAIT 狀態轉到 CLOSED 狀態。
祭上狀態機圖:
在這裡為了討論方便,假設主動關閉連線的一方均為本地客戶端,被動關閉連線的一方均為服務端,以客戶端與服務端 TCP 狀態的變化來討論。
2 存在的目的
為什麼 TCP 需要設定 TIME-WAIT 狀態等待 2MSL 才能轉到 CLOSED 狀態關閉連線呢?
2.1 避免在新連線上收到舊連線的資料
避免在同一四元組(源地址、源埠、目的地址、目的埠)上的新連線收到舊連線的資料。
如下圖所示,服務端第一次傳送的序號為 3 的資料包因延時未送達客戶端,服務端重發第二次序號為 3 的資料包後客戶端接收到並主動斷開連線。
在很短時間內,客戶端重新向服務端發起連線,這時服務端傳送序號 1、序號 2 的資料給客戶端,但同時客戶端也收到了在網路上延時到達的服務端第一次傳送的序號為 3 的資料包。
RFC793 中描述了 ISN 每 4 微秒會自增 1,達到 2^32 後又從 0 開始。這樣周始往復,一個 ISN 的週期大約是 4.55 個小時。所以雖然 TCP 每次建立連線時的 SYN 序列號都不會相同,但若如果在接收視窗很大的情況下,快速重新建立的連線使用的序列號可能會有一部分與舊連線使用過的序列號重合,因此新連線誤接收舊連線相同序列號的資料包是有機率發生的。
客戶端通過 TIME-WAIT 等待 2MLS 的時間可以避免這個問題:1)收到的延遲資料包被丟棄;2)2MLS 的時間會讓 ISN 與舊連線使用過的序列號重合範圍減小甚至不重合。TIME-WAIT 為新連線準備了時間緩衝帶,舊連線的資料包與新連線的資料包因此有足夠的界限。
2.2 確保服務端正確關閉連線
服務端如果沒有收到四次揮手中的最後一個 ACK,將會一直處於 LAST-ACK 狀態,並一直重傳 FIN 報文,有三種可能的情況發生:
-
放棄重傳 FIN,並移除該連線;
-
收到 ACK,狀態轉為 CLOSED,並正常關閉連線;
-
收到 RST,並移除該連線。
客戶端通過 TIME-WAIT 等待 2MLS 時間確保服務端正確關閉連線。如若在 TIME-WAIT 收到服務端重傳的 FIN,說明最後傳送的 ACK 在網路中丟失了,需要重發 ACK 以確保服務端能收到 ACK 並正確關閉連線。這也是為什麼 TIME-WAIT 等待時間是 2MLS 的原因,如果服務端重傳 FIN,客戶端必定在 2MLS 期間內收到:即使服務端收到 ACK 再重傳 FIN, 這個過程也只需要 2MLS 時間。
3 引發的問題
TIME-WAIT 狀態雖好,但是當大量的連線處於 TIME-WAIT 狀態而未被及時關閉,它會導致以下問題:
3.1 佔用連線資源
TIME-WAIT 狀態在 Linux 下會持續 60s,在這 60s 內,不能建立相同相同四元組(源地址、源埠、目的地址、目的埠)的新連線。
3.2 佔用記憶體空間
在核心中,一個 TIME-WAIT 狀態的 socket 與三個結構體相關,而這些資料結構在記憶體中都佔用一定的空間:
struct tcp_timewait_sock
每當收到一個新的報文時,會在名為 “TCP established” 的雜湊表中查詢這個連線。該雜湊表中的每個桶不僅包含 TIME-WAIT 狀態的連線連結串列,還包含其它正常狀態的連線連結串列。其中,TIME-WAIT 狀態的連結串列元素資料結構是 tcp_timewait_sock(168 bytes),而其它正常狀態的連結串列元素結構是 struct tcp_sock。
struct tcp_timewait_sock { struct inet_timewait_sock tw_sk; u32 tw_rcv_nxt; u32 tw_snd_nxt; u32 tw_rcv_wnd; u32 tw_ts_offset; u32 tw_ts_recent; long tw_ts_recent_stamp; }; struct inet_timewait_sock { struct sock_common __tw_common; int tw_timeout; volatile unsigned char tw_substate; unsigned char tw_rcv_wscale; __be16 tw_sport; unsigned int tw_ipv6only : 1, tw_transparent : 1, tw_pad : 6, tw_tos : 8, tw_ipv6_offset : 16; unsigned long tw_ttd; struct inet_bind_bucket *tw_tb; struct hlist_node tw_death_node; };
struct hlist_node
struct inet_timewait_sock 的資料成員 tw_death_node,用來跟蹤 TIME-WAIT 狀態的連線的存活時間,存活時間越長排在連結串列越靠後的位置。
struct inet_bind_socket
繫結埠的雜湊表,儲存本地被繫結的埠及相關聯的引數,用於:1)判斷是否可以在給定的埠上繫結;2)尋找未被繫結的可用的埠。
雜湊表的每個元素資料結構為 inet_bind_socket(48 bytes)。
3.3 佔用 CPU 資源
在 CPU 使用上,查詢一個可用的本地埠的代價可能有一丟丟大。這項工作由函式 inet_csk_get_port() 完成:鎖住並迭代本地的所有埠,直到找到一個未使用的埠。
4 解決方案
4.1 增加四元組可選範圍
具體來說:
-
客戶端設定 net.ipv4.ip_local_port_range 來擴充客戶端埠範圍;
-
客戶端使用更多的 IP 地址,例如,在負載均衡器上配置更多的 IP;
-
服務端監聽更多的埠,如 81,82,83 等;
-
服務端監聽更多的 IP 地址。
4.2 SO_LINGER 選項
預設情況下,應用程式呼叫 close() 關閉 socket 後會立即返回,TCP 模組會把傳送快取中的殘餘的資料繼續傳送完,最終轉到 TIME-WAIT 狀態。
SO_LINGER 是 socket 的一個選項,當 socket 被 close 時,該選項控制 socket 是否延遲關閉,以及如何處理髮送快取中的殘餘資料。
通過呼叫 setsockopt 來設定 socket 選項:
#include <sys/socket.h> int setsockopt( int sockfd, int level, int option_name, const void* option_value, socklen_t option_len );
sockfd 引數指定被操作的目標 socket,level 引數指定要操作哪個協議的選項,比如 IPv4、IPv6、TCP 等,option_name 引數則指定選項的名字。option_value 和 option_len 引數分別是被操作選項的值和長度。詳情見 setsockopt。
設定 SO_LINGER 選項的值時,我們需要給 setsockopt 傳遞一個 linger 型別的結構體,其定義如下:
#include <sys/socket.h> struct linger { int l_onoff; /* 開啟:非 0,關閉:0 */ int l_linger; /* 延遲時間 */ };
根據 linger 結構體中兩個成員變數的不同值,close() 會產生如下三種行為之一:
-
l_onff 為 0:SO_LINGER 關閉,close 用預設行為來關閉 socket;
-
l_onff 非 0:SO_LINGER 開啟,
-
l_linger == 0:客戶端應用程式呼叫 close() 後立即返回,傳送快取中的殘餘資料被丟棄,同時傳送一個 RST 給服務端來異常終止當前連線;
-
l_linger > 0:
-
socket 是阻塞的:客戶端應用程式呼叫 close() 後等待 l_linger 的時間,直到傳送完所有快取中的殘餘資料並得到遠端的確認。如果這段時間內還沒有傳送完殘餘資料,close() 返回 -1 並設定 errno 為 EWOULDBLOCK;
-
socket 是非阻塞的:客戶端應用程式呼叫 close() 後立即返回,根據 close() 的返回值與 error 來判斷殘餘資料是否已經發送完畢。
因此,開啟 SO_LINGER 並將 l_linger 設定為 0 時,服務端會收到 RST 並關閉連線。相當於跳過 TIME_WAIT 狀態直接關閉服務端的連線。但是,SO_LINGER 並沒有解決新連線收到舊連線資料包的問題。
4.3 SO_REUSEADDR 選項/net.ipv4.tcp_tw_reuse
開啟 SO_REUSEADDR 選項或者配置 net.ipv4.tcp_tw_reuse 為 1 後,Linux 將可以複用處於 TIME-WAIT 狀態的連線。前面我們說到,TIME-WAIT 狀態存在的目的一是為了避免在新連線上收到舊連線的資料,二是為了確保被動關閉方正確關閉連線,那麼我們開啟 SO_REUSEADDR 複用連線不是一切回到原點了嗎?這一切都是 TCP 時間戳選項的功勞。
RFC 1323 描述了一套如何在大寬頻高速網路下提升效能的 TCP 擴充套件,在這其中,新定義一個新的 TCP 時間戳選項:
Kind
1 位元組,固定為 8。
Length
1 位元組,固定為 10。
Timestamp Value (TSval)
4 位元組, TCP 傳送此選項時的當前時間戳。
TImestamp Echo Reply (TSecr)
4 位元組,僅在 ACK 中有效,把收到的 TSval 回填到 TSecr 中發回給遠端。當此報文不是 ACK,即 TSercr 無效時,TSecr 的值必須是 0 。
我們來看時間戳如何接手 TIME-WAIT 的問題:
1)避免在新連線上收到舊連線的資料
舊連線的資料會因為時間戳過於老舊而被丟棄;
2)確保服務端正確關閉連線
一旦客戶端用新的連線替代了 TIME-WAIT 狀態的連線,客戶端發出 SYN 報文後服務端重傳 FIN 報文(因為時間戳的關係,服務端識別出是新的連線,客戶端的 SYN 報文被忽略)。因為客戶端當前處於 SYN-SENT 狀態,所以會回覆 RST,這使得服務端能正確脫離 LAST-ACK 狀態並關閉連線。這之後,SYN 初始化報文會重發,重新進入新連線的建立流程:
4.4 net.ipv4.tcp_tw_recycle
net.ipv4.tcp_tw_recycle 配置為 1 會開啟系統對 TIME-WAIT 狀態的 socket 的快速回收。
net.ipv4.tcp_tw_recycle 同樣利用 TCP 的時間戳選項來優化 TIME-WAIT:Linux 每收到一個遠端(IP)的資料包,都記錄它的時間戳。當處於 TIME-WAIT 狀態的 socket 收到的同一遠端的資料包時間戳小於記錄值,Linux 直接丟棄該資料包並回收 socket。
但是,net.ipv4.tcp_tw_recycle 並不被推薦(Linux 從 4.12 核心版本開始移除了 tcp_tw_recycle 配置),它可能會導致很多難以排查的古怪問題。特別是伺服器或者客戶端在 NAT 網路中,多個伺服器或客戶端共用 NAT 裝置的時間戳,資料包可能會被丟棄。
4.5 net.ipv4.tcp_max_tw_buckets
表示系統同時保持 TIME-WAIT 套接字的最大數量,如果超過這個數字,TIME-WAIT 套接字將立刻被清除並列印警告資訊。預設值為180000。
5 參考資料
-
TCP/IP詳解 卷1:協議
-
Linux 高效能伺服器程式設計
-
TCP 的那些事兒(上)
-
Coping with the TCP TIME-WAIT state on busy Linux servers …
-
對 Linux TCP 的若干終點和誤會
-
被拋棄的tcp_recycle
&n