圖解 TCP 連接生命週期

TCP 連接生命週期

    來自《TCP 半連接全連接(一) -- 全連接隊列相關過程》(https://xiaodongq.github.io/2024/05/18/tcp_connect/)一文的作者,很好的總結了連接建立,數據收發到連接關閉的過程,正所謂一圖勝千言!

原圖地址:https://xiaodongq.github.io/images/tcp-connect-close.png

連接建立:三次握手


三次握手的大致流程如下:

1. 第一次握手(SYN):

2. 第二次握手(SYN + ACK):

3. 第三次握手(ACK):

    如圖所示,Linux 服務端在建立 TCP 連接的過程中,會涉及到兩個重要的隊列:半連接(SYNC)對了和全鏈接隊列(ACCEPT),很多和 TCP 連接相關的問題,或多或少都會和

半連接隊列和全連接隊列

    關於 TCP 半連接、全鏈接隊列,我們可以參考博文:從一次線上問題說起,詳解 TCP 半連接隊列、全連接隊列,帶着問題去分析,並做了詳細的實驗進行驗證,以及通過內核源碼分析原理。

    在 TCP 三次握手的過程中,Linux 內核會維護兩個隊列,分別是:

    正常的 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

    對於非 LISTEN 狀態的 socket:

    全連接隊列的最大長度由 min(somaxconn, backlog) 控制,其中:

    上面例子中,相關的內核參數:

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 = 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 建立連接相關的內核參數

    在 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_abort_on_overflow 設置爲 1 時,服務器在全連接隊列(accept 隊列)已滿的情況下會主動重置新進入的連接,具體行爲如下:

推薦是將 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)包。這個過程確保雙方可以有序地關閉連接,釋放資源。以下是具體步驟:

  1. 第一次揮手(FIN 包):主動關閉方(如客戶端)發送一個 FIN 包,表示它不再有數據需要發送,準備關閉連接。此時,連接進入 FIN-WAIT-1 狀態。

  2. 第二次揮手(ACK 包):被動關閉方(如服務器)收到 FIN 後,發送一個 ACK 包進行確認,告知已經知道對方請求關閉連接。此時,主動關閉方進入 FIN-WAIT-2 狀態,等待對方也發送 FIN 包。

  3. 第三次揮手(FIN 包):被動關閉方發送 FIN 包,表示它的數據也已發送完畢,準備關閉連接。此時,被動關閉方進入 CLOSE-WAIT 狀態,而主動關閉方收到此 FIN 包後進入 TIME-WAIT 狀態。

  4. 第四次揮手(ACK 包):主動關閉方收到被動關閉方的 FIN 包後,發送一個 ACK 包,確認收到對方的關閉請求。這一 ACK 包發出後,主動關閉方在 TIME-WAIT 狀態下等待一段時間(通常是 2MSL,兩個最大報文段壽命)以確保對方收到 ACK 包,然後徹底關閉連接。被動關閉方在收到 ACK 後,立即進入 CLOSED 狀態並釋放資源。

    我們經常會發現有很多處於 TIME- WAIT 狀態的連接,主動關閉方進入 TIME-WAIT 狀態並等待 2MSL 主要有兩個目的:

    一些特殊情況:

    在關閉連接過程中,一些和超時相關的內核參數:

    當 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

    但是也有一些其他內核參數可以控制:

    對於 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