ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

TCP/IP与Socket编程:从原理到C#/Python/C语言实现与排错

TCP/IP与Socket编程:从原理到C#/Python/C语言实现与排错 做了这么多年的后端开发我几乎每隔几天就会被同事或者读者问同一个问题TCP/IP协议到底该怎么学socket网络编程又该怎么写说真的这两件事从来就不是割裂的——TCP/IP 是网络通信的规则socket 是代码里握住这套规则的那只手。谁先理解了这两者的关系谁就已经绕开了大半的网络编程坑。今天我把从原理到代码的完整套路拆出来不讲废话只讲实操适合正在学 TCP/IP、socket或者已经被“端口被占用”这类报错折磨过的人。这份内容会覆盖三个层面先讲原理帮你把 TCP/IP 和 socket 的对应关系焊死在脑子里再讲代码用 C# 和 Python 写一套最小可用的服务端和客户端然后讲排错把热词里反复出现的几个报错逐个拆解。最后我还会补充 C 语言的实现要点和 VBA 调 Winsock 的另类玩法这也是很多人实际项目中会遇到的需求。1. 先理清 TCP/IP 和 socket 到底是干嘛的1.1 网络分层不是八股文是排查问题的地图很多人一看到“TCP/IP 四层模型”就头大觉得这是面试题跟写代码没关系。其实完全不是这样。你写 socket 代码时遇到的每一个参数、每一个报错都能在分层模型里找到位置。我习惯用一个寄快递的类比来讲应用层你写的内容比如一段 JSON 数据。传输层快递单上面写着“从哪个端口来到哪个端口去”——TCP 端口就是干这个的。网络层物流路线规划决定包裹怎么从 A 城市到 B 城市——IP 地址就是干这个的。链路层快递车实际跑的公路和高速公路。你写 socket 的时候其实是在传输层和应用层的交界处工作。你不需要关心数据怎么走物理链路那是 IP 层和链路层的事你要关心的是数据怎么可靠地到达对方应用的某个端口这是 TCP 负责的事。而 socket 就是操作系统提供给你的一扇门你往门里扔数据TCP 会帮你打包好、按序发送、丢失重传你从门里取数据TCP 已经把对方发来的字节流按顺序放在了门口。这就能解释为什么很多人学 socket 一开始痛苦你明明只是想发一条消息结果要写 bind、listen、accept、connect、recv、send 一堆函数。其实每个函数对应的是 TCP 状态机的一个阶段理解了这个映射代码就不再是死记硬背。1.2 端口占用的本质四元组决定了谁能用这个地址热词里有一个非常经典的报错Windows Socket Error: 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。这个报错出现的原因一点都不神秘。TCP 连接是由一个四元组唯一确定的也就是源 IP、源端口、目的 IP、目的端口。而你要启动一个服务必须绑定一个 IP 和端口相当于在自己家门前挂一个门牌号。如果这个门牌号已经被别人挂了你就挂不上去。端口是一个 16 位的整数范围是 0 到 65535。其中 0 到 1023 是特权端口一般需要管理员权限才能绑定1024 到 49151 是注册端口49152 到 65535 是动态端口是客户端临时连接时用的。你在本地开发时8080、443、3306 这些端口太常用了稍微不留神就会冲突。我之前遇到过一种特别隐蔽的情况服务明明已经退出了但端口还是被占着。原因就是 TCP 的 TIME_WAIT 状态。主动关闭连接的一方在发送最后一个 ACK 之后并不会立刻释放连接而是要等 2MSL最大报文段生存时间的时间确认网络上残留的数据包都已经消失。这个状态下的端口看起来还是被占用的但其实是协议栈自己在“打扫卫生”。这也是为什么很多服务端程序在 bind 之前都要先设置SO_REUSEADDR。它告诉系统如果 TIME_WAIT 状态占了端口不用担心可以复用。1.3 三次握手和四次挥手在代码里对应什么TCP 三次握手是所有教材都会讲的内容但很少有人告诉你握手不是你在代码里写出来的而是内核帮你完成的。你调用connect()时内核开始发起三次握手。你调用accept()时内核已经完成了握手把建立好的连接放在队列里等着你取。你调用close()时内核开始四次挥手。所以说白了socket API 不是 TCP 本身而是 TCP 状态的“遥控器”。你按一下 connectTCP 开始握手你按一下 closeTCP 开始挥手。你在代码里看不到 SYN、SYN-ACK、ACK 这些报文除非你用 Wireshark 抓包。但是理解握手对排查问题非常有用。比如你发现客户端 connect 卡了很久才失败那很可能是网络不通SYN 发出去没人回如果客户端 connect 直接返回“连接被拒绝”那说明目标主机收到了 SYN但那个端口根本没有进程在 listen。这两种情况在现象上都是“连不上”但排查方向完全不同。前者查路由、防火墙、丢包后者查服务端进程到底有没有启动。四次挥手里最值得关注的也是 TIME_WAIT。每次主动关闭连接的一方都要等 2MSL。如果服务端频繁主动断开连接你会发现大量 TIME_WAIT 状态的连接堆积端口被暂时占满新连接就进不来了。后面讲排错的时候我会再展开。2. 从零写一个最简 TCP 服务端和客户端2.1 为什么我用 C# 写服务端用 Python 写客户端写 TCP 示例语言选择其实很讲究。我选了 C# 和 Python 的组合原因有三个第一热词里出现了“C# socket receiving receive 回调”说明很多人确实在用 C# 做网络服务尤其是做桌面工具、上位机、内部服务的人特别多。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(服务端已启动监听端口 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表示监听本机所有网卡地址。这在本地开发时看着无所谓但如果你只监听127.0.0.1那局域网里的其他机器就永远连不进来如果你监听IPAddress.Any那所有网卡 IP 都能连。生产环境里这是第一个要明确的参数。listener.Start(5)里的数字是 accept 队列的长度不是最大连接数。它的意思是TCP 完成握手但还没被AcceptTcpClientAsync取走的连接最多能排几个队。队列满了之后新的连接请求就会被内核拒绝。这个参数对高并发服务有影响但对本文的测试场景5 已经够用。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的区别。send可能只发送了一部分数据就返回了它返回的是实际发送的字节数sendall会循环发送直到所有数据都发出去。在 TCP 这种字节流协议里永远不要假设“一次 send 就发完了所有数据”这是新手最容易踩的坑之一。2.4 一次完整的调试过程我最推荐的试验顺序是先启动 C# 服务端再运行 Python 客户端。服务端会打印出客户端的 IP 和端口客户端会收到回显。这里有个非常直观的体验你会发现客户端connect()连接成功的瞬间服务端才打印“客户端接入”。但实际上三次握手在connect()返回之前就已经完成了。也就是说connect 成功 握手完成 服务端 accept 队列里已经有这个连接了。如果你在服务端收到了客户端的数据但代码里ReadAsync读出来的却是乱码那 99% 是编码问题。我习惯全链路统一 UTF-8。C# 里是Encoding.UTF8Python 里是encode(utf-8)/decode(utf-8)。两边不一致中文几乎必乱。3. 别在 recv 里死等BeginReceive 回调的标准写法3.1 同步接收为什么会让程序“卡死”如果你是做 WinForms、WPF 客户端程序的一定体验过界面卡死的痛苦。原因很简单你在 UI 线程里调用了Receive()或者ReadAsync()但那个线程又没有真的异步操作它只能在那里等数据。UI 线程被占住界面自然就动不了了。就算你用了async/await只要上下文是在 UI 线程上恢复的也会出现类似的问题。最稳妥的做法是用回调模型你告诉 socket “数据到了你就叫我”然后 UI 线程继续该干嘛干嘛数据到了之后系统在后台线程池里执行你的回调函数。这也是热词里“C# socket receiving receive 回调”指向的核心内容。下面我给出一个实战中常用的模式。3.2 BeginReceive 回调的标准套路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(对端关闭连接。); _socket.Close(); } } }这个模式有三个关键点。第一_buffer必须是类的字段不能是局部变量。因为BeginReceive是异步的你传入的缓冲区如果被垃圾回收了回调执行时会出大问题。把它放在类字段里等于告诉 GC这块内存我还用着。第二回调结束前必须启动下一次BeginReceive。TCP 是持续流你收完一包数据后如果不马上再挂一次接收回调后续的数据就没人接收了。这里最容易踩的坑是忘记在回调里继续调用ReceiveLoop()结果只能收到第一段数据。第三用EndReceive获取实际长度。很多人错误地直接用_buffer.Length作为有效数据的长度这是不行的。缓冲区长度是 4096但实际收到的可能只有 200 字节。EndReceive的返回值才是真实数据长度。3.3 粘包和半包绕不过去的坎写 TCP 程序永远会遇到粘包和半包。我在给新同事培训时总喜欢用一个比喻来解释TCP 是字节流不是消息流。它就像一条水管你在 A 端往里面扔了三个乒乓球在 B 端拿到的可能是两个球、一个球、半颗球、三颗球粘在一起……总之TCP 不保证消息边界。解决方案有很多我推荐最通用的一种长度头。也就是在发送消息之前先发一个 4 字节的整数告诉对端这条消息有多长然后再发消息本身。接收端先读 4 字节算出消息长度再读对应长度的数据。这个方案的代码并不复杂但它是所有 RPC 框架、消息中间件的基石。理解它之后你再看 Redis RESP 协议、HTTP 的 Content-Length会发现全都是一个套路。4. 进阶Python/C#/C 三语言对照与 VBA 的另类玩法4.1 Python 写 TCP 服务端最适合快速复现问题如果你不想每次测试都编译一次 C# 程序Python 的socketserver模块能让你在 30 秒内起一个多线程 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()这段代码天然支持多客户端并发。每次有客户端连进来ThreadingTCPServer都会起一个新的线程处理。它特别适合用来验证我的协议设计到底合不合理粘包问题到底出在客户端还是服务端4.2 C 语言实现从原理到字节热词里出现了“tcp ip sockets 编程 c语言实现”如果你把 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 语言最大的特点是所有参数都暴露在你面前。sockaddr_in结构体里的sin_family、sin_addr.s_addr、sin_port对应着你在 C# 里写的AddressFamily、IPAddress、Port。htons这个函数也特别有意思它把主机字节序转成网络字节序。因为不同 CPU 的字节序不一样TCP/IP 协议规定网络上统一用大端所以你在手动填充端口号时必须转换。很多人觉得 C 语言难其实难的不是 API而是内存和指针。但正因为难写过一次 C 版本之后你再看任何语言的 socket 接口都会觉得非常轻松。4.3 VBA 用 Winsock 调 Telnet别笑真有这种需求热词里还有一条非常罕见的“VBA 代码实现 telnet 的 window socket 调用”。你可能觉得 VBA 都是用来写 Excel 宏的怎么会跟网络编程扯上关系实际情况是有些老旧的工控系统、路由器管理接口、仪器设备只提供 Telnet 或者裸 TCP 接口。企业里又没有专门的 IT 团队业务人员只能用 Excel 做数据收集。这时候有人会用 VBA 的 Winsock 控件来实现 TCP 通信。思路是这样在 VBA 工程里插入MSWinsock.Winsock控件然后Winsock1.RemoteHost 192.168.1.100 Winsock1.RemotePort 23 Winsock1.Connect 在 DataArrival 事件中接收数据 Private Sub Winsock1_DataArrival(ByVal bytesTotal As Long) Dim strData As String Winsock1.GetData strData Debug.Print strData End Sub你会发现控件的Connect、DataArrival、GetData本质上就是 socket 的connect、recv、read。它的事件驱动模型和 C# 的BeginReceive回调是同一个思路。所以我一直觉得socket 编程不局限于语言理解了模型走到哪个平台都认得它。5. 故障排查绑定失败、防火墙、连接被重置5.1 端口被占用failed to create server shutdown socket 报错实战记录热词里有一整条错误信息格式是“failed to create server shutdown socket on address [localhost] and port [802…]”。这个报错看起来很长很吓人但本质上就一句话这个地址和端口已经被占用了操作系统不让你重复绑定。我在本机复现过这个错误。我先起了一个 Java 应用监听 8020 端口然后在没有停掉它的情况下又启动 Tomcat日志里就出现了failed to create server shutdown socket on address [localhost] and port [8020]。为什么叫 shutdown socket因为 Tomcat 默认会开一个本地 socket用来监听 shutdown 命令。它绑定失败只影响关闭指令但应用还能正常启动所以很多人会忽略这个警告。不过后面你真正想关闭服务时就会发现关不掉——因为那个监听 shutdown 的 socket 根本没起来。遇到端口占用不要猜直接用命令查系统查端口命令查进程命令杀进程命令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导致连接释放不正常或者测试脚本频繁地 connect、close你会看到 error 信息是“通常每个套接字地址(协议/网络地址/端口)只允许使用一次”。这时候用netstat -ano查看端口状态你可能会看到一堆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)。这个选项告诉内核如果端口上残留的是 TIME_WAIT 状态的连接可以复用。第二在客户端也尽量避免频繁地创建和关闭连接。我见过有些测试程序在循环里对同一个服务不断 connect/close压测没做多少先把端口搞满了。这种场景下要么用持久连接要么把 TIME_WAIT 的回收参数调快。5.3 服务明明启动了外部却连不上监听地址和防火墙很多人本地测试一切正常一到部署就给客户报“连不上”。我排查这类问题有固定顺序。先确认监听地址。netstat -ano看服务端监听的本地地址。如果监听的是127.0.0.1:8080那只有本机能连如果监听的是0.0.0.0:8080才是所有网卡都可以连。多网卡服务器上这个区别尤其明显。再看防火墙。Windows 上第一次启动 TCP 服务时系统通常会弹窗询问是否允许通信。如果你点了取消或者部署时根本没注意到这个弹窗外部连接就会被 Windows 防火墙静默阻断。Linux 服务器则要检查firewalld或ufw的规则。最后看云服务商的安全组。这是个特别容易被人忽略的点。很多云服务器默认只开放少数端口你在服务器内部怎么测都通但外部就是进不来问题就在安全组规则里没有放行对应的端口。5.4 连接被重置Recv 返回 0 和 SocketException 的区别还有一个经验分享想写出来。TCP 断开有两种情况如果对端正常关闭recv/Receive会返回 0。在 C# 的BeginReceive回调里EndReceive返回 0 就是正常关闭。如果对端异常退出、断电、程序崩溃你这边会收到一个SocketException错误码通常是ConnectionReset。它的含义是对端发了一个 RST 报文把连接强行重置了。这两种情况在业务代码里一定要区别处理。前者是“对方说再见了”后者是“对方突然消失”。如果你不区分把 RST 也当作正常断开那你的服务端可能永远不知道对端已经异常退出还会继续占着资源和内存。6. 最后聊点实际的写到这里核心内容已经讲完了。最后说几句经验之谈。我调网络程序有个习惯服务端代码里第一件事就加上 SO_REUSEADDR不管什么语言先把这个“端口复用”选项打开。它不会带来副作用但在你频繁重启服务、遇到 TIME_WAIT 时能帮你省掉一晚上排查时间。第二个习惯是写代码前先用 Python 把协议跑通再换 C# 或 C 去落地。Python 足够快足够简单能让你把精力聚焦在协议本身上而不是被语言特性绊住脚。我做的很多项目都是先用 Python 脚本模拟客户端再拿 C# 写正式服务端这套流程非常稳。最后一个建议一定要学会看抓包。Wireshark 装上不难难的是你肯不肯在“为什么收不到数据”的时候打开抓包软件看一眼。三次握手有没有完成SYN 有没有发出ACK 有没有回来这些问题的答案全在数据包里。代码不会骗你报文更不会骗你。TCP/IP 和 socket 这条路说难也难说简单也简单。难在你如果只看理论就永远隔着一层纱简单在你只要动手写过一两个服务端和客户端很多概念自然就通了。希望这篇能帮你把那层纱捅破。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表