圖解 TCP 連接生命週期
TCP 連接生命週期
來自《TCP 半連接全連接(一) -- 全連接隊列相關過程》(https://xiaodongq.github.io/2024/05/18/tcp_connect/)一文的作者,很好的總結了連接建立,數據收發到連接關閉的過程,正所謂一圖勝千言!
原圖地址:https://xiaodongq.github.io/images/tcp-connect-close.png
連接建立:三次握手
三次握手的大致流程如下:
1. 第一次握手(SYN):
-
客戶端向服務器發送一個 SYN(同步序列編號)包,請求建立連接。
-
SYN 包包含一個隨機生成的初始序列號(ISN,即 Initial Sequence Number),用於標識客戶端的初始數據序列位置。
2. 第二次握手(SYN + ACK):
-
服務器收到 SYN 包後,確認客戶端想要建立連接,於是向客戶端發送一個帶有 SYN 和 ACK 標誌位的包。
-
這個包的 ACK(確認序列編號)位設爲客戶端的 ISN + 1,表示接收到了客戶端的 SYN。
-
同時,服務器也會生成一個自己的 ISN,並在 SYN 字段中發送給客戶端,表示服務器希望建立連接。
3. 第三次握手(ACK):
-
客戶端收到服務器的 SYN + ACK 包後,發送一個確認包(ACK),並將 ACK 設置爲服務器的 ISN + 1。
-
此時,客戶端和服務器的連接已經建立,雙方可以開始進行數據傳輸。
如圖所示,Linux 服務端在建立 TCP 連接的過程中,會涉及到兩個重要的隊列:半連接(SYNC)對了和全鏈接隊列(ACCEPT),很多和 TCP 連接相關的問題,或多或少都會和
半連接隊列和全連接隊列
關於 TCP 半連接、全鏈接隊列,我們可以參考博文:從一次線上問題說起,詳解 TCP 半連接隊列、全連接隊列,帶着問題去分析,並做了詳細的實驗進行驗證,以及通過內核源碼分析原理。
在 TCP 三次握手的過程中,Linux 內核會維護兩個隊列,分別是:
-
半連接隊列 (SYN Queue)
-
全連接隊列 (Accept Queue)
正常的 TCP 三次握手過程中,當 Server 端收到 Client 端的 SYN 請求後,Server 端進入 SYN_RECV 狀態,此時內核會將連接存儲到_**半連接隊列 (SYN Queue)**_,並向 Client 端回覆 SYN+ACK。
當 Server 端收到 Client 端的 ACK 後,內核將連接從_**半連接隊列 (SYN Queue)**_ 中取出,添加到_**全連接隊列 (Accept Queue)**_,Server 端進入 _**ESTABLISHED**_ 狀態。
當 Server 端應用進程調用 accept 函數時,將連接從全連接隊列 (Accept Queue) 中取出。
半連接隊列和全連接隊列都有長度大小限制,超過限制時內核會將連接 Drop 丟棄或者返回 RST 包(TCP 層的丟包就來源於此)。
全連接隊列指標查看
我們可以通過 ss 命令可以查看到全連接隊列的信息:
# -n 不解析服務名稱
# -t 只顯示 tcp sockets
# -l 顯示正在監聽(LISTEN)的 sockets
# 4 只顯示 IPv4
ss -lnt4
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 1024 *:32479 *:*
LISTEN 0 1024 *:32000 *:*
LISTEN 0 1024 *:31332 *:*
LISTEN 0 1024 *:31046 *:*
LISTEN 0 1024 127.0.0.1:37383 *:*
LISTEN 0 128 *:42984 *:*
LISTEN 0 1024 127.0.0.1:10248 *:*
LISTEN 0 1024 172.18.120.55:8808 *:*
LISTEN 0 1024 127.0.0.1:8808 *:*
LISTEN 0 1024 *:5000 *:*
LISTEN 0 1024 172.18.120.55:8809 *:*
LISTEN 0 1024 127.0.0.1:11434 *:*
LISTEN 0 1024 172.18.120.55:10251 *:*
...
對於 LISTEN 狀態的 socket:
-
Recv-Q:當前全連接隊列的大小,即已完成三次握手_**等待**_應用程序 **accept()** 的 **TCP** 鏈接;
-
Send-Q:全連接隊列的最大長度,即全連接隊列的大小。
對於非 LISTEN 狀態的 socket:
-
Recv-Q:已收到但未被應用程序讀取的字節數;
-
Send-Q:已發送但未收到確認的字節數。
全連接隊列的最大長度由 min(somaxconn, backlog) 控制,其中:
-
somaxconn 是 Linux 內核參數,由 /proc/sys/net/core/somaxconn 指定;
-
backlog 是 TCP 協議中 listen 函數的參數之一,即 int listen(int sockfd, int backlog) 函數中的 backlog 大小。在 Golang 中,listen 的 backlog 參數使用的是 /proc/sys/net/core/somaxconn 文件中的值。
上面例子中,相關的內核參數:
cat /proc/sys/net/core/somaxconn
1024
sysctl net.core.somaxconn
net.core.somaxconn = 1024
作爲服務器,如果要處理很多客戶端的連接,我們應該把 somaxconn 的值設大一些(默認是 128,一般建議設置爲 4096),避免當客戶端的併發連接比較多的的時候,應用程序處理不過來。
半連接隊列指標查看
在 Linux 內核版本小於 2.6.20 時,半連接隊列纔等於 backlog 的大小。半連接隊列的長度計算相對比較複雜,《從一次線上問題說起,詳解 TCP 半連接隊列、全連接隊列》給出了一種計算公式:
backlog = min(somaxconn, backlog)
nr_table_entries = backlog
nr_table_entries = min(backlog, sysctl_max_syn_backlog)
nr_table_entries = max(nr_table_entries, 8)
// roundup_pow_of_two: 將參數向上取整到最小的 2^n,注意這裏存在一個 +1
nr_table_entries = roundup_pow_of_two(nr_table_entries + 1)
max_qlen_log = max(3, log2(nr_table_entries))
max_queue_length = 2^max_qlen_log
可以看到,半連接隊列的長度由三個參數指定:
-
調用 listen 時,傳入的 backlog;
-
** /proc/sys/net/core/somaxconn** 默認值爲 **128**;
-
/proc/sys/net/ipv4/tcp_max_syn_backlog 默認值爲 1024。
我們假設 listen 傳入的 backlog = 128 (Golang 中調用 listen 時傳遞的 backlog 參數使用的是 /proc/sys/net/core/somaxconn),其他配置採用默認值,來計算下半連接隊列的最大長度:
backlog = min(somaxconn, backlog) = min(128, 128) = 128
nr_table_entries = backlog = 128
nr_table_entries = min(backlog, sysctl_max_syn_backlog) = min(128, 1024) = 128
nr_table_entries = max(nr_table_entries, 8) = max(128, 8) = 128
nr_table_entries = roundup_pow_of_two(nr_table_entries + 1) = 256
max_qlen_log = max(3, log2(nr_table_entries)) = max(3, 8) = 8
max_queue_length = 2^max_qlen_log = 2^8 = 256
可以得到半隊列大小是 256。
我們可以通過如下方式查看當前監聽 IP + 端口對應的半連接數量:
# netstat -nat:顯示所有 TCP 連接的狀態。
# awk '/SYN_RECV/ {print $4}':篩選出所有狀態爲 SYN_RECV 的連接,打印本地 IP 和端口(第四列)。
# sort:對 IP 和端口進行排序,以便進行統計。
# uniq -c:統計每個 IP 和端口出現的次數,即各 IP 和端口的 SYN_RECV 連接數量。
# sort -nr:按數量從高到低排序,方便查看哪些 IP 和端口的 SYN_RECV 狀態連接最多。
netstat -nat | awk '/SYN_RECV/ {print $4}' | sort | uniq -c | sort -nr
8 172.18.120.55:15443
8 172.18.120.55:10252
8 172.18.120.55:10251
7 127.0.0.1:8080
每個 IP + 端口組合(即每個監聽的套接字)在內核中都會維護一個獨立的半連接隊列,用於存儲那些已收到客戶端 SYN 包但尚未完成三次握手的連接。
與 TCP 建立連接相關的內核參數
- net.ipv4.tcp_syn_retries(客戶端):Client 發給 Server 的 SYN 包,可能會在傳輸過程中丟失,或者因爲其他原因導致 Server 無法處理,此時 Client 這一側就會觸發超時重傳機制。但是也不能一直重傳下去,重傳的次數也是有限制的,這就是 tcp_syn_retries 這個配置項來決定的。假設 tcp_syn_retries 爲 3,那麼 SYN 包重傳的策略大致如下:
在 Client 發出 SYN 後,如果過了 1 秒 ,還沒有收到 Server 的響應,那麼就會進行第一次重傳;如果經過 2s 的時間還沒有收到 Server 的響應,就會進行第二次重傳;一直重傳 tcp_syn_retries 次。
對於 tcp_syn_retries 爲 3 而言,總共會重傳 3 次,也就是說從第一次發出 SYN 包後,會一直等待(1 + 2 + 4 + 8)秒,如果還沒有收到 Server 的響應,connect() 就會產生 ETIMEOUT 的錯誤。
而 tcp_syn_retries 的默認值是 6,也就是說如果 SYN 一直髮送失敗,會在(1 + 2 + 4 + 8 + 16+ 32 + 64)秒,即 127 秒後產生 ETIMEOUT 的錯誤。
通常情況下,我們可以將數據中心內部服務器的 tcp_syn_retries 給調小,這裏推薦設置爲 2,來減少阻塞的時間。因爲對於數據中心而言,它的網絡質量是很好的,如果得不到 Server 的響應,很可能是 Server 本身出了問題。在這種情況下,Client 及早地去嘗試連接其他的 Server 會是一個比較好的選擇。
-
net.ipv4.tcp_synack_retries(服務端):Server 向 Client 發送的 SYNACK 包也可能會被丟棄,或者因爲某些原因而收不到 Client 的響應,這個時候 Server 也會重傳 SYNACK 包。重傳的次數也是由內核參數 tcp_synack_retries 控制的。tcp_synack_retries 的重傳策略與 tcp_syn_retries 是一致的。它在系統中默認是 5,對於數據中心的服務器而言,通常都不需要這麼大的值,推薦設置爲 2。
-
net.ipv4.tcp_synack_retries(服務端):爲了防止 SYN Flood 攻擊,Linux 內核引入了 SYN Cookies 機制。SYN Cookie 的原理:在 Server 收到 SYN 包時,不去分配資源來保存 Client 的信息(放入到半連接隊列中),而是根據這個 SYN 包計算出一個 Cookie 值,然後將 Cookie 記錄到 SYNACK 包中發送出去。對於正常的連接,該 Cookies 值會隨着 Client 的 ACK 報文被帶回來。然後 Server 再根據這個 Cookie 檢查這個 ACK 包的合法性,如果合法,纔去創建新的 TCP 連接。通過這種處理,SYN Cookies 可以防止部分 SYN Flood 攻擊。所以對於 Linux 服務器而言,推薦開啓,將 net.ipv4.tcp_syncookies 設置爲 1。
-
開啓之後,理論上只要當全連接隊列未滿,SYN 請求永遠不會被 Drop,但是根據《從一次線上問題說起,詳解 TCP 半連接隊列、全連接隊列》一文的實驗,這個假設還需要看內核的具體實現,他得到的結果是:實驗發現即使 syncookies 設爲 1,當半連接隊列長度 > 全連接隊列最大長度時,就會觸發 DROP SYN 請求。
-
net.ipv4.tcp_abort_on_overflow(服務端):當服務器中積壓的全連接個數超過最大值(由 somaxconn 和傳入的 backlog 共同決定)且 net.ipv4.tcp_abort_on_overflow 爲 0 (默認值)時,系統不會立即關閉新的連接,而是將超出的連接請求置於等待狀態。具體來說,服務器不會發送 TCP RST(重置)包來通知客戶端連接被拒絕,而是直接 丟棄(drop)客戶端的重傳 SYN 包。以下是具體的行爲:
-
初始 SYN 包:當客戶端發送一個 SYN 包請求建立連接時,如果全連接隊列已滿,服務器會丟棄該 SYN 包,不會發出任何響應。客戶端通常會進行重傳。
-
後續的 SYN 重傳包:當客戶端未收到服務器的 SYN+ACK 響應後,會根據 TCP 重傳機制重新發送 SYN 包。此時,服務器仍然丟棄這些重傳的 SYN 包。
-
客戶端的行爲:由於服務器不響應且未發送 RST 包,客戶端會按照 TCP 的重試策略,繼續發送 SYN 包,直到超過重試次數或達到連接超時。此時,客戶端會報告連接失敗。
當 net.ipv4.tcp_abort_on_overflow 設置爲 1 時,服務器在全連接隊列(accept 隊列)已滿的情況下會主動重置新進入的連接,具體行爲如下:
-
初始 SYN 包:當服務器收到客戶端的初始 SYN 包,但發現全連接隊列已滿,無法接受更多連接時,服務器立即返回一個 TCP RST(重置)包給客戶端。
-
客戶端的行爲:由於服務器發送了 TCP RST 包,客戶端會立即收到連接被拒絕的通知,這樣客戶端就可以立刻知道當前連接請求失敗,可以選擇立即重試或採取其他措施,而不需要經歷重傳和等待超時的過程。
推薦是將 net.ipv4.tcp_abort_on_overflow 設爲 0。這是因爲 Server 如果來不及 accept() 而導致全連接隊列滿,這往往是由瞬間有大量新建連接請求導致的,正常情況下 Server 很快就能恢復,然後 Client 再次重試後就可以建連成功了。也就是說,將 tcp_abort_on_overflow 配置爲 0,給了 Client 一個重試的機會。
accept() 成功返回後,一個新的 TCP 連接就建立完成了,TCP 連接進入到了 ESTABLISHED 狀態
TCP 斷開連接過程
關於 TCP 連接狀態,我們放上另外一張圖:
摘自:https://commons.wikimedia.org/wiki/File:TCP_state_diagram.jpg
TCP 連接關閉過程通常由客戶端或服務器發起,遵循一個稱爲 四次揮手(Four-Way Handshake) 的流程。四次揮手的每一步都涉及一方發送 FIN(finish)包,另一方確認(ACK)包。這個過程確保雙方可以有序地關閉連接,釋放資源。以下是具體步驟:
-
第一次揮手(FIN 包):主動關閉方(如客戶端)發送一個 FIN 包,表示它不再有數據需要發送,準備關閉連接。此時,連接進入 FIN-WAIT-1 狀態。
-
第二次揮手(ACK 包):被動關閉方(如服務器)收到 FIN 後,發送一個 ACK 包進行確認,告知已經知道對方請求關閉連接。此時,主動關閉方進入 FIN-WAIT-2 狀態,等待對方也發送 FIN 包。
-
第三次揮手(FIN 包):被動關閉方發送 FIN 包,表示它的數據也已發送完畢,準備關閉連接。此時,被動關閉方進入 CLOSE-WAIT 狀態,而主動關閉方收到此 FIN 包後進入 TIME-WAIT 狀態。
-
第四次揮手(ACK 包):主動關閉方收到被動關閉方的 FIN 包後,發送一個 ACK 包,確認收到對方的關閉請求。這一 ACK 包發出後,主動關閉方在 TIME-WAIT 狀態下等待一段時間(通常是 2MSL,兩個最大報文段壽命)以確保對方收到 ACK 包,然後徹底關閉連接。被動關閉方在收到 ACK 後,立即進入 CLOSED 狀態並釋放資源。
我們經常會發現有很多處於 TIME- WAIT 狀態的連接,主動關閉方進入 TIME-WAIT 狀態並等待 2MSL 主要有兩個目的:
-
確保最終的 ACK 包能到達對方,避免因網絡丟包導致對方未能徹底關閉連接。
-
防止舊的重複數據包乾擾新的連接。等待 2MSL 確保舊連接的包不再出現在網絡中,從而避免衝突。
一些特殊情況:
-
**RST(Reset)**包:在異常情況下(如超時或一方故障),TCP 會發送 RST 包立即中斷連接。RST 包不會經過四次揮手過程。
-
半關閉:TCP 支持半關閉,即一方可以停止發送數據而繼續接收數據,直到對方也關閉爲止。這種情況在長連接的協議(如 HTTP/1.1)中較爲常見。
在關閉連接過程中,一些和超時相關的內核參數:
當 TCP 進入到 FIN_WAIT_2 狀態後,如果本端遲遲收不到對端的 FIN 包,那就會一直處於這個狀態,於是就會一直消耗系統資源。Linux 爲了防止這種資源的開銷,設置了這個狀態的超時時間 tcp_fin_timeout,默認爲 60s,超過這個時間後就會自動銷燬該連接。
至於本端爲何遲遲收不到對端的 FIN 包,通常情況下都是因爲對端機器出了問題,或者是因爲太繁忙而不能及時 close()。所以,通常我們都建議將 tcp_fin_timeout 調小一些,以儘量避免這種狀態下的資源開銷。對於數據中心內部的機器而言,將它調整爲 2s 是合適的(具體要做一些測試驗證)。
Linux 上 TIME_WAIT 的默認存活時間是 60s(TCP_TIMEWAIT_LEN,一個內核代碼的常量),這個時間對於數據中心而言可能還是有些長了。在 Linux 的內核中,這個參數無法修改。阿里雲等公有云通過修改內核代碼實現了修改功能,具體可以參考:https://help.aliyun.com/zh/alinux/user-guide/change-the-tcp-time-wait-timeout-period。
但是也有一些其他內核參數可以控制:
-
tcp_max_tw_buckets(主動方):是一個 Linux 內核參數,用於限制系統中 TIME-WAIT 狀態的 TCP 連接數上限。當系統中處於 TIME-WAIT 狀態的連接數超過 tcp_max_tw_buckets 值時,系統將主動丟棄一些 TIME-WAIT 連接,以釋放資源。對於數據中心而言,網絡是相對很穩定的,基本不會存在 FIN 包的異常,所以建議將該值調小一些(例如 10000)。
-
**net.ipv4.tcp_tw_reuse(主動方):**是一個 Linux 內核參數,用於控制是否允許系統重用處於 TIME-WAIT 狀態的 TCP 連接。
-
0(默認值):不允許重用 TIME-WAIT 狀態的連接。系統中的連接必須在 TIME-WAIT 狀態停留到期(即經過 2MSL 時間)後纔會徹底關閉。
-
1:允許重用 TIME-WAIT 狀態的連接。系統會在適當的情況下將這些連接重新用於新的連接請求。Client 關閉跟 Server 的連接後,也有可能很快再次跟 Server 之間建立一個新的連接,而由於 TCP 端口最多隻有 65536 個,如果不去複用處於 TIME_WAIT 狀態的連接,就可能在快速重啓應用程序時,出現端口被佔用而無法創建新連接的情況,如果有這種現象,建議開啓 tcp_tw_reuse 功能。
-
****net.ipv4.tcp_tw_recycle: 用於加速 TIME_WAIT 狀態連接的回收,但是該選項是很危險的,因爲它可能會引起意料不到的問題,比如可能會引起 NAT 環境下的丟包問題。因此建議將該選項關閉,將 net.ipv4.tcp_tw_recycle 設爲 0。因爲打開該參數引發了很多的問題,所以 Linux 內核 4.12 版本就索性移除了該內核參數:tcp: remove tcp_tw_recycle (https://git.kernel.org/pub/scm/linux/kernel/git/next/linux-next.git/commit/?id=4396e46187ca5070219b81773c4e65088dac50cc)。
對於 CLOSE_WAIT 狀態而言,系統中沒有對應的配置項。但是該狀態也是一個危險信號,如果這個狀態的 TCP 連接較多,那往往意味着應用程序有 Bug,在某些條件下沒有調用 close() 來關閉連接。我們在生產環境上就遇到過很多這類問題。所以,如果你的系統中存在很多 CLOSE_WAIT 狀態的連接,那你最好去排查一下你的應用程序,看看哪裏漏掉了 close()。
筆者整理了一個腳本 tcp_stat.sh,可以用於統計 TCP 連接狀態:
#!/bin/bash
echo "監聽的 TCP 端口及其對應的 IP、PID、進程名稱、建立的連接數量及收發報文量:"
# 打印標題行,並設置列寬
printf "%-40s %-10s %-10s %-20s %-20s %-40s\n" "IP" "Port" "PID" "進程名稱" "連接數" "收發報文量"
# 解析 netstat 輸出並格式化列寬
netstat -tnlp | awk '/^tcp/ {print $4, $7}' | while read -r line; do
address=$(echo $line | awk '{print $1}')
pid_info=$(echo $line | awk '{print $2}')
# 判斷是 IPv4, IPv6 地址,或監聽所有接口 (:: 或 0.0.0.0)
if [[ $address == :::* ]]; then
ip=":::"
port="${address##:::}"
elif [[ $address == *:*:* ]]; then
# IPv6 地址:提取格式 [IPv6]:Port
ip=$(echo $address | sed -E 's/^\[(.*)\]:[0-9]+$/\1/')
port=$(echo $address | sed -E 's/^.*\]:([0-9]+)$/\1/')
else
# IPv4 地址或 0.0.0.0:Port
ip=$(echo $address | cut -d':' -f1)
port=$(echo $address | cut -d':' -f2)
[[ $ip == "0.0.0.0" ]] && ip="0.0.0.0"
fi
pid=$(echo $pid_info | cut -d'/' -f1)
if [[ -n "$pid" && "$pid" =~ ^[0-9]+$ ]]; then
pname=$(ps -p $pid -o comm=)
connections=$(netstat -tn | awk -v port=$port '/^tcp/ && $4 ~ ":"port"$" {count++} END {print count+0}')
# 獲取收發報文量
traffic=$(ss -tin "sport = :$port" | awk '
/bytes_acked/ {
for (i=1; i<=NF; i++) {
if ($i ~ /bytes_acked/) { tx += substr($i, index($i, ":")+1) }
if ($i ~ /bytes_received/) { rx += substr($i, index($i, ":")+1) }
}
}
END {printf "%.2fMB/%.2fMB\n", rx/1024/1024, tx/1024/1024}
'
)
# 輸出每一行數據
printf "%-40s %-10s %-10s %-20s %-10s %-20s\n" "$ip" "$port" "$pid" "$pname" "$connections" "$traffic"
else
printf "%-40s %-10s %-10s %-20s %-10s %-20s\n" "$ip" "$port" "-" "-" "-" "-"
fi
# 對輸出按 IP 和數值端口排序
done | sort -k1,1 -k2,2n
執行效果:
./tcp_stat.sh
監聽的 TCP 端口及其對應的 IP、PID、進程名稱、建立的連接數量及收發報文量:
IP Port PID 進程名稱 連接數 收發報文量
0.0.0.0 22 2434 sshd 2 0.01MB/0.01MB
0.0.0.0 80 17190 nginx 0 0.00MB/0.00MB
0.0.0.0 111 1911 rpcbind 0 0.00MB/0.00MB
0.0.0.0 443 17190 nginx 0 0.00MB/0.00MB
0.0.0.0 1235 2451 haproxy 0 0.00MB/0.00MB
0.0.0.0 5000 2451 haproxy 0 0.00MB/0.00MB
0.0.0.0 8181 17190 nginx 0 0.00MB/0.00MB
0.0.0.0 16443 2451 haproxy 4 708.62MB/10939.55MB
內核參數總結
摘自:https://time.geekbang.org/column/article/284912
爲了更加系統學習,筆者推薦大家可以學習一下極客時間的《Linux 內核技術實戰課》。
網絡優化參考
更多關於網絡性能優化,大家可以參考:《性能分析實戰篇 - Linux 網絡性能優化指南》。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/cOXUH8knOomVLGE6YWs9Mg