首页 / 资讯中心 / 文章详情

C# Socket编程实战:TCP与UDP协议核心差异及避坑指南

C# Socket编程实战:TCP与UDP协议核心差异及避坑指南 ★ FEATURED ARTICLE
最近在维护一个老项目时又被一长串乱七八糟的连接日志折腾了一整天。查来查去问题既不在服务器也不在数据库而是项目里负责网络通信的那段Socket代码写得太“随性”了。说起来很多同学在学C#网络编程时第一步就是从C# Socket编程的TCP与UDP协议开始。这个切入点确实对但往往也是大家最容易敷衍过去的环节。很多人上来就抄一段客户端连服务器的代码跑通了就以为万事大吉结果一到真实环境下粘包、断线、丢消息、端口冲突全来了。这篇文章就是我自己把这些年踩过的坑、做过的小项目、反复试错后总结出来的笔记整理了一遍想踏踏实实聊清楚两件事TCP和UDP到底有什么区别以及用C#写Socket通信时哪些基础点必须想明白再动手。文章偏重原理和实操结合适合刚接触C#网络编程、正在写课程设计或公司内部小工具、以及那些想补一补协议底子的开发者。看完不敢说你马上能写出工业级框架但至少心里能有一张清晰的路线图知道每条路通向哪里、路上哪里有坑。1. 为什么先把协议弄清楚再动手写Socket1.1 Socket到底是个啥我第一次接触Socket的时候以为它是某种很玄的高科技。后来在调试里翻来覆去才意识到它本质上就是一个“管道口”。操作系统给你开了一个口子你的程序可以通过这个口子把数据扔给网卡再从网卡那边收数据。这个口子在C#里对应的就是System.Net.Sockets.Socket类。它的工作方式和我们寄快递特别像。你要把数据包发到另一台机器上不需要知道对方内部是怎么处理内存的、跑的是什么操作系统你只需要知道对方的IP地址和端口号然后通过Socket把数据交给操作系统剩下的路由、寻址、差错校验都由系统底层去办。收快递那边呢也是在某个端口上等着数据到了系统就把它塞进对应的Socket缓冲区里你的程序再从缓冲区里取出来。这里有个特别容易混淆的点Socket本身不区分TCP还是UDP区分的是你创建Socket时使用的协议类型。C#里Socket类是一个通用入口通过SocketType和ProtocolType两个参数决定它是走TCP还是UDP。所以「Socket编程」准确说是一个总称真正的核心其实是底下那套传输层的协议规则。1.2 TCP与UDP的本质差异用打电话和寄明信片来理解聊TCP和UDP的区别我特别喜欢用打电话和寄明信片来类比因为这两件事我们日常生活里都经历过。TCP像打电话。拨号前得先建立连接对方接了才正式开始通话双方你一句我一句、彼此确认。你说完一段话对方如果没听清会“喂你说啥”你得重复一遍。挂电话的时候也要有明确的结束流程不能说挂就挂。整个过程里双方都清楚“我们正处于一次通话中”这就是面向连接。UDP像寄明信片。你写好内容选个地址投进邮筒就完事了对方收不收得到、什么时候收到、顺序对不对你不关心也控制不了。邮局是按自己的节奏送件的明信片可能寄丢可能晚到可能两三张的顺序全部乱掉。你每次发的内容是独立的一张卡片不会因为前面那张没到后面那张就卡住不发了。这就是无连接和尽力而为传输。用了这个类比之后很多协议概念就都好懂了TCP三次握手就像你拨号、对方接通后互相确认“能听见吗”把这个过程用网络包来回三次的方式确认双方都能收发数据。TCP四次挥手挂电话时双方互相说“那我挂了”确认对方不会再发消息把连接彻底断开。TCP的确认重传你说话对方没听清要求重复。TCP的按序到达通话内容不会乱七八糟一句接一句。UDP不保证顺序、不保证不丢明信片爱几号到就几号到丢了只能认栽。这些都是很朴素的道理但是搞懂了它们你在读那些协议状态的文档时就会觉得每个状态都有画面感而不是一堆必须死记硬背的词条。1.3 选TCP还是选UDP把需求摊开来看很多人一开始都会问TCP这么可靠为什么还有人用UDP这个问题换成“为什么有时候你不打电话非要寄明信片”就明白了。打电话虽然可靠但有成本你得等对方接得一句一句来通话中任何网络抖动都可能造成延迟增大。在某些场景下这个成本是承受不起的。举几个实际场景对可靠性和完整性要求极高的场合比如文件传输、网页访问、数据库连接、远程登录必须用TCP。这类业务丢一个字节都可能导致整个文件损坏、登录失败、SQL语句出错完全不能接受。对实时性要求极高、但可以容忍部分数据丢失的场景比如语音通话、视频会议、在线游戏中的位置同步UDP更合适。你打游戏的时候队友最新的位置信息如果丢了那就按最新一次收到的位置显示就行但如果为了可靠去重传一个半秒前的位置包等它补到了队友已经又跑出一段距离反而更卡、更乱。再比如局域网内设备发现。你往广播地址发一条UDP消息所有设备都能收到这在TCP里实现起来要麻烦得多。而像DNS查询这种本身请求响应就一个包丢了再查一次就行大可不必为每个查询都建一条TCP连接。我在实际项目里的判断标准很简单这个数据如果丢了会造成不可接受的结果吗如果会就选TCP如果丢了重新滑动一下就能自己恢复那UDP可能是更好的选择。另外还有一条辅助判断如果消息的频率很高每秒钟几十上百条而且每条的时效性很短那么多半适合UDP如果消息一天也就几条每一条都关键那么TCP几乎是必然。2. C#里操作Socket的几种姿势以及各自的脾气2.1 System.Net.Sockets.Socket底层原语C#里最原生的方式就是直接使用Socket类所有参数都自己控制地址族IPv4或IPv6、套接字类型流式、数据报式等、协议类型TCP、UDP。创建一个TCP的Socket是这样Socket tcpSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);创建一个UDP的Socket是这样Socket udpSocket new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);用最底层的方式写你的代码会很长因为Bind、Listen、Accept、Connect、Send、Receive全都要自己手动调还要自己管理缓冲区、处理断线异常。但反过来你对全局的掌握也最细。比如我想让某个Socket在关闭后还能让端口快速释放就需要直接操作SetSocketOption配置重用的选项这在封装好的类里反而不太好控制。什么时候用最底层的Socket我个人的经验是当你需要非常规的行为时比如同时处理大量客户端连接、做自定义协议、控制Socket选项、实现多播等直接用Socket类最灵活。平时写业务代码我会先考虑下一节说的封装类。2.2 TcpClient / TcpListener官方给你包好的轮子不要重复造轮子微软在System.Net.Sockets命名空间下已经提供了面向TCP的封装TcpClient和TcpListener。TcpListener负责服务端监听接收客户端的连接请求TcpClient负责客户端连接以及双方的收发数据。它们内部是很薄的一层封装核心还是Socket但帮你把连接、流读取这些高频操作简化了。尤其是TcpClient拿到的是NetworkStream这让很多熟悉Stream读写的人上手非常快可以和StreamReader、StreamWriter、BinaryReader配合。服务端典型结构TcpListener listener new TcpListener(IPAddress.Any, 8080); listener.Start(); while (true) { TcpClient client listener.AcceptTcpClient(); // 每个客户端开一个线程或任务去处理 }客户端典型结构using TcpClient client new TcpClient(); client.Connect(127.0.0.1, 8080); NetworkStream stream client.GetStream(); // 读写 stream这段代码看起来简单但里面藏着很多值得琢磨的地方。AcceptTcpClient是同步阻塞的如果放到UI线程里执行界面会卡死。我在初学的时候就没注意这个窗口点连接后直接“白屏无响应”后来才明白必须把它放到后台线程或者用异步版本。2.3 UdpClient无连接场景下的简化封装UDP对应的封装类是UdpClient。它抹平了“服务器”和“客户端”的边界——大家本质上都是平等端点谁都可以先发消息。发送方写法using UdpClient udpClient new UdpClient(); byte[] data Encoding.UTF8.GetBytes(hello); udpClient.Send(data, data.Length, 192.168.1.100, 8888);接收方写法using UdpClient udpClient new UdpClient(8888); IPEndPoint remoteEndPoint null; byte[] data udpClient.Receive(ref remoteEndPoint);这里有个很关键的小细节Receive方法需要传一个IPEndPoint引用参数接收完成后它会告诉你数据是从哪个IP和端口发来的。你后续如果要回复这条消息就直接用这个地址发不需要提前知道对方是谁。UdpClient用起来确实方便但它是基于Socket的浅封装所以很多底层行为比如广播、多播、设置缓冲区大小仍然可以通过它暴露出来的Client属性即内部的Socket对象来配置。它打开了一条UDP通道我们需要能拿到底层Socket去设置选项这样设计我个人是认可的既要封装方便也保留底层控制的口子。2.4 如何选择一张表帮你决策做选择不是拍脑袋我整理了一个决策表自己每次用之前都会过一遍需求特征推荐方案原因快速实现TCP通信代码量优先TcpClient / TcpListener封装完善流式读写方便快速实现UDP收发UdpClient收发简单内置远程地址处理高并发服务器上千连接Socket 异步需要精细控制每个连接和缓冲区自定义协议非标准行为Socket封装类可能限制了一些选项局域网广播/多播Socket UdpClientUdpClient配合Socket选项实现多播嵌入式设备通信协议自定义TcpClient/UdpClient封装已够用重点在协议一句话总结能用封装类就用封装类封装类不够用了再退到底层Socket。不要为了炫技而直接用Socket也不要不分场景地硬套封装类。3. TCP实战把可靠传输这件事做到位3.1 服务端监听与客户端连接TCP是典型的“服务端主动监听、客户端主动连接”模型。服务端先占好一个端口客户端拿着服务端的IP和端口来“敲门”门开了就能通信。服务端监听的核心步骤我在2.2节已经写过但实际落地时还需要注意一点listener.AcceptTcpClient()每次只接受一个客户端如果要支持多个客户端同时连接必须对每个客户端创建独立的任务来处理否则后面来的客户端会在队列里一直等待而且服务端线程会被第一个客户端占死。这里给出一个最基本的并发服务端骨架我用这种方式起过不少内部演示服务方便且好扩展TcpListener listener new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine(监听中...); while (true) { TcpClient client await listener.AcceptTcpClientAsync(); _ HandleClientAsync(client); } async Task HandleClientAsync(TcpClient client) { using (client) using (NetworkStream stream client.GetStream()) using (var reader new StreamReader(stream)) { string? line; while ((line await reader.ReadLineAsync()) ! null) { Console.WriteLine($收到: {line}); } } }这段代码使用了_ HandleClientAsync(client)意思是不等这个任务执行完就返回立刻去接受下一个连接。这是异步高并发服务器里一个很常见的手法和技巧我在刚接触异步编程时总是习惯写await HandleClientAsync(client)结果服务端就变成了同步串行处理后面的客户端排队等到天荒地老。这点值得专门说一句服务端接受到新连接后处理这个连接的逻辑不要放在接收循环里同步等待。3.2 数据处理读写缓冲区的细节TCP是字节流协议。这五个字很简短但信息量巨大。你在应用层调Send发送一个数组TCP不关心你发的是一个“消息”还是一条“记录”它只负责把这串字节可靠地送到对端。对端调Receive时收到的字节数也不一定和你发送的字节数一一对应。所以Socket编程里读写缓冲区必须很谨慎。一个比较常见的错误是发送方发了1024字节接收方开一个1024字节的缓冲区调一次Receive以为能刚好收到1024字节。实际上这次Receive可能只返回了512字节剩下的512字节还在路上或者还在内核缓冲区里。正确做法是循环读取直到读够期望的字节数或者通过某种规则判断一次完整消息已经接收完毕。我常用的是“先读长度再读内容”的长度前缀方式在3.3节里展开。另外缓冲区大小不等于一次能读到的数据大小。缓冲区只是给你一个临时存放的地方操作系统收到数据后就往这个缓冲里塞塞多少是多少。连续调用多次Receive才能把一次业务消息拼完整这是TCP开发中再正常不过的事。3.3 粘包与拆包最常见也最容易被忽略的问题初学者写TCP通信跑通一次收发后往往信心满满但一旦连续发多条消息就发现问题来了对端收到的数据粘成一团或者一条消息被截成两半。这就是典型的粘包和拆包。原因在于TCP是流式协议它不保留消息边界。我们用Send发了两条消息“Hello”和“World”对端可能一次Receive直接读到了“HelloWorld”这就是粘包也可能第一次读到“Hel”还得再读一次才能拿到剩下的“loWorld”这就是拆包。解决方案一般有三种第一种固定长度。每条消息都规定好长度不够就填充。简单粗暴但浪费流量而且消息长度一旦超过约定的固定值就完蛋了。适合消息长度不会变化很大的场景比如自定义的二进制指令每条都是固定64字节、128字节。第二种分隔符。像StreamReader.ReadLineAsync一样遇到\n或\r\n就算一条消息结束。HTTP协议的头部以及很多基于文本的协议都这么干。实现简单但消息内容里不能出现这个分隔符否则就要转义。第三种长度前缀。这也是我最推荐的做法。发送数据前先用4个字节表示后面内容的长度再在后面拼接真正的数据。接收端先读4个字节解析出长度再按照长度循环读取完整内容。配合BinaryWriter/BinaryReader特别好用。// 发送端 byte[] payload Encoding.UTF8.GetBytes(这是一条业务消息); byte[] lengthPrefix BitConverter.GetBytes(payload.Length); byte[] sendBuffer lengthPrefix.Concat(payload).ToArray(); await stream.WriteAsync(sendBuffer);// 接收端 byte[] lengthBuffer new byte[4]; await ReadExactlyAsync(stream, lengthBuffer, 4); int length BitConverter.ToInt32(lengthBuffer, 0); byte[] dataBuffer new byte[length]; await ReadExactlyAsync(stream, dataBuffer, length); string message Encoding.UTF8.GetString(dataBuffer);ReadExactlyAsync就是循环读直到读满指定长度async Task ReadExactlyAsync(NetworkStream stream, byte[] buffer, int count) { int read 0; while (read count) { int n await stream.ReadAsync(buffer, read, count - read); if (n 0) throw new IOException(连接已关闭); read n; } }这段代码的价值在于它不受粘包和拆包影响每次都能把一条完整消息切割出来。我在一个模拟项目X里面用它来处理自定义Json协议传输几千条消息没有出过问题。3.4 心跳保活与断线重连TCP连接有一条“太长时间没数据传输就会被中间设备掐断”的江湖潜规则。很多路由器、运营商网关会对长时间空闲的TCP连接做回收。即使网络本身没有问题如果两端一直不通信这条连接也可能在某一天悄悄消失。最麻烦的是一端默认连接还活着但另一端已经走了直到你发数据时才发现连接已不可用。解决办法很老套但有效心跳机制。应用层定期发送一个小数据包给对端。对端收到后可以回复一个心跳包。如果连续几次比如3次没有收到对端回复就可以判定连接已经断了客户端需要做重连服务端需要清理掉无效连接。心跳包设计上要注意一个问题不要和业务消息混在一起解析时造成歧义。最简单的是用一种特殊的消息类型区分开比如在消息头里加一个MessageType字段值为1代表业务值为2代表心跳。这样业务逻辑在解析时就可以分开处理。我在写长连接程序时一般把心跳逻辑单独抽出来不写在业务代码里这样业务代码的纯度比较高。具体实现可以用一个定时器每隔30秒发送一次心跳包接收端用了超时机制来判断连接是否空闲超时超时没收到心跳就主动关闭连接。这是一个通用的保活套路。3.5 异步与同步别把主线程卡死很多初学Socket的人写出来的第一版程序是同步的在UI线程里直接调Connect、Receive。UI线程被阻塞界面看起来就像死了一样。还有写服务端的在while (true)循环里同步Accept结果只能处理一个客户端。我自己的经验是上了异步之后代码的复杂度没有增加太多但健壮性提升了一个量级。C#的async/await写起来其实和同步差不多比如AcceptTcpClientAsync、ReadAsync、WriteAsync加个Async后缀再用await修饰一下就可以了。所谓异步的方法是指调用后立刻返回实际操作在后台继续等操作真正完成后再通过await回到正常流程因此不会阻塞调用方的线程。比如UI线程调用await client.ConnectAsync(...)界面可以继续响应用户点击等连接成功后再执行后面的代码。单线程代码一旦多了一个await就要特别留意await之后执行的代码可能已经不在原来的线程上了。在WinForms或WPF里如果你在按钮点击事件里await了一个网络操作操作完成后默认会调度回UI线程所以可以直接更新界面。但如果你用了.ConfigureAwait(false)那就不会回到UI线程此时更新界面必须用同步上下文。很多朋友在这里踩坑界面控件直接报“线程间操作无效”原因就在这。4. UDP实战无连接通信的正确打开方式4.1 UDP收发的正确姿势UDP没有“连接”的概念通信双方地位平等。你可以先启动一个接收端也可以先让发送端发几条消息接收端稍后再上线。只要你发的数据包到达时接收端已经在监听那个端口它就能收到。这个特性在局域网设备发现、传感器数据上报、多人游戏位置同步等场景下非常合适。设备开机后向局域网内定期广播自己的存在客户端在同一个网段监听广播消息就能自动发现设备而不用手动配置IP。UDP的接收端需要绑定一个端口using UdpClient udpServer new UdpClient(9000); IPEndPoint? remoteEP null; while (true) { byte[] data udpServer.Receive(ref remoteEP); string msg Encoding.UTF8.GetString(data); Console.WriteLine(${remoteEP.Address}:{remoteEP.Port} - {msg}); // 如果需要回复 byte[] response Encoding.UTF8.GetBytes(收到); udpServer.Send(response, response.Length, remoteEP); }注意这里Receive是阻塞的。要在后台线程或者async环境中调用否则同样会卡住主线程。UdpClient提供了ReceiveAsync可以配合await使用。4.2 可靠性谁来保障应用层要做的事UDP不保证数据一定到达、不保证顺序、不保证不重复。那么问题和矛盾就会出现如果真的需要一定程度的可靠性怎么办答案是在应用层自己实现可靠性机制。我用过的一个轻量方案是这样的发送方给每条消息分配一个自增的序号同时启动一个重传定时器。发送后如果在规定时间内没有收到接收方的确认包ACK就重新发送这条消息。接收方收到消息后检查序号如果发现比上次大就正常处理并回复一条ACK如果收到重复序号就直接丢弃但也要回复ACK防止发送方误以为丢失而反复重传。这个方案并不复杂但你会发现这不就是TCP在做的事情吗确实如此。如果你需要的可靠性和TCP一样强那不如直接用TCP。应用层做可靠性通常是因为想要UDP的低延迟和低开销而只需要“尽力而为但还不够可靠”的折中。常见的做法是只处理丢包不保证严格顺序或者允许乱序但要求不重复。举个例子游戏里玩家移动的消息即使偶尔丢了一两帧后续帧也会很快刷新位置但如果一直丢包玩家位置就会瞬移体验极差。所以游戏开发者常常会实现一套针对UDP的可靠性增强每条消息同样需要确认和重传。这是UDP协议栈之上的一层自定义封装。不过这里我不建议新手一上来就自己实现整个可靠UDP协议栈那是个很深的坑。大多数场景下把消息频率提高、序号带上、做简单去重已经能满足需求。4.3 局域网广播与多播UDP有个特别有用的能力广播。给255.255.255.255这个广播地址发消息同网段的所有主机都能收到。这在设备发现、服务广播场景中特别常用。广播在UdpClient里用起来要开个开关UdpClient udpClient new UdpClient(); udpClient.EnableBroadcast true; byte[] data Encoding.UTF8.GetBytes(discover); await udpClient.SendAsync(data, data.Length, 255.255.255.255, 9000);注意EnableBroadcast这个属性必须在发送广播包之前设置为true否则发广播地址会抛异常。我曾经在这上面吃过大亏写了代码发不出去一查才发现是系统默认不让你往广播地址发送数据。在实现服务端发现协议时我还加了等待响应的逻辑比如发送后监听广播对应的端口收集设备回复。多播组播比广播更进一步它允许你指定一组设备接收同一个地址的数据。比较典型的是视频直播、证券行情推送等一对多场景。C#里通过Socket的SetSocketOption加入多播组Socket socket new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); IPAddress multicastAddress IPAddress.Parse(239.0.0.1); socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(multicastAddress, IPAddress.Any)); socket.Bind(new IPEndPoint(IPAddress.Any, 8000));多播相比广播能跨网段传播也只在感兴趣的主机之间传播效率更高但路由器需要启用组播协议和对应路由配置配置要复杂不少。局域网小范围使用时广播往往够用了。4.4 UDP适合的场景和反例哪些场景我支持用UDP局域网设备发现 / 服务发现广播一条消息等着响应。用TCP你要维护一堆连接成本高。实时游戏状态同步位置、角度、动作可以容忍偶尔丢包但不能容忍延迟。每次接收端取最新一份数据即可。音视频流传输已有的RTP协议就是基于UDP的丢了几个包最多画面花一下或声音卡一下重传反而更糟。高频遥测数据上报每秒钟若干次上报丢了一次下一轮会再报应用端取最新值就好。反例也很明显文件传输、账号登录、数据库连接、交易请求、消息队列。这些场景里丢一个字节都可能产生不可接受的后果不要用UDP。也不要用UDP去实现一个你以为很简单的自定义可靠协议数据传输不可靠时排查成本极高调试时还会因为丢包产生大量让人崩溃的偶现问题。5. 实测排坑实录我从这些坑里爬出来的经验5.1 端口起不来或连接被拒绝端口起不来最常见的两个原因权限不够和端口被占用。系统对1024以下的端口要求管理员权限。你在本地调试代码里写new TcpListener(IPAddress.Any, 80)跑起来报错很可能就是因为当前用户权限不足。端口被占用就更常见了。程序崩溃后端口没有正常释放或者上一个实例还开着你就起不了新监听。排查手段很简单netstat -ano | findstr 9000看到占用PID之后去任务管理器里把这个进程结束掉。另外程序关闭时记得主动关掉Socket和Listener如果设置了端口复用在Socket上设置ReuseAddress关闭后立刻重启也能较快重新绑定。我记得有一次在开发环境里程序停掉之后端口还在TIME_WAIT状态导致重启失败加了这个选项之后就好了。连接被拒绝Connection refused通常意味着目标端口上根本没有服务在监听或者防火墙把包拦了。本地测试时可以先在本机telnet 127.0.0.1 9000试一下。通了说明配置正常不通就查服务是否真实启动端口是否绑定正确。5.2 收发数据时遇到“程序卡死”程序卡死在Socket编程里几乎是“经典必演节目”同步阻塞调用放在了UI线程。UI卡死还好排查怕的是后台线程也被阻塞比如你在一个长时间运行的循环里调用同步Receive而对方一直没有发数据这个线程就永远卡在那了就像大家看到的那样CPU占用低、程序也无响应。解决思路有三条第一能用异步就用异步。ReadAsync、WriteAsync能避免线程白白挂起。 第二给阻塞操作设置超时。Socket.ReceiveTimeout可以在接收时设置毫秒级超时超时会抛异常你捕获后做重试或关闭连接。这个方法适合一些简单场景但要注意并不是所有环境都精确可靠。 第三设计一套合理的协议如果客户端预期要收到服务端的响应就规定好响应超时时间超时了做重连或者给用户提示而不是傻等。5.3 抓包定位问题会看才是王道我调试网络问题最常用的手段是抓包。Wireshark、tcpdump这类工具能把你在代码里看不见的底层过程全部展示出来TCP连接怎么建立的、数据包怎么拆分的、哪一段数据丢了还是被重传了、连接是怎么断开的一目了然。什么时候该抓包两端都在本地、数据明明发了但对方收不到或者自己觉得发了多条消息对端却说只在一条里面或者通信用了一段时间突然断开且毫无规律。这种时候先不要改代码先抓包看真实情况。举个例子处理粘包问题的时候抓包工具能看到客户端确实发了两段数据服务端只调用了一次Receive就把两段都读走了。这就能证明问题出在应用层消息边界不明确而不是底层丢了数据。不少人喜欢自己在代码里打Console.WriteLine去猜我建议早点学会看抓包结果效率高得多。5.4 性能与缓冲区调优关于性能我自己总结过几条实用的调优经验第一缓冲区别开太大也别开太小。网络流接收时如果缓冲区只有4KB而一条消息有10KB你的循环要读三次效率低。反过来每个连接都开1MB缓冲区1000个连接就白白占了1GB内存不可取。实际开发中我习惯先用4KB试读配合长度前缀动态扩容到恰好能容纳一条消息的大小。第二发送数据时尽量避免一条消息发一个Send调用对于高频小包可以批量合并减少系统调用次数。这个优化在局域网环境下可能感受不明显但到了公网或者高延迟环路上效果立竿见影。第三UDP发送频率过高时内核缓冲区很容易被填满之后新到的数据包会被直接丢弃。代码里可以通过Socket.SendBufferSize和Socket.ReceiveBufferSize调大缓冲区但要清醒意识到调大缓冲区只是延后了丢包并不能根治发送端速度大于接收端处理速度的问题。真正的根治办法是控制发送频率或者增加接收端的消费能力。5.5 与多个客户端同时通信时的资源管理服务端一旦和多个客户端通信管理连接资源就成了一门学问。我的建议是连接集合用线程安全的容器比如ConcurrentDictionaryTcpClient, string来维护客户端标识每次收到数据时可以带上客户端标识方便定位是哪台设备发来的。另外一定要避免在客户端断开时不清理资源。如果不主动移除对应的TcpClient一方面内存泄漏另一方面遍历集合发送数据时会对已断开的连接执行发送操作出现异常后如果没有捕获整个服务可能崩掉。我在公共代码里通常会把“移除连接”和“异常处理”放进一个finally块确保无论是否发生了异常资源都能被释放。在此也分享一个小技巧服务端给所有客户端广播消息时如果某一个客户端抛了异常不要中断整个广播循环捕获该客户端的异常并把它从集合中移除然后继续给下一个客户端发送。这样单个客户端的异常就不会拖垮整个服务器。写在最后C# Socket编程说难不难说简单也不简单难处通常在TCP/UDP协议细节和边界情况。我做了这么多项目最深的体会是写网络通信代码永远不要只看“正常的路径”要经常问自己——对端不按常理出牌怎么办数据发了一半断了怎么办对方重复发送了怎么办把这些问题想清楚了代码自然就稳了。最后再分享一个小技巧初学者不要一开始就追求封装各种高大上的框架先写一个能跑通的TCP客户端和服务端再抓包看它们三次握手和四次挥手的过程然后把代码改成异步最后加上粘包处理。沿着这条路走下来你对C# Socket编程就算是真正入了门。后面无论再去接触更底层的网络编程还是转去做更复杂的物联网通信架构都会顺畅得多。这几千字算是我自己踩坑总结的“还愿文”希望能帮到愿意在这一行认真打磨的你。
阅读完成 · 觉得有帮助?
咨询建站