简介一套C# Socket编程实战示例面向需要理解TCP/IP套接字通信、服务器与多客户端交互机制的开发者。实验完整实现服务端与客户端两端程序采用面向连接的Socket服务端可同时响应多个客户端连接既能向指定客户端发送消息也能群发给所有在线客户端特别实现了客户端与客户端之间的直接通信不依赖服务端转发并具备对方异常退出的检测处理。资源共44个文件以C#源文件.cs和可执行程序.exe为主包含完整项目工程.csproj、界面资源.resx、程序配置.settings及说明文档.txt压缩包仅104KB适合快速下载研读。已有5198人浏览学习。通过该示例可掌握Socket编程中连接建立、数据收发、多客户端管理、点对点直连及异常断开处理的核心思路并可直接运行exe观察通信效果或基于源码二次开发成聊天室、远程控制等应用。1. 直连的真实困境不是代码不会写是网络环境不允许前两天有个做上位机的朋友问我能不能让两台工控机上的客户端程序不经过服务器中转直接用 C# Socket 互发数据。这个问题听起来很简单——TcpClient连接一下、UdpClient发个包不就行了但真正动手后你会发现客户端之间直接通信这件事卡住你的往往不是 C# 的 Socket API而是你和目标机器之间的网络环境。我见过的案例里大多数直连失败最后排查出来都不是代码问题。两台设备在同一个局域网程序随随便便就跑通了一旦两边都在不同的宽带网络、公司内网或者移动网络后面TCP 连接就怎么都建不起来。因为ConnectAsync返回的永远是目标主机不可达或超时你根本不知道那个包到底是被路由器丢了还是被防火墙吞了。1.1 为什么客户端之间直接通信天然绕不开 NAT先花两分钟把网络环境讲明白。现在绝大多数设备没有公网 IP都躲在路由器后面路由器负责 NAT网络地址转换把内网设备的私有地址映射成一个公网出口。拿办公室电话来类比整层办公室只有一部外线电话每个人都有分机号。你拨内部分机直接就能通但外面的人想直接打到你的分机必须先拨总机再由前台转接。这个前台加总机就是路由器上的 NAT 和端口映射规则。你写一个客户端程序想做点对点直连本质上是想让外部世界的另一台设备主动访问你所在局域网里的某个内网地址。如果路由器上没有提前配置映射规则外面进来的包根本不知道要转给谁只能丢弃。这不是 C# 能绕过的问题而是网络拓扑本身就不允许。1.2 三种直连场景你先对号入座我一般会把客户端直连的需求分成三种情况来处理场景典型环境能否直接连推荐方案同一局域网内两台电脑连同一个路由器能TCP/UDP 直连用内网 IP公网可达云服务器、有公网 IP 的机器、路由器已做端口映射能TCP 直连或 UDP 直连双方都在 NAT 后面家庭宽带、公司网络、4G/5G 热点不能直接 TCP 连UDP NAT 穿透打洞判断方法很简单让 A 程序的监听端口绑定IPAddress.Any然后从 B 那台机器上用telnet A的IP 端口试一下。如果通恭喜你属于前两行如果不通老实往下看 NAT 穿透。2. 环境允许就真刀真枪TCP点对点直连的完整实现2.1 一端监听一端连接TcpListener TcpClient 完整代码如果两台机器能互相访问那最稳的方案就是 TCP。TCP 直连本质上是经典的服务端/客户端模式只不过在点对点架构里服务端和客户端都是你的业务程序区别只是谁负责监听、谁负责发起连接。先看监听方代码我这里叫它 A 端。A 端在指定端口上监听等 B 连过来然后读数据using System.Net; using System.Net.Sockets; using System.Text; // A 端监听等待 B 连接 var listener new TcpListener(IPAddress.Any, 8899); listener.Start(); Console.WriteLine(A 端已监听 8899 端口); using var client await listener.AcceptTcpClientAsync(); Console.WriteLine($B 已连接{client.Client.RemoteEndPoint}); using var stream client.GetStream(); byte[] buffer new byte[4096]; int count; while ((count await stream.ReadAsync(buffer, 0, buffer.Length)) 0) { string msg Encoding.UTF8.GetString(buffer, 0, count); Console.WriteLine($收到{msg}); }B 端代码就更短了主动连接到 Ausing System.Net.Sockets; using System.Text; using var tcpClient new TcpClient(); await tcpClient.ConnectAsync(A 的 IP, 8899); Console.WriteLine($已连接到 A{tcpClient.Client.RemoteEndPoint}); using var stream tcpClient.GetStream(); byte[] data Encoding.UTF8.GetBytes(你好我是 B); await stream.WriteAsync(data, 0, data.Length);这套代码跑通了你就有了一条不经过任何服务器的 TCP 点对点链路。注意如果 A 有多个 IP比如同时有有线、无线、虚拟网卡IPAddress.Any会同时监听所有网卡实际项目里通常要按部署环境精确绑定某一个 IP避免连到错误的网卡上。另外真实上位机项目里监听循环要放在后台线程或者Task.Run里千万不能阻塞 UI 线程不然就会出现界面采集卡顿的老毛病。2.2 TCP 是流协议消息边界必须自己处理很多第一次写 Socket 通信的人会踩这个坑A 端连续 Send 两次B 端可能一次 Read 就把两条消息全收进来了反过来A 端一次 Send 了一大包数据B 端也可能分几次 Read 才读完。原因很简单——TCP 是字节流协议它只保证字节顺序不保证消息边界。解决办法是在应用层约定一个最简单的消息帧格式前面 4 字节放正文长度int后面放正文内容。发送端先写长度再写正文接收端先读满 4 字节再按长度读正文。下面是可直接用的封装static async Task SendMessageAsync(NetworkStream stream, string message) { byte[] body Encoding.UTF8.GetBytes(message); byte[] header BitConverter.GetBytes(body.Length); await stream.WriteAsync(header, 0, header.Length); await stream.WriteAsync(body, 0, body.Length); } static async Taskstring? ReceiveMessageAsync(NetworkStream stream) { byte[] header new byte[4]; int read await ReadFullAsync(stream, header, 4); if (read 0) return null; // 对端关闭 int length BitConverter.ToInt32(header, 0); byte[] body new byte[length]; await ReadFullAsync(stream, body, length); return Encoding.UTF8.GetString(body); } static async Taskint ReadFullAsync(NetworkStream stream, byte[] buffer, int count) { int total 0; while (total count) { int n await stream.ReadAsync(buffer, total, count - total); if (n 0) break; total n; } return total; }ReadFullAsync这段是必须的ReadAsync并不保证一次调用就把 count 字节读满。这个差一点就同步好了结果数据不完整的小问题会在线程不稳定的真实网络环境里随机出现非常恶心。很多 C# Socket 通信的偶发乱码、偶尔丢数据根源就在这里。2.3 连接保活KeepAlive 与心跳TCP 长连接还有一个特别坑的地方半开连接。对方程序崩溃、断电、网线被拔你这一端根本不知道尤其是没有数据往来的空隙连接可能挂着很久才报错。想及时发现靠系统底层的 KeepAlive 参数不够因为默认探测周期太长Windows 下可能要 2 小时而且很多环境下根本不触发。工程上最靠谱的方案是应用层心跳每几秒发一个 Ping 包对方回 Pong连续 N 次没回应就判定连接断开然后自动清理和重连。粗框架长这样int missCount 0; while (true) { await SendMessageAsync(stream, PING); string? resp await ReceiveMessageAsync(stream); if (resp PONG) missCount 0; else missCount; if (missCount 5) { Console.WriteLine(连接已失效准备重连); break; } await Task.Delay(5000); }心跳还有一个附带好处很多路由器/NAT 会回收长时间不活动的连接表项定时发数据等于让路由记住这条链路还在。我是把心跳保活和NAT 保活当成同一件事来做的省事。3. 跨NAT直连的核心技术UDP NAT穿透的可行性分析3.1 为什么穿透首选 UDP 而不是 TCP上一章的 TCP 直连看起来很完美但它在双方都躲在 NAT 后面时基本连不上。那有没有办法从 NAT 眼皮底下打通一条链路有主流做法是 UDP NAT 穿透也就是常说的 UDP 打洞。为什么选 UDP 而不是 TCP因为 TCP 是面向连接的协议三次握手的过程对 NAT 来说很敏感外部主动发起的 SYN 包在没有前置放行规则的情况下几乎都会被 NAT 丢弃。UDP 就完全不一样了它是无连接的只要内网主机往外发出一个包NAT 就会自动建立一个内网地址/端口 到 公网地址/端口的临时映射之后外部对这个公网端口的回应就能顺着映射递进来。穿透的核心思路就是利用我先给你发一个包让 NAT 记住我你的包才有机会进来。我做 P2P 通信测试时专门比较过 TCP 打洞和 UDP 打洞的成功率。TCP 打洞需要两端同时 SYN、同时等待、还要求 NAT 对 TCP 的处理比较宽松成功率比 UDP 低一截而且链路一旦断了重新建立连接的逻辑非常繁琐。所以除非业务强制要求 TCP否则我建议点对点场景直接走 UDP。3.2 NAT 四种类型与打洞成功概率NAT 不是只有一种行为它分四类。这张表你一定要收好因为它直接决定你的打洞方案能不能成功NAT 类型行为特征打洞成功概率全锥型同一内网地址/端口映射后任何外部主机都能通过该映射访问极高受限锥型只有内网主机主动向其发过包的外部主机才能回包高端口受限锥型外部主机的 IP 和端口都必须被内网主机访问过一般对称型每次访问不同目标 IP/端口NAT 都可能分配新的映射端口极低家庭宽带路由器绝大多数是锥型 NAT所以很多点对点应用能跑起来。真正让人头疼的是公司出口网关、部分移动网络出口它们经常是对称型 NAT——你在打洞阶段好不容易查到了对端的公网端口等对端换一个目标再发包端口可能又变了。遇到对称 NAT打洞这件事基本不用纠结直接降级到服务器中转别浪费时间。3.3 打洞时序注册、交换地址、双端同时发包如果两边的 NAT 都还算友好UDP 打洞的完整流程是这样的客户端 A 和 B 分别向一个部署在公网的信令服务器发送注册包。信令服务器是整个方案里唯一的中介但它只负责交换地址不转发业务数据。信令服务器收到注册包后能看到每个客户端的真实公网端点源 IP 加源端口把这个信息回传给客户端。这里要注意客户端自己是不知道自己出去后会被 NAT 换成什么端口号的只有服务器看到的才是外面的人能访问到的地址。服务器再把 B 的公网端点告诉 A把 A 的公网端点告诉 B。A 和 B 同时拿着对方的公网端点往那个地址发送 UDP 探测包并且连续发送好几次。当 A 发的探测包经过自己的 NAT 时NAT 记录了A 要去 B 这个地址当这个包到达 B 的 NAT 时因为 B 刚好也往 A 发了包B 的 NAT 也记录了B 要去 A 这个地址所以 A 的包就能被放进去。反过来同理。两边都收到来自对端的探测包说明链路打通了之后的数据完全走 A 和 B 之间的点对点链路信令服务器不再参与业务传输。这里最容易犯的错是A 发了一个包之后傻等以为 B 那边会主动来找自己。实际上打洞要求双方同时往对方地址发不是单方面等。所以客户端代码一定是后台持续接收 前台循环发送探测包的结构而不是发一个包然后阻塞等回复。4. C#实现UDP打洞信令服务器与客户端核心代码4.1 信令服务器UdpClient 注册与查询信令服务器要部署在有公网 IP 的服务器上云服务器即可代码本身不复杂就是一个不停接收 UDP 数据并维护名字 到 公网端点映射的循环using System.Net; using System.Net.Sockets; using System.Text; var server new UdpClient(7000); var clients new Dictionarystring, IPEndPoint(); Console.WriteLine(信令服务器启动监听 UDP 7000); while (true) { UdpReceiveResult result await server.ReceiveAsync(); string text Encoding.UTF8.GetString(result.Buffer); if (text.StartsWith(REG:)) { string name text.Substring(4); clients[name] result.RemoteEndPoint; Console.WriteLine(${name} 注册公网端点{result.RemoteEndPoint}); byte[] resp Encoding.UTF8.GetBytes(result.RemoteEndPoint.ToString()); await server.SendAsync(resp, result.RemoteEndPoint); } else if (text.StartsWith(GET:)) { string name text.Substring(4); if (clients.TryGetValue(name, out var target)) { byte[] resp Encoding.UTF8.GetBytes(target.ToString()); await server.SendAsync(resp, result.RemoteEndPoint); } } else if (text.StartsWith(BYE:)) { string name text.Substring(4); clients.Remove(name); } }两个动作REG:注册并返回自己的公网端点GET:查询某个名字的公网端点。服务器要维护全局映射多客户端并发操作字典时建议加锁或者直接用ConcurrentDictionary不然两个客户端同时注册可能产生竞态。再进一步线上环境还需要定期清理长期不活跃的注册条目简单做法是给每个条目加一个最近活跃时间超过比如 30 秒没有心跳就删掉。4.2 客户端打洞核心代码客户端的逻辑分三步注册拿到自己的公网地址查询拿到对端公网地址然后同时打洞。下面这段代码可以直接复制到控制台项目里跑using System.Net; using System.Net.Sockets; using System.Text; string serverIp 你的信令服务器公网IP; int serverPort 7000; string myName Alice; string peerName Bob; using var udp new UdpClient(AddressFamily.InterNetwork); // 明确走 IPv4 var linked false; // 1. 注册拿到自己的公网端点 await udp.SendAsync(Encoding.UTF8.GetBytes($REG:{myName}), serverIp, serverPort); UdpReceiveResult res await udp.ReceiveAsync(); Console.WriteLine($我的公网端点{Encoding.UTF8.GetString(res.Buffer)}); // 2. 查询对端公网端点 await udp.SendAsync(Encoding.UTF8.GetBytes($GET:{peerName}), serverIp, serverPort); res await udp.ReceiveAsync(); IPEndPoint peer ParseEndpoint(Encoding.UTF8.GetString(res.Buffer)); Console.WriteLine($对端公网端点{peer}); // 3. 后台持续接收对端的打洞包 var linkedTcs new TaskCompletionSourceIPEndPoint(); _ Task.Run(async () { while (!linked) { try { UdpReceiveResult r await udp.ReceiveAsync(); string msg Encoding.UTF8.GetString(r.Buffer); if (msg.StartsWith(PUNCH) r.RemoteEndPoint.Equals(peer)) { linkedTcs.TrySetResult(r.RemoteEndPoint); break; } } catch { break; } } }); // 4. 前台循环发送探测包 byte[] punch Encoding.UTF8.GetBytes($PUNCH:{myName}); for (int i 0; i 20; i) { await udp.SendAsync(punch, peer); await Task.Delay(300); } if (await Task.WhenAny(linkedTcs.Task, Task.Delay(8000)) linkedTcs.Task) { Console.WriteLine($打洞成功直连地址{await linkedTcs.Task}); linked true; } else { Console.WriteLine(打洞失败请检查两边 NAT 类型或准备走中继); } static IPEndPoint ParseEndpoint(string str) { string[] parts str.Split(:); return new IPEndPoint(IPAddress.Parse(parts[0]), int.Parse(parts[1])); }注意两个细节。第一我用new UdpClient(AddressFamily.InterNetwork)而不是无参构造函数因为无参构造函数在很多环境下会绑定到 IPv6 双栈地址信令服务器返回的却是 IPv4 映射两边对不上就直接收不到包。第二打洞包要循环发 20 次左右不要发一次就停——第一个包经常因为 NAT 表还没建好而被丢弃多发几次成功率会明显提升。4.3 一个很隐蔽的坑UdpClient.Connect 不能随手用注意打洞阶段千万不要调用udp.Connect否则你会被各种莫名其妙的问题折磨。很多 C# Socket 教程会这样写udp.Connect(ip, port); await udp.SendAsync(data);。UDP 本身是无连接的调用 Connect 只是给 socket 预设一个默认远端后面 Send/Receive 就不用每次都指定端点。这个写法在固定对端通信时没问题但放到打洞场景里会坑人。打洞阶段你同时要和信令服务器通信、要向对端多个可能的地址发包、要接收任意来源的探测包。一旦调用udp.Connectsocket 就变成了只认这个固定远端的连接语义其他端点发过来的包可能被过滤接收时也可能抛异常。我早期写打洞代码就被这个问题卡了一晚上明明两边都在发探测包但就是互相收不到。后来把Connect去掉改用udp.SendAsync(data, peer)显式指定对端问题立刻消失。如果你的对端 NAT 类型比较宽松穿透成功后可以再用Connect锁定链路减少每次指定端点的麻烦。但穿透过程里请务必保持无连接状态。4.4 打不通怎么办中继兜底穿透不是 100% 成功的。公司出口对称 NAT、某些运营商封锁 UDP、甚至是信令服务器临时抖动都会让打洞失败。工程上和别人对接时我一定会在穿透方案旁边放一个中继兜底所有业务数据都通过信令服务器转发。具体实现就是在服务器端维护一个目标名 到 端点的映射客户端发来的数据包如果带TO:Bob前缀服务器就把它转发给 BobBob 回包时带上TO:Alice即可。有中继兜底之后整个系统的可用性就完全不一样了。穿透成功时延迟最低穿透失败时自动切中继功能不瘫痪只是多一跳延迟。我个人的做法没这么复杂就是先试穿透失败就切中继。5. 链路打通之后保活、可靠传输与网络环境排障5.1 NAT 映射会过期保活包别停穿透成功了链路也会自动断开对NAT 端口映射是有有效期的。不同路由器策略不一样短的几十秒长的几分钟。如果一段时间内没有任何包穿过这条链路NAT 就把映射回收了下次对端再发数据就进不来。这也是为什么所有点对点软件即使没有业务数据也在互相发心跳包。打洞成功后我建议双方每 10 到 15 秒互发一个心跳包这个包要直接发给对端不要又发给信令服务器。代码如下byte[] keepAlive Encoding.UTF8.GetBytes(KEEPALIVE); while (linked) { await udp.SendAsync(keepAlive, peer); await Task.Delay(15000); }这个包很小带宽成本几乎可以忽略但能保证 NAT 表一直热着。具体发送间隔要根据实际网络环境调如果发现隔一段时间就断一次通常就是保活间隔大于 NAT 映射回收时间了把间隔调短试试。5.2 UDP 不可靠直连后如何保证数据完整打洞成功只是拿到了能通信的通道不代表数据可靠。UDP 会丢包、乱序、重复这是协议层的特性。如果只是传音频、视频、或者一些丢了也没关系的状态数据直接用没问题但文件传输、业务指令、数据库同步这类敏感数据必须自己加可靠层。最简单的可靠方案就是序号 ACK 重传。给每个数据包编一个递增序号接收方收到后回一个 ACK发送方超时没收到 ACK 就重发。包格式可以设计成这样字段长度说明Magic2 字节固定 0x5A5A用来快速识别合法包Type1 字节0x01 数据0x02 ACK0x03 心跳Seq2 字节数据包序号循环使用Ack2 字节最后一次收到的数据包序号Payload变长业务数据发送端的逻辑大致是static async Taskbool SendReliableAsync(UdpClient udp, IPEndPoint target, byte[] payload, ushort seq) { byte[] packet BuildPacket(seq, 0x01, payload); for (int retry 0; retry 3; retry) { await udp.SendAsync(packet, packet.Length, target); if (await WaitAckAsync(udp, seq, 2000)) // 2 秒等 ACK return true; } return false; }WaitAckAsync会循环接收 UDP 数据判断Ack字段是不是自己期望的序号。再提醒一次这个过程也处在未 Connect状态收到其他端点的包要直接忽略或按需处理。可靠层自己做起来不难但边界条件很多比如序号回绕、重传风暴、接收窗口大小。如果你不想从零写也可以直接用现成的可靠 UDP 库像游戏领域常用的 LiteNetLib内部已经把这些处理和底层 Socket 封装好了能省不少事。用库和自研不冲突要看你的项目约束是什么。5.3 端口占用与防火墙开局遇到 bind error 怎么办开发阶段最常碰到的报错是bind: only one usage of each socket address对应到 Windows 就是SocketException (10048)地址被占用。很多新手一脸懵地以为是自己代码写错了其实就两种情况一是上一次启动的进程还没退出端口被那个进程占着二是端口被其他程序占了。排查命令就两行netstat -ano | findstr 8899 taskkill /pid 占用进程的PID /f代码层面SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true)能在某些场景下缓解 TIME_WAIT 问题但注意它不能让两个进程同时监听同一个端口。规划端口时建议给每个服务固定一个端口段测试时统一从某个区间里取避免随手写一个撞上系统或别的软件。还有防火墙。Windows 防火墙默认拦截入站连接你TcpListener监听 8899 后第一次运行系统会弹窗问是否允许访问有时候你手一抖点了取消后面就是连不通但代码又没报错的诡异状态。命令行里可以这样放行netsh advfirewall firewall add rule nameMySocketApp dirin actionallow protocolTCP localport8899UDP 同理把protocolUDP再放行一次 7000、8899 等端口。公网服务器上更是要确认云控制台的防火墙策略也放行了对应端口很多云厂商的虚拟化防火墙是独立于系统防火墙的这层不放行系统里怎么配都没用。6. 我做这套东西的一些坚持最后分享几条只有真做项目才会琢磨出来的心得。第一能 TCP 直连就绝不为了图新鲜而绕 NAT。穿透和可靠传输的复杂度远超想象除非你的需求天生就是两个 NAT 后面的设备要低延迟互传否则老老实实走服务器中转反而省心。第二客户端直连的可用性受网络环境影响非常大产品里一定要有清晰的连接状态反馈不能让用户看到按钮点了没反应。第三网络层和业务层要解耦把直连、穿透、中继做成可以自动降级的三条路径这是工程级的做法而不是某一次跑通了就完事。我自己的项目里早期所有设备通信都走公网服务器中转后来视频流大了带宽和延迟都顶不住才回头认真补穿透这一课。把 NAT 类型、端口映射、信令交换这些概念彻底搞清楚之后再回头看 C# 的 Socket API其实都是些常规操作。你要是也想做点对点文件传输、音视频通话或者工控设备之间的低延迟互传这篇文章里的流程可以直接拿来当起点代码量不大但每一步背后的为什么都很关键。本文还有配套的精品资源点击获取