斯坦福 CS144 計算機網絡播客:傳輸層協議、可靠通信與端到端原則
互聯網的核心魅力在于其連接能力,但這份連接的底層網絡,本質上是“盡力而為”(best-effort)的——它不保證數據包一定能送達,也不保證它們的順序或完整性。那么,我們每天使用的網頁瀏覽、文件下載等需要高度可靠性的應用,是如何在這片“狂野西部”之上建立起秩序的呢?
答案就在 傳輸層 (Transport Layer) 。
這篇博文將和你一起,深入探討傳輸層的兩大核心協議:UDP 和 TCP,并揭示它們如何一步步從不可靠的服務之上,構建出我們依賴的可靠通信。
傳輸層的兩大支柱:UDP 與 TCP
傳輸層協議在不同的應用程序之間提供端到端的通信服務。在這個層面上,有兩個性格迥異的主角:UDP 和 TCP。
UDP:輕裝上陣的“信使”
用戶數據報協議 (User Datagram Protocol, UDP) 提供的是一種極其簡單的服務。你可以把它想象成一個只管寄信、不問結果的郵差。
它的特點非常鮮明:
- 無連接 (Connectionless) :發送數據前不需要“握手”建立連接。每個數據報(datagram)都包含了所有必要的信息,獨立地在網絡中傳輸。
- 不可靠 (Unreliable) :它不保證數據報一定送達,不發送確認,不檢測丟失,也不會要求重發。
- 無序 (No Ordering) :數據報到達的順序可能與發送順序不同。如果應用需要有序,必須自己在上層處理。
- 開銷小 :UDP 頭部只有 8 字節,包含源/目的端口、長度和校驗和四個字段,非常高效。
正是因為這些特點,UDP 非常適用于那些對實時性要求高、能容忍少量丟包、或者應用層自己實現了可靠性機制的場景。
典型的應用實例包括:
- DNS (域名系統) :一次請求和一次響應,快速高效。
- DHCP (動態主機配置協議) :用于獲取 IP 地址。
- NTP (網絡時間協議) :用于時間同步。
- 在線游戲、視頻直播等。
值得一提的是 UDP 的校驗和 (Checksum)。它不僅計算 UDP 頭部和數據,還包含了源/目的 IP 地址和協議 ID 等偽頭部信息。這在理論上違反了分層原則(傳輸層不應關心網絡層信息),但這是一個非常務實的設計,它能幫助檢測數據報是否因為網絡錯誤而被“張冠李戴”,送錯了終點。
TCP:穩重可靠的“工程師”
傳輸控制協議 (Transmission Control Protocol, TCP) 是互聯網的基石,超過 95% 的互聯網應用(如網頁瀏覽、郵件、文件傳輸)都構建在它之上。與 UDP 的“放任不管”不同,TCP 提供的是一種 可靠的、面向連接的、雙向字節流 (bi-directional byte stream) 服務。
它就像一位嚴謹的工程師,通過一系列復雜的機制來確保萬無一失:
- 可靠性 (Reliability) :確保數據無損壞、無丟失、無重復且按序到達。
- 有序交付 (Ordered Delivery) :將數據按發送順序交付給應用層。
- 字節流 (Byte Stream) :對應用程序來說,數據就像一個連續不斷的字節流,TCP 會負責將其拆分成合適大小的段 (segment) 進行傳輸。
- 面向連接 (Connection-Oriented) :在通信前,必須通過“三次握手”建立連接;結束后,通過“四次揮手”斷開連接。
為了實現這些目標,TCP 精心設計了多種機制:
- 確認 (Acknowledgement, ACK) :接收方每收到一個數據段,都會向發送方回送一個確認。
- 序列號 (Sequence Number) :TCP 將每個字節都進行了編號。每個 TCP 段的頭部都攜帶了該段數據第一個字節的序列號,用于檢測丟包和重排亂序的數據。
- 校驗和 (Checksum) :用于檢測數據在傳輸過程中是否被損壞。
- 流量控制 (Flow Control) :防止發送方發送數據太快,壓垮接收方的緩沖區。
- 擁塞控制 (Congestion Control) :從整個網絡的角度出發,調節發送速率,避免造成網絡擁塞。
ICMP:網絡層的“偵察兵”
還有一個協議我們必須提及: 互聯網控制消息協議 (Internet Control Message Protocol, ICMP) 。ICMP 是與 IP 緊密相關的控制協議:ICMP 報文被封裝在 IP 數據報中,屬于網絡層,用于向主機和路由器報告網絡層的錯誤并提供診斷功能(例如 ping 和 traceroute)。
我們常用的 ping 和 traceroute 命令,就是基于 ICMP 實現的。例如,ping 使用 ICMP 的“回顯請求 (Echo Request)”和“回顯應答 (Echo Reply)”消息。traceroute 則巧妙地利用了 ICMP 的“超時 (Time Exceeded)”和“端口不可達 (Port Unreachable)”消息來探測數據包經過的路由路徑。
可靠性的基石:TCP 連接管理
TCP 的可靠性始于其連接的建立與拆除過程。這個過程由一個精確定義的 有限狀態機 (Finite State Machine, FSM) 控制,確保了雙方狀態的同步。
三次握手:建立連接
TCP 通過三次報文交換來建立一個連接,這個過程被稱為 三向握手 (Three-Way Handshake) 。
Client Server
(state: CLOSED) (state: LISTEN)
-- SYN (seq=x) ---------------->
(state: SYN_SENT)
(state: SYN_RCVD)
<-- SYN, ACK (seq=y, ack=x+1) --
(state: ESTABLISHED)
-- ACK (seq=x+1, ack=y+1) ----->
(state: ESTABLISHED)- SYN :客戶端(主動開啟方)發送一個 SYN 報文段,其中包含一個隨機生成的 初始序列號 (Initial Sequence Number, ISN) ,我們稱之為 seq=x。此時,客戶端進入 SYN_SENT 狀態。
- SYN+ACK :服務器收到 SYN 后,也發送一個 SYN 報文段作為響應,其中包含了服務器自己的 ISN (seq=y),同時通過 ACK 報文段確認收到了客戶端的 SYN,確認號為 ack=x+1。此時,服務器進入 SYN_RCVD 狀態。
- ACK :客戶端收到服務器的 SYN+ACK 后,發送一個 ACK 報文段來確認服務器的 SYN,確認號為 ack=y+1。
至此,雙方都確認了對方的接收和發送能力,連接建立成功,可以開始傳輸數據。隨機化 ISN 的一個重要目的是為了防止舊連接中延遲的數據包干擾新建立的連接。
四次揮手:斷開連接
連接的拆除則需要四次報文交換,即 四向揮手 (Four-Way Handshake) 。
Client Server
(state: ESTABLISHED) (state: ESTABLISHED)
-- FIN (seq=x) ----------------->
(state: CLOSE_WAIT)
(state: FIN_WAIT_1)
<--------------- ACK (ack=x+1) --
(state: FIN_WAIT_2)
(sends remaining data...)
<------------------ FIN (seq=y) --
(state: LAST_ACK)
(state: TIME_WAIT)
-- ACK (ack=y+1) ---------------->
(state: CLOSED)
(waits 2*MSL)
(state: CLOSED)- FIN :當一方(比如客戶端)數據發送完畢,會發送一個 FIN 報文段,表示“我這邊沒數據要發了”。
- ACK :另一方(服務器)收到 FIN 后,回復一個 ACK,表示“知道了”。此時,從客戶端到服務器方向的連接關閉,但服務器仍然可以向客戶端發送數據。
- FIN :當服務器也發送完所有數據后,它會發送自己的 FIN 報文段。
- ACK :客戶端收到服務器的 FIN 后,回復最后一個 ACK。
在這里,主動關閉方(客戶端)在發送最后一個 ACK 后,會進入一個特殊的 TIME_WAIT 狀態,并等待兩倍的 最大段生存時間 (Maximum Segment Lifetime, MSL) 。這主要是為了兩個目的:
- 確保最后一個 ACK 報文能成功到達對方。如果這個 ACK 丟失,對方會重傳 FIN,客戶端仍能響應。
- 防止新連接復用相同的端口號時,受到舊連接中延遲報文的干擾。
TCP 的智慧:流量、窗口與擁塞控制
當 TCP 連接進入 ESTABLISHED 狀態后,真正的數據傳輸才剛剛開始。但這并非一個簡單的“你發我收”的過程。TCP 面臨兩大核心挑戰:
- 流量控制 (Flow Control) :如何確保發送方不會因為發送速度過快,而壓垮(overwhelm)接收方的處理能力?這是一個點對點的問題。
- 擁塞控制 (Congestion Control) :如何確保單個 TCP 連接不會因為發送速度過快,而壓垮整個網絡的承載能力,導致網絡擁塞甚至崩潰?這是一個全局性的問題。
為了優雅地解決這兩個問題,TCP 設計了其最核心的機制之一—— 滑動窗口 (Sliding Window) ,并在此基礎上衍生出兩套獨立的控制算法。
流量控制:照顧接收方的感受
流量控制的目標非常明確:匹配發送方的發送速率與接收方的接收速率。
想象一下你正在往一個杯子里倒水。如果倒得太快,水就會溢出。TCP 的接收方同樣有一個大小有限的緩沖區(receive buffer)。如果發送方發送的數據超出了這個緩沖區的容納能力,后續的數據包就會被丟棄,造成不必要的網絡開銷和重傳。
TCP 的解決方案是讓接收方明確地告訴發送方:“我還能接收多少數據”。
- 接收窗口 (Receive Window, rwnd) :接收方會在其發送的每一個 TCP 報文段(無論是純 ACK 報文還是攜帶數據的報文)的頭部 Window 字段中,填入其當前接收緩沖區的剩余空間大小。這個值就是**接收窗口 (rwnd)**。
- 發送方的響應 :發送方在收到這個 rwnd 值后,就會相應地調整自己的發送行為。它會確保任何時刻,網絡中“在途”(已發送但未收到確認)的數據總量,不能超過接收方通告的 rwnd 值。
Sender's View:
| ... | 已發送并確認 | 已發送但未確認 (在途數據) | 可用但未發送 | 不可用 | ... |
+------------------+-----------------------------+----------------+----------+
^ ^
上次確認 最后發送
|<-- 在途數據量 <= rwnd (接收窗口) -->|零窗口問題與探測
一個特殊情況是,當接收方緩沖區滿了,它會通告一個 rwnd = 0 的窗口。此時發送方必須停止發送數據。但問題是,如果接收方后續處理了數據、騰出了空間,它發出的更新窗口的 ACK 報文在網絡中丟失了怎么辦?發送方將永遠等待下去,形成死鎖。
為了解決這個問題,TCP 設計了 零窗口探測 (Zero-Window Probe) 機制。當發送方收到零窗口通知后,會啟動一個計時器。計時器超時后,它會發送一個非常小的探測報文(通常只包含 1 字節數據),“戳”一下接收方。接收方在收到這個探測報文后,會回復一個包含當前 rwnd 的 ACK,這樣發送方就能獲知窗口是否已經更新,從而打破死鎖。
擁塞控制:照顧整個網絡的感受
流量控制解決了點對點的問題,但如果網絡路徑上的路由器處理能力有限,即使接收方 rwnd 很大,大量的并發數據流依然會導致網絡擁塞(congestion)。擁塞會導致路由器隊列溢出、大量丟包、延遲劇增,最終導致整個網絡吞吐量急劇下降,這種現象被稱為 擁塞崩潰 (Congestion Collapse) 。
擁塞控制的核心思想是:TCP 發送方通過觀察網絡狀態(主要是 丟包 ),來動態調整自己的發送速率。
- 擁塞窗口 (Congestion Window, cwnd) :為此,TCP 在發送方內部維護了另一個狀態變量—— 擁塞窗口 (cwnd) 。它代表了發送方在收到 ACK 之前,可以向網絡中發送的最大數據量。cwnd 的大小不依賴于接收方,而是發送方根據網絡擁塞程度的 自我評估 。
- 真正的發送窗口 :最終,一個 TCP 發送方在任意時刻的實際發送窗口,是 流量控制窗口和擁塞控制窗口中的較小者 。
EffectiveWindow = min(rwnd, cwnd) 這個簡單的公式優雅地將兩個控制機制結合在了一起。
- TCP 擁塞控制的四大核心算法 :cwnd 的動態調整過程,主要由以下四個算法協同完成,它們共同構成了一套復雜的“舞蹈”,以適應不斷變化的網絡狀況。
1. 慢啟動 (Slow Start)
- 目的 :在連接剛建立時,快速探測網絡的可用帶寬。
- 行為 :cwnd 初始值很?。ㄍǔ?1 到 10 個 MSS,即最大報文段長度)。每收到一個 ACK,cwnd 就增加 1 個 MSS。這使得 cwnd 在每個 往返時間 (Round-Trip Time, RTT) 內近似翻倍,呈現 指數級增長 。
- 結束 :當 cwnd 的大小超過一個預設的 慢啟動閾值 (Slow Start Threshold, ssthresh) 時,慢啟動階段結束,進入擁塞避免階段。
2. 擁塞避免 (Congestion Avoidance)
- 目的 :當 cwnd 增長到一定程度(可能接近網絡容量)后,需要更謹慎地增加發送速率,避免造成擁塞。
- 行為 :進入此階段后,cwnd 不再指數增長,而是 線性增長 。粗略地說,每個 RTT,cwnd 只增加 1 個 MSS。這種“加性增(Additive Increase)”的方式會溫和地探測更多可用帶寬。
3. 擁塞發生時的反應(快速重傳與快速恢復)
當擁塞發生時,網絡就會出現丟包。TCP 通過兩種方式感知丟包:
- 超時重傳 (Timeout Retransmission) :這是最壞的情況,表示發送方在很長一段時間內沒有收到任何 ACK。TCP 會認為網絡發生了嚴重擁塞。
- 3 個重復的 ACK (3 Duplicate ACKs) :當發送方連續收到 3 個或以上針對同一數據包的 ACK 時,它會認為這個數據包之后的一個數據包丟失了。這是一個相對溫和的擁塞信號。
針對這兩種情況,TCP 的反應是不同的:
- 對于超時 :TCP 會采取激烈的“乘性減(Multiplicative Decrease)”策略。
- 將 ssthresh 設置為當前 cwnd 的一半。
- 將 cwnd 重置為初始值(例如 1 MSS)。
- 重新進入 慢啟動 階段。
- 對于 3 個重復 ACK :TCP 啟用快速重傳 (Fast Retransmit) 和快速恢復 (Fast Recovery) 算法。
- 快速重傳 :不等超時,立即重傳那個被認為丟失的數據段。
- 快速恢復 :不將 cwnd 降為 1,因為收到重復 ACK 說明網絡中仍有數據包在正常傳輸。
- 將 ssthresh 設置為當前 cwnd 的一半。
- 將 cwnd 也設置為新的 ssthresh 值(而不是 1)。
- 直接進入 擁塞避免 階段,跳過慢啟動。
總結:流量控制 vs. 擁塞控制
特性 | 流量控制 (Flow Control) | 擁塞控制 (Congestion Control) |
目標 | 保護 接收方 ,防止其緩沖區溢出 | 保護 網絡 ,防止網絡擁塞崩潰 |
控制方 | 由 接收方 驅動 | 由 發送方 驅動(基于對網絡的推斷) |
核心機制 | 接收窗口 ( | 擁塞窗口 ( |
問題范疇 | 端到端(點對點)問題 | 端到網絡再到端(系統性)問題 |
信號來源 | 接收方明確告知其可用緩沖空間 | 發送方通過 丟包(超時或重復 ACK)來推斷擁塞 |
通過這兩套機制的精妙配合,TCP 成功地在 ESTABLISHED 狀態下實現了既能充分利用網絡帶寬、又能避免壓垮接收方和整個網絡的智慧傳輸。
互聯網的設計哲學:端到端原則
為什么網絡協議會設計成今天這個樣子?背后有一個非常重要的指導思想—— 端到端原則 (End-to-End Principle) 。
這個原則有如下兩個版本。
弱端到端原則 (Weak End-to-End Principle)
其核心思想是,某些功能(如文件傳輸的正確性、安全性)只有在通信系統的 端點 (endpoint) ,也就是應用程序中,才能被完整且正確地實現。網絡中間的節點(如路由器)可以提供一些輔助功能作為性能優化(比如在無線鏈路層進行重傳),但應用程序 不能依賴 這些中間輔助來保證最終的正確性。
強端到端原則 (Strong End-to-End Principle)
這個版本更為激進,它認為網絡的職責應該盡可能簡單——就是高效、靈活地傳輸數據報。所有其他復雜功能都應該放在網絡的邊緣(端點)去實現。這樣做的好處是保持了網絡核心的簡潔和靈活性,能夠促進上層應用的創新。
數據的“指紋”:錯誤檢測機制
錯誤檢測 (Error Detection) 是闡述端到端原則最初的經典例子。以太網(鏈路層)使用強大的 CRC 校驗,TCP(傳輸層)使用自己的校驗和。但即便如此,數據在從路由器內存讀出寫入的過程中,仍可能發生錯誤。因此,為了確保萬無一失,像 BitTorrent 這樣的應用程序在接收完所有 TCP 數據后,依然會對每個數據塊進行一次哈希校驗。這完美體現了端到端原則——最終的正確性必須由端點應用來負責。
為了檢測數據在傳輸過程中是否被篡改,協議棧在不同層級使用了多種錯誤檢測算法。
- 校驗和 (Checksum) :TCP/IP 協議棧廣泛使用的一種簡單算法。它通過對數據進行反碼求和來計算出一個值。它的優點是計算速度快,軟件實現容易,但缺點是錯誤檢測能力較弱。
- 循環冗余校驗 (Cyclic Redundancy Code, CRC) :一種基于多項式除法的強大錯誤檢測碼。它的計算相對復雜,但硬件實現非常高效。CRC 能夠檢測出絕大多數常見的傳輸錯誤,因此被以太網、Wi-Fi 等鏈路層協議廣泛采用。
- 消息認證碼 (Message Authentication Code, MAC) :這不僅是用于檢測隨機錯誤,更是為了 安全 。它通過將數據和一個共享的密鑰結合起來計算出一個“指紋”。只有擁有密鑰的通信雙方才能生成或驗證這個 MAC。任何對數據的惡意篡改都會導致 MAC 驗證失敗。我們熟知的 TLS/SSL 就使用了這種機制來保證數據完整性。
TCP 的生命周期:深入理解 TCP 狀態機
在上面的文章中,我們探討了 TCP 如何通過三次握手和四次揮手來建立和終止連接。但這些“握手”和“揮手”并非孤立事件,它們是一系列嚴謹狀態轉換的一部分。控制這一系列轉換的“規則手冊”,就是 TCP 狀態機 (TCP State Machine) 。
每一個 TCP 連接,從誕生到消亡,都嚴格遵循這個狀態機定義的路徑。它不是一個抽象的理論模型,而是實實在在運行在全球數十億設備中的代碼邏輯,是保證不同廠商、不同系統的設備能夠無誤溝通的基石。
理解 TCP 狀態機,不僅能幫助我們深入領會 TCP 的可靠性精髓,還能在網絡故障排查、程序性能優化時,為我們提供強有力的理論武器。例如,當你在服務器上用 netstat 命令看到大量處于 TIME_WAIT 或 CLOSE_WAIT 狀態的連接時,你就能立刻明白這意味著什么。
宏觀藍圖:TCP 狀態轉換圖
首先,讓我們看一下 TCP 狀態機的全貌。這張圖囊括了 TCP 連接生命周期中所有可能的狀態和轉換路徑。初看可能有些復雜,但別擔心,我們會一步步分解它。

我們將沿著最常見的路徑——連接建立、數據傳輸、連接關閉——來詳細解讀這個生命周期。
第一幕:連接建立(三次握手)
連接的建立分為主動方(通常是客戶端)和被動方(通常是服務器)。
被動方(服務器)的視角
CLOSED -> LISTEN
- 觸發事件 : 應用程序在服務器上啟動,調用 listen() 系統調用,準備接受新的連接請求。
- 狀態描述 : 此時服務器還沒有收到任何連接請求,只是“豎起耳朵”在監聽指定的端口。這是連接生命的起點。
LISTEN -> SYN_RCVD (SYN Received)
- 觸發事件 : 服務器接收到一個來自客戶端的 SYN 報文段。
- 采取動作 : 服務器回復一個 SYN+ACK 報文段,確認客戶端的 SYN 并發送自己的 SYN。
- 狀態描述 : 服務器已經“收到同步信號”,并發送了響應,現在等待客戶端的最終確認。
SYN_RCVD -> ESTABLISHED (Established)
- 觸發事件 : 服務器接收到客戶端針對其 SYN+ACK 的 ACK 報文段(三次握手的最后一步)。
- 狀態描述 : 連接已成功建立!雙方現在可以自由地雙向傳輸數據。
主動方(客戶端)的視角
CLOSED -> SYN_SENT (SYN Sent)
- 觸發事件 : 應用程序在客戶端上發起連接請求,例如調用 connect()。
- 采取動作 : 客戶端發送一個 SYN 報文段給服務器。
- 狀態描述 : 客戶端已經“發送了同步信號”,現在等待服務器的響應。
SYN_SENT -> ESTABLISHED
- 觸發事件 : 客戶端接收到服務器的 SYN+ACK 報文段。
- 采取動作 : 客戶端回復一個 ACK 報文段。
- 狀態描述 : 在客戶端看來,發送完最后一個 ACK 后,連接就已建立。
第二幕:數據傳輸
ESTABLISHED
這是連接生命中最長、也是最有價值的階段。在這個狀態下,雙方的應用程序通過 send() 和 recv() 等系統調用,自由地交換數據。所有的數據傳輸、流量控制、擁塞控制都在這個穩定的狀態下進行。
示例 — 被動方收到 FIN -> CLOSE_WAIT(服務器端常見情況)
場景:客戶端正常關閉輸出方向(發送 FIN),服務器 recv() 返回 0(EOF),內核將套接字置為 CLOSE_WAIT,此時服務器的應用必須調用 close()(或 shutdown())來發送自己的 FIN,否則會一直處于 CLOSE_WAIT(常見泄漏 bug)。
/* server_close_wait_example.c
編譯: gcc server_close_wait_example.c -o server
*/
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main() {
int srv = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(9090);
addr.sin_addr.s_addr = INADDR_ANY;
bind(srv, (struct sockaddr*)&addr, sizeof(addr));
listen(srv, 1);
int c = accept(srv, NULL, NULL);
char buf[128];
ssize_t n = recv(c, buf, sizeof(buf), 0);
if (n == 0) {
// <-- recv() 返回 0:對端已發 FIN,套接字進入 CLOSE_WAIT
printf("peer closed write side (recv==0) -> socket is in CLOSE_WAIT\n");
// 此處如果程序忘記 close(c),套接字將持續處于 CLOSE_WAIT
} else if (n > 0) {
write(STDOUT_FILENO, buf, n);
}
// 正確做法:處理完剩余數據后關閉連接,發送自己的 FIN -> LAST_ACK 或直接 close 完成終止
fflush(stdout);
sleep(10);
close(c);
close(srv);
return 0;
}關鍵點
- recv() 返回 0 就是應用層收到“對方已經關閉發送方向”的信號,OS 狀態變成 CLOSE_WAIT。
- 如果應用不 close()(或 shutdown(SHUT_WR)),會長期占用 CLOSE_WAIT,可能導致服務器資源耗盡。
在我的 MAC 上,使用 Control + D 發送 FIN ,一共三個終端:
終端1> ./server
終端3> lsof -nP -iTCP:9090
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
server 75553 piperliu 3u IPv4 0x21a588ac71b533f4 0t0 TCP *:9090 (LISTEN)
終端2> nc 127.0.0.1 9090
終端3> lsof -nP -iTCP:9090
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
server 75553 piperliu 3u IPv4 0x21a588ac71b533f4 0t0 TCP *:9090 (LISTEN)
server 75553 piperliu 4u IPv4 0x8e56a7a57207245f 0t0 TCP 127.0.0.1:9090->127.0.0.1:51689 (ESTABLISHED)
nc 75970 piperliu 3u IPv4 0x9268cd499a63ea18 0t0 TCP 127.0.0.1:51689->127.0.0.1:9090 (ESTABLISHED)
終端2> ^D # Control + D
終端1> peer closed write side (recv==0) -> socket is in CLOSE_WAIT
終端3> lsof -nP -iTCP:9090
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
server 75553 piperliu 3u IPv4 0x21a588ac71b533f4 0t0 TCP *:9090 (LISTEN)
server 75553 piperliu 4u IPv4 0x8e56a7a57207245f 0t0 TCP 127.0.0.1:9090->127.0.0.1:51689 (CLOSE_WAIT)
nc 75970 piperliu 3u IPv4 0x9268cd499a63ea18 0t0 TCP 127.0.0.1:51689->127.0.0.1:9090 (FIN_WAIT_2)示例 — 主動方半關閉(client 發 FIN 后仍可讀):FIN_WAIT_1 -> FIN_WAIT_2 -> TIME_WAIT
場景:客戶端把發送方向關閉(shutdown(fd, SHUT_WR))但繼續讀服務器返回的數據;這是半關閉(half-close)的典型用法,適合客戶端發送完請求但仍需讀取結果的協議(HTTP/1.0 以前的部分場景)。
/* client_half_close_example.c
編譯: gcc client_half_close_example.c -o client
*/
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main() {
int s = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in srv = {0};
srv.sin_family = AF_INET;
srv.sin_port = htons(9090);
inet_pton(AF_INET, "127.0.0.1", &srv.sin_addr);
connect(s, (struct sockaddr*)&srv, sizeof(srv));
const char *req = "hello, server\n";
send(s, req, strlen(req), 0); // 發送數據
shutdown(s, SHUT_WR); // <-- 發送 FIN(進入 FIN_WAIT_1)
// 一旦收到對方 ACK,會進入 FIN_WAIT_2
// 仍然可以 recv() 直到 peer 發 FIN(然后我們回復最后的 ACK;進入 TIME_WAIT)
char buf[256];
ssize_t n;
while ((n = recv(s, buf, sizeof(buf), 0)) > 0) {
write(STDOUT_FILENO, buf, n);
}
// 當對端發 FIN 時,recv 返回 0,內核會把我們放到 TIME_WAIT(在完整握手序列中)
close(s);
return 0;
}關鍵點
- shutdown(SHUT_WR):應用告知內核“我不會再發數據了”,內核發送 FIN(到 FIN_WAIT_1)。
- 在 FIN_WAIT_2 階段還能 recv(),直到對方關閉其發送方向并觸發 TIME_WAIT。
- TIME_WAIT 的存在有兩個目的:保證最后 ACK 可重傳 & 防止舊數據干擾新連接(2 * MSL)。
第三幕:連接關閉(四次揮手)
與連接建立類似,關閉連接也分為主動方和被動方。任何一方都可以是主動關閉方。
主動關閉方(例如,客戶端)的視角
ESTABLISHED -> FIN_WAIT_1
- 觸發事件 : 客戶端應用程序調用 close(),決定關閉連接。
- 采取動作 : 客戶端發送一個 FIN 報文段,表示“我的數據已經發完了”。
- 狀態描述 : 客戶端進入等待狀態,等待服務器對其 FIN 的確認。
FIN_WAIT_1 -> FIN_WAIT_2
- 觸發事件 : 客戶端收到了服務器對其 FIN 的 ACK。
- 狀態描述 : 此時,從客戶端到服務器方向的連接已經關閉,這被稱為 半關閉 (half-close) 狀態??蛻舳瞬荒茉侔l送數據,但仍然可以接收來自服務器的數據??蛻舳爽F在等待服務器發送它自己的 FIN。
FIN_WAIT_2 -> TIME_WAIT
- 觸發事件 : 客戶端終于收到了服務器的 FIN 報文段。
- 采取動作 : 客戶端回復最后一個 ACK。
- 狀態描述 : 客戶端進入 TIME_WAIT 狀態。這是整個狀態機中最關鍵和最常被問到的狀態之一。
TIME_WAIT:優雅退場的藝術
TIME_WAIT 狀態,也稱為 2MSL 等待狀態 ,其存在有兩個至關重要的原因:
- 保證可靠的關閉 : 為了確保最后一個 ACK 能夠成功到達服務器。如果這個 ACK 在網絡中丟失,服務器將收不到確認,會超時重傳它的 FIN。此時,處于 TIME_WAIT 狀態的客戶端仍然可以接收到這個重傳的 FIN 并重新發送 ACK,從而保證服務器能夠正常關閉。
- 防止舊連接的干擾 : 一個連接由一個四元組(源IP,源端口,目的IP,目的端口)唯一確定。在 TIME_WAIT 狀態持續的 2倍最大段生存時間 (2 * MSL) 內,這個四元組不能被用于建立新的連接。這可以防止網絡中可能存在的、來自舊連接的延遲數據包(“迷路”的報文)被新連接錯誤地接收,從而保證了新連接的純凈。
被動關閉方(例如,服務器)的視角
ESTABLISHED -> CLOSE_WAIT
- 觸發事件 : 服務器接收到客戶端的 FIN 報文段。
- 采取動作 : 服務器回復一個 ACK,并通知上層應用程序:對方已經關閉了連接。
- 狀態描述 : 服務器進入 CLOSE_WAIT 狀態。這個狀態的含義是“等待應用程序關閉連接”。如果一個服務器上有大量連接處于 CLOSE_WAIT 狀態,通常意味著應用程序存在 bug——它沒有在接收到連接關閉信號后,及時調用 close() 來關閉自己的寫通道。
CLOSE_WAIT -> LAST_ACK
- 觸發事件 : 服務器端的應用程序在處理完剩余數據后,也調用 close()。
- 采取動作 : 服務器發送自己的 FIN 報文段。
- 狀態描述 : 服務器等待來自客戶端的最后一次 ACK。
LAST_ACK -> CLOSED
- 觸發事件 : 服務器收到了來自客戶端的最后一個 ACK。
- 狀態描述 : 連接徹底關閉,資源被釋放。
示例 — 同時關閉 -> CLOSING
場景:雙方幾乎同時調用 close()(或都先 shutdown(SHUT_WR) 然后 send FIN),就可能出現 CLOSING 狀態:雙方都發送了 FIN,一方收到了對方的 FIN,但尚未收到對方對自己 FIN 的 ACK(罕見但可能發生)。
下面是兩端幾乎同時 shutdown(SHUT_WR) 的示意代碼(實測難以完全同步,示例用于說明意圖):
/* pseudo-simultaneous-close.c (示意) */
// A: send FIN
shutdown(sockA, SHUT_WR); // A -> FIN
// B: almost same time
shutdown(sockB, SHUT_WR); // B -> FIN
// 如果 A 在等待來自 B 的 ACK 時收到了 B 的 FIN -> A 進入 CLOSING關鍵點
- CLOSING 是短暫、中介的狀態,只在特定并發關閉的時序下出現。
- 大多數代碼無需專門處理 CLOSING,只要按正確順序 close()/shutdown() 即可。
示例 — RST(abortive close):如何讓連接立刻變為 CLOSED(“暴力關閉”)并產生 RST
場景:有時我們想要立即釋放資源,不想等待內核緩沖區或四次揮手超時(例如發生嚴重錯誤)??梢酝ㄟ^ SO_LINGER 配合 close() 強制發送 RST(中斷連接)。
/* abortive_close.c */
#include <sys/socket.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
void abortive_close(int fd) {
struct linger ling;
ling.l_onoff = 1;
ling.l_linger = 0; // 啟用 linger, linger=0 表示 close() 立即發送 RST
setsockopt(fd, SOL_SOCKET, SO_LINGER, &ling, sizeof(ling));
close(fd); // 立刻產生 RST,連接對端會立即收到 reset 并進入 CLOSED
}關鍵點
- 使用 SO_LINGER 且 l_linger == 0 時 close() 觸發 RST,不會按正常 FIN 順序。
- RST 會立即釋放雙方資源,但也可能導致對端發生錯誤(例如 ECONNRESET 或 SIGPIPE)。
示例 — TIME_WAIT 與端口重用:SO_REUSEADDR / SO_REUSEPORT 行為提示
當一個 socket 在 TIME_WAIT 中,通常不能立即被同一四元組重用。為了解決服務端快速重啟的問題,常設 SO_REUSEADDR(注意:這不是萬能鑰匙,語義依實現而異)。
/* server_reuseaddr.c 片段 */
int srv = socket(AF_INET, SOCK_STREAM, 0);
int on = 1;
setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
bind(srv, (struct sockaddr*)&addr, sizeof(addr));
listen(srv, 128);注意
- SO_REUSEADDR 允許 bind 到處于 TIME_WAIT 的端口上(在很多系統上可以),但并不改變 TIME_WAIT 的存在 — 它只是影響 bind 行為。
- 在高并發短連接服務中,也可以使用端口池或 ephemeral port 的方式減輕 TIME_WAIT 壓力(或使用負載均衡把連接分散到多臺后端)。
特殊情況
- CLOSING 狀態 : 一個比較罕見的狀態。它發生在雙方幾乎同時發送 FIN 的情況下。例如,A 發送 FIN 后,在等待 B 的 ACK 期間,卻收到了 B 發來的 FIN。此時,A 就會進入 CLOSING 狀態,等待 B 對自己 FIN 的 ACK。
- RST (Reset) : 除了優雅的 FIN 揮手,TCP 還有一種“暴力”的關閉方式——發送 RST 報文段。這會直接中斷連接,使狀態立即跳轉到 CLOSED。常見于向一個未監聽的端口發起連接,或是一方異常退出時。
CLOSE_WAIT 泄漏示例
# netstat -tn | grep CLOSE_WAIT | wc -l
350如果你在服務器上看到大量 CLOSE_WAIT,通常說明應用端未及時調用 close()。用 lsof -i :port 或 ss -tanp | grep CLOSE_WAIT 找到對應 PID,檢查是否忘記在處理完 recv() == 0 后關閉描述符(或在異常路徑中漏掉 close)。
TIME_WAIT 普遍存在
# netstat -an | grep TIME_WAIT | wc -l
1200TIME_WAIT 數量大通常是短連接頻繁建立/關閉的天然結果,不是內核異常??煽紤]:
- 使用長連接(keep-alive)。
- 復用連接(連接池)。
- 如果合理,可調整內核 tcp 參數(例如 Linux 的 tcp_tw_reuse、tcp_fin_timeout),但生產環境要小心。
如果 send 后收到 SIGPIPE
- 這通常發生于對端已 close(或 RST)并且應用繼續 write()。解決:忽略 SIGPIPE(signal(SIGPIPE, SIG_IGN))或在 send() 中使用 MSG_NOSIGNAL。
小結:把 C 代碼的行為映射回狀態機
- connect()(客戶端) -> SYN_SENT;收到 SYN+ACK 并 ACK -> ESTABLISHED。
- listen() -> LISTEN;accept() 后收到 SYN -> SYN_RCVD -> ESTABLISHED。
- shutdown(fd, SHUT_WR) 或 close()(發送方) -> 內核發送 FIN -> FIN_WAIT_1。
- recv() == 0(被動方) -> 收到對端 FIN,內核把 socket 標記為 CLOSE_WAIT;必須 close() 才會發送自己的 FIN。
- SO_LINGER with l_linger=0 + close() -> 發送 RST,立即 CLOSED(暴力釋放)。
- 同時 FIN -> 可能出現 CLOSING(罕見)。
- 等待 2 * MSL 后 TIME_WAIT -> CLOSED(保證最后 ACK 可重傳 & 防止舊報文干擾)。

























