
简介这份资源面向具备一定C#基础、希望深入理解网络编程的开发者围绕Socket套接字实现客户端之间的直接通信。内容涵盖服务端与客户端两端程序构建套接字类型为面向连接的Socket并自行设计双方应答模式完成服务端与客户端之间的数据收发。服务端可响应单个或任意多个客户端连接请求支持向单个客户发送消息以及群发消息给所有客户端同时具备异常响应功能能处理对方异常退出客户端之间也可直接通信而非通过服务端转发。资源包共44个文件以13个cs源码文件为核心辅以6个exe可执行程序、5个txt说明文档、4个resx资源文件及csproj项目文件等压缩包约104KB结构清晰便于对照学习。目前已有5201人学习下载适合作为Socket通信实验的完整参考方案帮助读者掌握连接管理、消息收发与异常处理等关键实现思路。1. 从「客户端之间直接通信」说起为什么 Socket 是绕不开的那条路做过 C# 上位机的人大概率都遇到过这个需求两台设备上的程序要互相传数据但中间不想再架一台服务器做中转。比如车间里两台工控机一台负责采集 PLC 数据另一台负责界面展示你希望采集端直接把数据推给展示端而不是让展示端去轮询数据库。这时候「C# 利用 Socket 实现客户端之间直接通信」就成了一个非常具体的落地问题。它的本质是让每个客户端既是连接发起方也是数据接收方通过 TCP 或 UDP 在点对点之间建立通道。适合的场景包括局域网内设备互传、上位机与上位机之间的状态同步、测试工装与主控程序之间的指令交互。不适合的场景也很明确——跨公网、需要穿透 NAT、对可靠性要求极高的生产环境这些情况老老实实上服务端中转更稳妥。这篇文章不讲泛泛的 Socket 概念而是把「两个客户端怎么直连、代码怎么写、参数怎么调、坑在哪」一条线走完。如果你手上有两台机器、一个 Visual Studio跟着做就能跑通。2. 直连模型怎么选TCP 点对点与 UDP 广播的取舍2.1 先想清楚「谁先开口」——对等角色的确定Socket 直连最容易被忽略的一步是确定谁是监听方、谁是连接方。TCP 是面向连接的协议必须有一端先Bind一个端口并Listen另一端才能Connect过来。如果两个客户端都只想「主动连别人」那谁也连不上谁。常见做法是在配置里给每个客户端一个角色标识比如 A 端固定监听8888端口B 端启动后主动连 A 端的 IP 和端口。这样 A 端是服务端角色B 端是客户端角色但两者在业务逻辑上是对等的——都能发、都能收。这就是「客户端之间直接通信」最常见的落地形态。如果你希望两端完全对等、谁先启动谁监听那就需要一套协商机制先尝试连接对方连不上就自己监听。这个逻辑后面会给代码。2.2 TCP 还是 UDP一张表看清适用边界对比项TCP 直连UDP 直连连接方式需要 Listen/Connect直接 SendTo 目标地址可靠性有序、不丢、不重不保证到达、不保证顺序适用数据指令、文件、状态同步高频遥测、心跳、广播发现防火墙友好度需要放行监听端口相对容易但广播可能被拦代码复杂度稍高要处理粘包低但要做丢包补偿典型场景上位机指令交互多设备状态广播我一般会这样选如果传的是控制指令、配置参数、需要确认到达的数据用 TCP如果是周期性上报的温度、转速、心跳包丢一两帧无所谓用 UDP 更省事。很多现场其实是 TCP 和 UDP 混用——UDP 做设备发现和心跳TCP 做正式数据通道。2.3 最小可跑的 TCP 直连代码下面这段代码展示一个「先尝试连接失败则监听」的对等模型。把它分别跑在两台机器上改一下对方 IP 就能互通。using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading.Tasks; class PeerNode { private const int Port 8888; private const string PeerIp 192.168.1.102; // 改成对端 IP private TcpClient _client; private NetworkStream _stream; public async Task StartAsync() { // 先尝试作为客户端连接对端 try { _client new TcpClient(); await _client.ConnectAsync(PeerIp, Port); Console.WriteLine(已连接到对端); } catch (SocketException) { // 连不上说明对端还没起来自己当监听方 Console.WriteLine(连接失败切换为监听模式); var listener new TcpListener(IPAddress.Any, Port); listener.Start(); _client await listener.AcceptTcpClientAsync(); Console.WriteLine(已接受对端连接); } _stream _client.GetStream(); _ Task.Run(ReceiveLoop); // 收数据不阻塞主流程 await SendLoop(); // 主流程负责发数据 } private async Task ReceiveLoop() { var buffer new byte[4096]; while (true) { int n await _stream.ReadAsync(buffer, 0, buffer.Length); if (n 0) break; // 对端关闭 string msg Encoding.UTF8.GetString(buffer, 0, n); Console.WriteLine($[收到] {msg}); } } private async Task SendLoop() { while (true) { string input Console.ReadLine(); if (string.IsNullOrEmpty(input)) continue; byte[] data Encoding.UTF8.GetBytes(input); await _stream.WriteAsync(data, 0, data.Length); } } }逻辑说明ConnectAsync失败会抛SocketException这里用它来判断对端是否已启动。AcceptTcpClientAsync返回后双方都拿到了一个TcpClient后续读写完全对称。接收放在独立 Task 里避免阻塞发送。参数说明Port是监听端口两端必须一致PeerIp只在连接阶段用监听方不需要。buffer大小 4096 是经验值传大文件要改成分块协议。ReadAsync返回 0 表示对端正常关闭必须处理否则会死循环。提示这段代码没有处理粘包。如果发送频率高或单条消息超过缓冲区必须加长度前缀后面会讲。3. 把「能通」变成「好用」消息边界、心跳与断线重连3.1 粘包不是玄学是 TCP 的默认行为TCP 是字节流协议它不关心你发了几个包。你调两次Write对端可能一次Read全收到也可能分三次收到。这就是粘包和半包。很多新手在这里翻车本地测试好好的一到现场数据就错位。解决办法只有一个在应用层定义消息边界。最常见的是「长度前缀 内容」先发 4 字节的 int 表示正文长度再发正文。接收方先读 4 字节知道长度后再读对应字节数。// 发送长度前缀 UTF8 正文 private async Task SendMessageAsync(string msg) { byte[] body Encoding.UTF8.GetBytes(msg); byte[] len BitConverter.GetBytes(body.Length); // 4 字节 await _stream.WriteAsync(len, 0, 4); await _stream.WriteAsync(body, 0, body.Length); } // 接收先读满 4 字节长度再读正文 private async Taskbyte[] ReadExactAsync(int count) { byte[] buf new byte[count]; int offset 0; while (offset count) { int n await _stream.ReadAsync(buf, offset, count - offset); if (n 0) throw new IOException(连接已关闭); offset n; } return buf; } private async Task ReceiveLoop() { while (true) { byte[] lenBuf await ReadExactAsync(4); int len BitConverter.ToInt32(lenBuf, 0); byte[] body await ReadExactAsync(len); string msg Encoding.UTF8.GetString(body); Console.WriteLine($[收到] {msg}); } }逻辑说明ReadExactAsync保证一定读满指定字节数这是处理半包的关键。BitConverter默认小端序两端都是 C# 时没问题如果对端是 C 或 Java要确认字节序。参数说明长度前缀用 4 字节 int最大支持 2GB 消息实际场景建议限制在 1MB 以内超过就分片。如果对端语言不确定可以改用固定 4 字节大端序用IPAddress.HostToNetworkOrder转换。3.2 心跳包让「假连接」现形TCP 连接断开时ReadAsync会返回 0这没问题。但有一种情况很坑网线拔了、对端断电操作系统不会立刻通知你连接看起来还在实际发出去的数据石沉大海。这就是「假连接」。解决办法是心跳每隔固定时间发一个约定好的小包比如PING对端回PONG。连续几次没收到回应就主动关闭重连。private DateTime _lastRecv DateTime.Now; private async Task HeartbeatLoop() { while (true) { await Task.Delay(5000); // 5 秒一次 if ((DateTime.Now - _lastRecv).TotalSeconds 15) { Console.WriteLine(心跳超时主动断开); _client.Close(); break; } await SendMessageAsync(PING); } }逻辑说明_lastRecv在每次收到数据时更新。超过 3 个心跳周期没收到任何数据就判定连接失效。这里简化处理实际项目里可以把心跳和业务数据分开避免心跳包干扰业务解析。参数说明心跳间隔 5 秒、超时 15 秒是局域网常用值。如果网络抖动大可以放宽到 10 秒 / 30 秒。注意心跳包也要走长度前缀协议否则会破坏消息边界。3.3 断线重连别让程序死在一次异常上现场环境里网络闪断、对端重启都是常态。如果程序一断就退出运维会找你麻烦。重连逻辑要放在外层循环里捕获异常后延迟重试。public async Task RunWithReconnectAsync() { while (true) { try { await StartAsync(); // 包含连接和收发 } catch (Exception ex) { Console.WriteLine($连接异常{ex.Message}5 秒后重试); } await Task.Delay(5000); } }逻辑说明StartAsync内部任何异常都会跳出外层统一延迟重试。注意重连时要重新创建TcpClient不能复用已关闭的实例。参数说明重试间隔 5 秒是折中值太短会刷日志太长影响恢复速度。可以做成指数退避第一次 1 秒之后翻倍上限 30 秒。注意重连成功后要重新初始化接收循环和心跳循环否则会出现多个接收 Task 抢同一个流的情况。4. 避坑与排查直连通信里最容易翻车的 5 个点4.1 现象本地能通换台机器就连不上原因防火墙拦了监听端口。Windows 默认会弹窗询问是否允许如果点了取消或者程序以服务方式运行没弹窗端口就被静默拦截。解决在目标机器上手动放行端口。命令行执行netsh advfirewall firewall add rule namePeerComm dirin actionallow protocolTCP localport8888。如果是 UDP把protocolTCP改成UDP。放行后不需要重启立即生效。4.2 现象连上了但收不到数据或者收到乱码原因粘包处理不对或者两端编码不一致。常见的是发送方用Encoding.Default接收方用Encoding.UTF8中文直接变问号。解决统一用Encoding.UTF8并且确保长度前缀和正文用同一套读写逻辑。排查时可以先发固定字符串HELLO看对端收到几个字节如果字节数不对就是边界问题。4.3 现象程序运行一段时间后 CPU 飙高原因接收循环里ReadAsync返回 0 后没有 break导致死循环空转。或者心跳循环里Task.Delay被异常跳过。解决检查所有ReadAsync的返回值等于 0 必须跳出循环并关闭连接。心跳循环里用try-catch包住Task.Delay确保延迟一定执行。4.4 现象对端关闭后本端发送不报错原因TCP 的半开连接。对端调了Close本端第一次Write可能成功数据进了缓冲区第二次才会抛异常。如果本端不读也不写永远不知道对端已关闭。解决靠心跳机制兜底。另外在Write时捕获IOException一旦出现就主动关闭重连。不要依赖TcpClient.Connected属性它反映的是上次操作的状态不是实时状态。4.5 现象多网卡机器上监听地址写错导致连不上原因IPAddress.Any会监听所有网卡一般没问题。但如果指定了IPAddress.Loopback那就只有本机能连。或者机器有多个 IP对端连了一个不可达的网段。解决监听方用IPAddress.Any连接方用ping确认目标 IP 可达。如果机器有多网卡在配置里明确指定用哪个本地 IP 去连可以用TcpClient的构造函数绑定本地终结点。5. 进阶技巧用 UDP 做发现用 TCP 做通道5.1 为什么需要「发现」这一步前面的代码要求你手动填对端 IP。两台机器还好十台设备呢每台都配一遍 IP 列表运维会疯。常见做法是UDP 广播做设备发现TCP 做正式数据通道。每个客户端启动时向广播地址发一个DISCOVER包其他客户端收到后回复自己的 IP 和 TCP 端口然后双方建立 TCP 连接。// UDP 发现监听 9999 端口收到 DISCOVER 就回复自己 private async Task DiscoveryLoop() { var udp new UdpClient(9999); udp.EnableBroadcast true; while (true) { var result await udp.ReceiveAsync(); string msg Encoding.UTF8.GetString(result.Buffer); if (msg DISCOVER) { string reply $HERE:{GetLocalIp()}:8888; byte[] data Encoding.UTF8.GetBytes(reply); await udp.SendAsync(data, data.Length, result.RemoteEndPoint); } } } private string GetLocalIp() { // 获取本机第一个非回环 IPv4 地址 var host Dns.GetHostEntry(Dns.GetHostName()); foreach (var ip in host.AddressList) if (ip.AddressFamily AddressFamily.InterNetwork) return ip.ToString(); return 127.0.0.1; }逻辑说明EnableBroadcast必须设为 true否则广播包发不出去。收到DISCOVER后回复里带上自己的 IP 和 TCP 端口对方拿到后就能发起 TCP 连接。GetLocalIp只取第一个 IPv4多网卡场景要按网段过滤。参数说明UDP 端口 9999 和 TCP 端口 8888 分开互不干扰。广播地址一般是255.255.255.255跨网段需要配子网定向广播地址。发现包建议加一个随机 token避免误响应其他程序的广播。5.2 一个验证连通性的小技巧写完代码别急着上业务先用telnet或Test-NetConnection验证端口通不通。PowerShell 里执行Test-NetConnection 192.168.1.102 -Port 8888如果TcpTestSucceeded为 True说明监听正常、防火墙放行。这一步能省掉大量「到底是代码问题还是网络问题」的纠结。我自己的习惯是每做一个新的直连方案先跑通「发一句收一句」再加心跳再加重连最后才上业务协议。顺序反了排查成本会成倍增加。希望帮到你。本文还有配套的精品资源点击获取