與排錯)
做了這么多年的后端開發(fā)我?guī)缀趺扛魩滋炀蜁煌禄蛘咦x者問同一個問題TCP/IP協(xié)議到底該怎么學socket網(wǎng)絡編程又該怎么寫說真的這兩件事從來就不是割裂的——TCP/IP 是網(wǎng)絡通信的規(guī)則socket 是代碼里握住這套規(guī)則的那只手。誰先理解了這兩者的關(guān)系誰就已經(jīng)繞開了大半的網(wǎng)絡編程坑。今天我把從原理到代碼的完整套路拆出來不講廢話只講實操適合正在學 TCP/IP、socket或者已經(jīng)被“端口被占用”這類報錯折磨過的人。這份內(nèi)容會覆蓋三個層面先講原理幫你把 TCP/IP 和 socket 的對應關(guān)系焊死在腦子里再講代碼用 C# 和 Python 寫一套最小可用的服務端和客戶端然后講排錯把熱詞里反復出現(xiàn)的幾個報錯逐個拆解。最后我還會補充 C 語言的實現(xiàn)要點和 VBA 調(diào) Winsock 的另類玩法這也是很多人實際項目中會遇到的需求。1. 先理清 TCP/IP 和 socket 到底是干嘛的1.1 網(wǎng)絡分層不是八股文是排查問題的地圖很多人一看到“TCP/IP 四層模型”就頭大覺得這是面試題跟寫代碼沒關(guān)系。其實完全不是這樣。你寫 socket 代碼時遇到的每一個參數(shù)、每一個報錯都能在分層模型里找到位置。我習慣用一個寄快遞的類比來講應用層你寫的內(nèi)容比如一段 JSON 數(shù)據(jù)。傳輸層快遞單上面寫著“從哪個端口來到哪個端口去”——TCP 端口就是干這個的。網(wǎng)絡層物流路線規(guī)劃決定包裹怎么從 A 城市到 B 城市——IP 地址就是干這個的。鏈路層快遞車實際跑的公路和高速公路。你寫 socket 的時候其實是在傳輸層和應用層的交界處工作。你不需要關(guān)心數(shù)據(jù)怎么走物理鏈路那是 IP 層和鏈路層的事你要關(guān)心的是數(shù)據(jù)怎么可靠地到達對方應用的某個端口這是 TCP 負責的事。而 socket 就是操作系統(tǒng)提供給你的一扇門你往門里扔數(shù)據(jù)TCP 會幫你打包好、按序發(fā)送、丟失重傳你從門里取數(shù)據(jù)TCP 已經(jīng)把對方發(fā)來的字節(jié)流按順序放在了門口。這就能解釋為什么很多人學 socket 一開始痛苦你明明只是想發(fā)一條消息結(jié)果要寫 bind、listen、accept、connect、recv、send 一堆函數(shù)。其實每個函數(shù)對應的是 TCP 狀態(tài)機的一個階段理解了這個映射代碼就不再是死記硬背。1.2 端口占用的本質(zhì)四元組決定了誰能用這個地址熱詞里有一個非常經(jīng)典的報錯Windows Socket Error: 通常每個套接字地址(協(xié)議/網(wǎng)絡地址/端口)只允許使用一次。這個報錯出現(xiàn)的原因一點都不神秘。TCP 連接是由一個四元組唯一確定的也就是源 IP、源端口、目的 IP、目的端口。而你要啟動一個服務必須綁定一個 IP 和端口相當于在自己家門前掛一個門牌號。如果這個門牌號已經(jīng)被別人掛了你就掛不上去。端口是一個 16 位的整數(shù)范圍是 0 到 65535。其中 0 到 1023 是特權(quán)端口一般需要管理員權(quán)限才能綁定1024 到 49151 是注冊端口49152 到 65535 是動態(tài)端口是客戶端臨時連接時用的。你在本地開發(fā)時8080、443、3306 這些端口太常用了稍微不留神就會沖突。我之前遇到過一種特別隱蔽的情況服務明明已經(jīng)退出了但端口還是被占著。原因就是 TCP 的 TIME_WAIT 狀態(tài)。主動關(guān)閉連接的一方在發(fā)送最后一個 ACK 之后并不會立刻釋放連接而是要等 2MSL最大報文段生存時間的時間確認網(wǎng)絡上殘留的數(shù)據(jù)包都已經(jīng)消失。這個狀態(tài)下的端口看起來還是被占用的但其實是協(xié)議棧自己在“打掃衛(wèi)生”。這也是為什么很多服務端程序在 bind 之前都要先設置SO_REUSEADDR。它告訴系統(tǒng)如果 TIME_WAIT 狀態(tài)占了端口不用擔心可以復用。1.3 三次握手和四次揮手在代碼里對應什么TCP 三次握手是所有教材都會講的內(nèi)容但很少有人告訴你握手不是你在代碼里寫出來的而是內(nèi)核幫你完成的。你調(diào)用connect()時內(nèi)核開始發(fā)起三次握手。你調(diào)用accept()時內(nèi)核已經(jīng)完成了握手把建立好的連接放在隊列里等著你取。你調(diào)用close()時內(nèi)核開始四次揮手。所以說白了socket API 不是 TCP 本身而是 TCP 狀態(tài)的“遙控器”。你按一下 connectTCP 開始握手你按一下 closeTCP 開始揮手。你在代碼里看不到 SYN、SYN-ACK、ACK 這些報文除非你用 Wireshark 抓包。但是理解握手對排查問題非常有用。比如你發(fā)現(xiàn)客戶端 connect 卡了很久才失敗那很可能是網(wǎng)絡不通SYN 發(fā)出去沒人回如果客戶端 connect 直接返回“連接被拒絕”那說明目標主機收到了 SYN但那個端口根本沒有進程在 listen。這兩種情況在現(xiàn)象上都是“連不上”但排查方向完全不同。前者查路由、防火墻、丟包后者查服務端進程到底有沒有啟動。四次揮手里最值得關(guān)注的也是 TIME_WAIT。每次主動關(guān)閉連接的一方都要等 2MSL。如果服務端頻繁主動斷開連接你會發(fā)現(xiàn)大量 TIME_WAIT 狀態(tài)的連接堆積端口被暫時占滿新連接就進不來了。后面講排錯的時候我會再展開。2. 從零寫一個最簡 TCP 服務端和客戶端2.1 為什么我用 C# 寫服務端用 Python 寫客戶端寫 TCP 示例語言選擇其實很講究。我選了 C# 和 Python 的組合原因有三個第一熱詞里出現(xiàn)了“C# socket receiving receive 回調(diào)”說明很多人確實在用 C# 做網(wǎng)絡服務尤其是做桌面工具、上位機、內(nèi)部服務的人特別多。C# 的 socket API 封裝得比較順手寫起來不像 C 語言那么繁瑣但底層模型又能看得清。第二Python 的 socket 模塊是標準庫三行代碼就能起一個連接。它非常適合快速驗證服務端的行為不用編譯、不用 NuGet 包適合初學者做實驗。第三這兩個語言的 API 幾乎能一對一對應上。你把 Python 的socket.socket()換成 C# 的new Socket()把bind換成Bind把recv換成Receive概念完全一致。語言差異不會成為理解 TCP 的障礙反而能讓你看清楚哪些東西是 socket 共通的。2.2 C# 服務端同步阻塞版完整代碼我用的是 .NET 8控制臺項目核心代碼就這么一段using System.Net; using System.Net.Sockets; using System.Text; TcpListener listener new TcpListener(IPAddress.Any, 8080); listener.Start(5); Console.WriteLine(服務端已啟動監(jiān)聽端口 8080 ...); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); Console.WriteLine($客戶端接入{client.Client.RemoteEndPoint}); _ HandleClientAsync(client); } async Task HandleClientAsync(TcpClient client) { using (client) using (NetworkStream stream client.GetStream()) { byte[] buffer new byte[1024]; while (true) { int length await stream.ReadAsync(buffer, 0, buffer.Length); if (length 0) { Console.WriteLine(客戶端斷開連接。); break; } string message Encoding.UTF8.GetString(buffer, 0, length); Console.WriteLine($收到{message}); byte[] resp Encoding.UTF8.GetBytes(服務器已收到: message); await stream.WriteAsync(resp); } } }這里有幾個點值得解釋。IPAddress.Any表示監(jiān)聽本機所有網(wǎng)卡地址。這在本地開發(fā)時看著無所謂但如果你只監(jiān)聽127.0.0.1那局域網(wǎng)里的其他機器就永遠連不進來如果你監(jiān)聽IPAddress.Any那所有網(wǎng)卡 IP 都能連。生產(chǎn)環(huán)境里這是第一個要明確的參數(shù)。listener.Start(5)里的數(shù)字是 accept 隊列的長度不是最大連接數(shù)。它的意思是TCP 完成握手但還沒被AcceptTcpClientAsync取走的連接最多能排幾個隊。隊列滿了之后新的連接請求就會被內(nèi)核拒絕。這個參數(shù)對高并發(fā)服務有影響但對本文的測試場景5 已經(jīng)夠用。2.3 Python 客戶端三行連上去import socket client socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect((127.0.0.1, 8080)) client.sendall(你好我是 Python 客戶端.encode(utf-8)) resp client.recv(1024) print(resp.decode(utf-8)) client.close()注意sendall和send的區(qū)別。send可能只發(fā)送了一部分數(shù)據(jù)就返回了它返回的是實際發(fā)送的字節(jié)數(shù)sendall會循環(huán)發(fā)送直到所有數(shù)據(jù)都發(fā)出去。在 TCP 這種字節(jié)流協(xié)議里永遠不要假設“一次 send 就發(fā)完了所有數(shù)據(jù)”這是新手最容易踩的坑之一。2.4 一次完整的調(diào)試過程我最推薦的試驗順序是先啟動 C# 服務端再運行 Python 客戶端。服務端會打印出客戶端的 IP 和端口客戶端會收到回顯。這里有個非常直觀的體驗你會發(fā)現(xiàn)客戶端connect()連接成功的瞬間服務端才打印“客戶端接入”。但實際上三次握手在connect()返回之前就已經(jīng)完成了。也就是說connect 成功 握手完成 服務端 accept 隊列里已經(jīng)有這個連接了。如果你在服務端收到了客戶端的數(shù)據(jù)但代碼里ReadAsync讀出來的卻是亂碼那 99% 是編碼問題。我習慣全鏈路統(tǒng)一 UTF-8。C# 里是Encoding.UTF8Python 里是encode(utf-8)/decode(utf-8)。兩邊不一致中文幾乎必亂。3. 別在 recv 里死等BeginReceive 回調(diào)的標準寫法3.1 同步接收為什么會讓程序“卡死”如果你是做 WinForms、WPF 客戶端程序的一定體驗過界面卡死的痛苦。原因很簡單你在 UI 線程里調(diào)用了Receive()或者ReadAsync()但那個線程又沒有真的異步操作它只能在那里等數(shù)據(jù)。UI 線程被占住界面自然就動不了了。就算你用了async/await只要上下文是在 UI 線程上恢復的也會出現(xiàn)類似的問題。最穩(wěn)妥的做法是用回調(diào)模型你告訴 socket “數(shù)據(jù)到了你就叫我”然后 UI 線程繼續(xù)該干嘛干嘛數(shù)據(jù)到了之后系統(tǒng)在后臺線程池里執(zhí)行你的回調(diào)函數(shù)。這也是熱詞里“C# socket receiving receive 回調(diào)”指向的核心內(nèi)容。下面我給出一個實戰(zhàn)中常用的模式。3.2 BeginReceive 回調(diào)的標準套路public class TcpEchoClient { private Socket _socket; private byte[] _buffer new byte[4096]; private readonly StringBuilder _receivedData new StringBuilder(); public void Start(Socket socket) { _socket socket; ReceiveLoop(); } private void ReceiveLoop() { try { _socket.BeginReceive(_buffer, 0, _buffer.Length, SocketFlags.None, ReceiveCallback, null); } catch (SocketException ex) { Console.WriteLine($接收啟動失敗{ex.SocketErrorCode}); } } private void ReceiveCallback(IAsyncResult ar) { int length; try { length _socket.EndReceive(ar); } catch (SocketException ex) { Console.WriteLine($連接異常{ex.SocketErrorCode}); return; } if (length 0) { string data Encoding.UTF8.GetString(_buffer, 0, length); _receivedData.Append(data); Console.WriteLine($收到{data}); ReceiveLoop(); } else { Console.WriteLine(對端關(guān)閉連接。); _socket.Close(); } } }這個模式有三個關(guān)鍵點。第一_buffer必須是類的字段不能是局部變量。因為BeginReceive是異步的你傳入的緩沖區(qū)如果被垃圾回收了回調(diào)執(zhí)行時會出大問題。把它放在類字段里等于告訴 GC這塊內(nèi)存我還用著。第二回調(diào)結(jié)束前必須啟動下一次BeginReceive。TCP 是持續(xù)流你收完一包數(shù)據(jù)后如果不馬上再掛一次接收回調(diào)后續(xù)的數(shù)據(jù)就沒人接收了。這里最容易踩的坑是忘記在回調(diào)里繼續(xù)調(diào)用ReceiveLoop()結(jié)果只能收到第一段數(shù)據(jù)。第三用EndReceive獲取實際長度。很多人錯誤地直接用_buffer.Length作為有效數(shù)據(jù)的長度這是不行的。緩沖區(qū)長度是 4096但實際收到的可能只有 200 字節(jié)。EndReceive的返回值才是真實數(shù)據(jù)長度。3.3 粘包和半包繞不過去的坎寫 TCP 程序永遠會遇到粘包和半包。我在給新同事培訓時總喜歡用一個比喻來解釋TCP 是字節(jié)流不是消息流。它就像一條水管你在 A 端往里面扔了三個乒乓球在 B 端拿到的可能是兩個球、一個球、半顆球、三顆球粘在一起……總之TCP 不保證消息邊界。解決方案有很多我推薦最通用的一種長度頭。也就是在發(fā)送消息之前先發(fā)一個 4 字節(jié)的整數(shù)告訴對端這條消息有多長然后再發(fā)消息本身。接收端先讀 4 字節(jié)算出消息長度再讀對應長度的數(shù)據(jù)。這個方案的代碼并不復雜但它是所有 RPC 框架、消息中間件的基石。理解它之后你再看 Redis RESP 協(xié)議、HTTP 的 Content-Length會發(fā)現(xiàn)全都是一個套路。4. 進階Python/C#/C 三語言對照與 VBA 的另類玩法4.1 Python 寫 TCP 服務端最適合快速復現(xiàn)問題如果你不想每次測試都編譯一次 C# 程序Python 的socketserver模塊能讓你在 30 秒內(nèi)起一個多線程 TCP 服務from socketserver import ThreadingTCPServer, StreamRequestHandler class Handler(StreamRequestHandler): def handle(self): data self.rfile.readline() print(f收到{data.decode(utf-8).strip()}) self.wfile.write(bpong\n) server ThreadingTCPServer((0.0.0.0, 9000), Handler) server.serve_forever()這段代碼天然支持多客戶端并發(fā)。每次有客戶端連進來ThreadingTCPServer都會起一個新的線程處理。它特別適合用來驗證我的協(xié)議設計到底合不合理粘包問題到底出在客戶端還是服務端4.2 C 語言實現(xiàn)從原理到字節(jié)熱詞里出現(xiàn)了“tcp ip sockets 編程 c語言實現(xiàn)”如果你把 C# 和 Python 都跑通了再看 C 語言其實就那幾步。我用最精簡的方式列一下核心骨架#include stdio.h #include string.h #include unistd.h #include arpa/inet.h int main() { int server_fd; struct sockaddr_in addr; server_fd socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr INADDR_ANY; addr.sin_port htons(8080); bind(server_fd, (struct sockaddr*)addr, sizeof(addr)); listen(server_fd, 5); while (1) { struct sockaddr_in client_addr; socklen_t len sizeof(client_addr); int client_fd accept(server_fd, (struct sockaddr*)client_addr, len); char buffer[1024] {0}; int n read(client_fd, buffer, sizeof(buffer)); printf(收到: %s\n, buffer); write(client_fd, pong, 4); close(client_fd); } return 0; }C 語言最大的特點是所有參數(shù)都暴露在你面前。sockaddr_in結(jié)構(gòu)體里的sin_family、sin_addr.s_addr、sin_port對應著你在 C# 里寫的AddressFamily、IPAddress、Port。htons這個函數(shù)也特別有意思它把主機字節(jié)序轉(zhuǎn)成網(wǎng)絡字節(jié)序。因為不同 CPU 的字節(jié)序不一樣TCP/IP 協(xié)議規(guī)定網(wǎng)絡上統(tǒng)一用大端所以你在手動填充端口號時必須轉(zhuǎn)換。很多人覺得 C 語言難其實難的不是 API而是內(nèi)存和指針。但正因為難寫過一次 C 版本之后你再看任何語言的 socket 接口都會覺得非常輕松。4.3 VBA 用 Winsock 調(diào) Telnet別笑真有這種需求熱詞里還有一條非常罕見的“VBA 代碼實現(xiàn) telnet 的 window socket 調(diào)用”。你可能覺得 VBA 都是用來寫 Excel 宏的怎么會跟網(wǎng)絡編程扯上關(guān)系實際情況是有些老舊的工控系統(tǒng)、路由器管理接口、儀器設備只提供 Telnet 或者裸 TCP 接口。企業(yè)里又沒有專門的 IT 團隊業(yè)務人員只能用 Excel 做數(shù)據(jù)收集。這時候有人會用 VBA 的 Winsock 控件來實現(xiàn) TCP 通信。思路是這樣在 VBA 工程里插入MSWinsock.Winsock控件然后Winsock1.RemoteHost 192.168.1.100 Winsock1.RemotePort 23 Winsock1.Connect 在 DataArrival 事件中接收數(shù)據(jù) Private Sub Winsock1_DataArrival(ByVal bytesTotal As Long) Dim strData As String Winsock1.GetData strData Debug.Print strData End Sub你會發(fā)現(xiàn)控件的Connect、DataArrival、GetData本質(zhì)上就是 socket 的connect、recv、read。它的事件驅(qū)動模型和 C# 的BeginReceive回調(diào)是同一個思路。所以我一直覺得socket 編程不局限于語言理解了模型走到哪個平臺都認得它。5. 故障排查綁定失敗、防火墻、連接被重置5.1 端口被占用failed to create server shutdown socket 報錯實戰(zhàn)記錄熱詞里有一整條錯誤信息格式是“failed to create server shutdown socket on address [localhost] and port [802…]”。這個報錯看起來很長很嚇人但本質(zhì)上就一句話這個地址和端口已經(jīng)被占用了操作系統(tǒng)不讓你重復綁定。我在本機復現(xiàn)過這個錯誤。我先起了一個 Java 應用監(jiān)聽 8020 端口然后在沒有停掉它的情況下又啟動 Tomcat日志里就出現(xiàn)了failed to create server shutdown socket on address [localhost] and port [8020]。為什么叫 shutdown socket因為 Tomcat 默認會開一個本地 socket用來監(jiān)聽 shutdown 命令。它綁定失敗只影響關(guān)閉指令但應用還能正常啟動所以很多人會忽略這個警告。不過后面你真正想關(guān)閉服務時就會發(fā)現(xiàn)關(guān)不掉——因為那個監(jiān)聽 shutdown 的 socket 根本沒起來。遇到端口占用不要猜直接用命令查系統(tǒng)查端口命令查進程命令殺進程命令Windowsnetstat -ano | findstr 8080tasklist | findstr PIDtaskkill /F /PID PIDLinuxss -lntp | grep 8080或lsof -i:8080ps -ef | grep PIDkill -9 PID查出來之后如果那個進程確實沒用了直接殺掉再重啟你的服務問題立刻解決。5.2 連接還活著但端口報錯TIME_WAIT 和 SO_REUSEADDR還有一種情況比上面的更隱蔽。服務端代碼里有個 bug導致連接釋放不正?;蛘邷y試腳本頻繁地 connect、close你會看到 error 信息是“通常每個套接字地址(協(xié)議/網(wǎng)絡地址/端口)只允許使用一次”。這時候用netstat -ano查看端口狀態(tài)你可能會看到一堆TIME_WAIT。這些連接還沒有完全死透端口暫時被占。這個問題有兩個解決方向。第一在代碼里設置SO_REUSEADDR。C 語言里就是setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt))C# 里用SocketOptionName.ReuseAddressPython 里就是s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)。這個選項告訴內(nèi)核如果端口上殘留的是 TIME_WAIT 狀態(tài)的連接可以復用。第二在客戶端也盡量避免頻繁地創(chuàng)建和關(guān)閉連接。我見過有些測試程序在循環(huán)里對同一個服務不斷 connect/close壓測沒做多少先把端口搞滿了。這種場景下要么用持久連接要么把 TIME_WAIT 的回收參數(shù)調(diào)快。5.3 服務明明啟動了外部卻連不上監(jiān)聽地址和防火墻很多人本地測試一切正常一到部署就給客戶報“連不上”。我排查這類問題有固定順序。先確認監(jiān)聽地址。netstat -ano看服務端監(jiān)聽的本地地址。如果監(jiān)聽的是127.0.0.1:8080那只有本機能連如果監(jiān)聽的是0.0.0.0:8080才是所有網(wǎng)卡都可以連。多網(wǎng)卡服務器上這個區(qū)別尤其明顯。再看防火墻。Windows 上第一次啟動 TCP 服務時系統(tǒng)通常會彈窗詢問是否允許通信。如果你點了取消或者部署時根本沒注意到這個彈窗外部連接就會被 Windows 防火墻靜默阻斷。Linux 服務器則要檢查firewalld或ufw的規(guī)則。最后看云服務商的安全組。這是個特別容易被人忽略的點。很多云服務器默認只開放少數(shù)端口你在服務器內(nèi)部怎么測都通但外部就是進不來問題就在安全組規(guī)則里沒有放行對應的端口。5.4 連接被重置Recv 返回 0 和 SocketException 的區(qū)別還有一個經(jīng)驗分享想寫出來。TCP 斷開有兩種情況如果對端正常關(guān)閉recv/Receive會返回 0。在 C# 的BeginReceive回調(diào)里EndReceive返回 0 就是正常關(guān)閉。如果對端異常退出、斷電、程序崩潰你這邊會收到一個SocketException錯誤碼通常是ConnectionReset。它的含義是對端發(fā)了一個 RST 報文把連接強行重置了。這兩種情況在業(yè)務代碼里一定要區(qū)別處理。前者是“對方說再見了”后者是“對方突然消失”。如果你不區(qū)分把 RST 也當作正常斷開那你的服務端可能永遠不知道對端已經(jīng)異常退出還會繼續(xù)占著資源和內(nèi)存。6. 最后聊點實際的寫到這里核心內(nèi)容已經(jīng)講完了。最后說幾句經(jīng)驗之談。我調(diào)網(wǎng)絡程序有個習慣服務端代碼里第一件事就加上 SO_REUSEADDR不管什么語言先把這個“端口復用”選項打開。它不會帶來副作用但在你頻繁重啟服務、遇到 TIME_WAIT 時能幫你省掉一晚上排查時間。第二個習慣是寫代碼前先用 Python 把協(xié)議跑通再換 C# 或 C 去落地。Python 足夠快足夠簡單能讓你把精力聚焦在協(xié)議本身上而不是被語言特性絆住腳。我做的很多項目都是先用 Python 腳本模擬客戶端再拿 C# 寫正式服務端這套流程非常穩(wěn)。最后一個建議一定要學會看抓包。Wireshark 裝上不難難的是你肯不肯在“為什么收不到數(shù)據(jù)”的時候打開抓包軟件看一眼。三次握手有沒有完成SYN 有沒有發(fā)出ACK 有沒有回來這些問題的答案全在數(shù)據(jù)包里。代碼不會騙你報文更不會騙你。TCP/IP 和 socket 這條路說難也難說簡單也簡單。難在你如果只看理論就永遠隔著一層紗簡單在你只要動手寫過一兩個服務端和客戶端很多概念自然就通了。希望這篇能幫你把那層紗捅破。