1. 从一次"丢包事故"说起:UDP的真实面目
两年前我做了一个数据采集类的项目,终端设备通过无线网络持续上报状态,场景要求低延迟、高并发、容忍偶发数据丢失。技术评审时我和另一个同事吵了一架:他坚持用TCP,理由很朴素——"TCP不丢包,安全"。我最后选了UDP,因为设备量上来之后,TCP的连接维护成本、队头阻塞、心跳保活会变成一场噩梦。
那次项目最终平稳落地,但中间踩了不少坑。UDP这个东西,很多人对它的理解停留在"不可靠的TCP"这种粗浅层面,而"自定义Socket类封装"这四个字背后的工程量,远远不是一个SendTo和ReceiveFrom就完事的。
这篇博文不打算写教科书式的协议科普。我会从协议底层的行为边界讲起,再用完整的C#代码带你走一遍"自定义Socket类封装"从设计到落地的全过程,包括重连策略、状态事件、粘包拆包思路,以及我在真实项目中踩过的坑。无论你是刚开始接触网络编程的学生,还是正在为某个采集、游戏、实时音视频项目选型的开发者,这篇内容都值得你花十分钟读完。
2. UDP的底层逻辑:数据报、乱序、边界,这三个"坑"其实是特性
2.1 数据报模型:每条消息都是独立个体
TCP是流协议,它眼里没有"消息"这个单位,只有一连串没有边界的字节。应用层往TCP连接里写了100个字节,对端可能一次读到50个、下一次读到50个,也可能一次读到100个甚至更多。为了还原业务消息,你必须自己设计消息边界——这就是粘包/拆包问题存在的根源。
UDP是数据报协议,它天然以"封包"为单位:每一次SendTo(或Send)对应一个独立的UDP数据报,接收端每次ReceiveFrom拿到的就是完整的一包,不多不少。这个特性让UDP在"消息边界天然对齐"这个维度上,比TCP省心不是一星半点。
但"独立数据报"也意味着:网络层没有"虚拟连接"的概念。TCP的连接是四元组(源IP、源端口、目的IP、目的端口)在内核中维护的一张状态表;UDP则完全无状态,内核的UDP socket只是"接收数据报的收件箱"。你发出去的包,路由器、交换机只按目标IP尽力转发,谁也不会记得你曾经发过什么。
这就带来第一个认知转变:UDP的系统开销小,是因为它把"连接状态"这个包袱扔给了应用层。各有各的命,你不背着,就要自己处理。
2.2 不可靠的三重体现:丢、乱、重
"UDP不可靠"具体落在三个维度:
- 不保证到达:网络拥塞导致路由器丢包、接收缓冲区溢出导致内核丢包、中间链路故障导致数据包被黑洞,UDP一概不管。
- 不保证顺序:互联网层是分组交换,同一主机连续发出的两个UDP包,可能走不同的物理路径,后发的先到一点都不奇怪。
- 不保证不重复:数据链路层的重传、某些网关的重发机制,会造成同一个UDP包被投递多次。
这三个特性,我在实际项目中几乎全部遇到过。最典型的是跨运营商网络传输,某段时间丢包率能到3%~5%,而应用层没有做任何补偿,接收方数据出现明显空洞;而UDP重复投递的问题,在双WAN口路由器环境或Wi-Fi重传场景下也不少见。
如果做一个类比:TCP是"打电话"——接通后两个人都占着线,说一句一定能听到一句,但建立通话的成本高,且通话中不能随便插话。UDP是"发快递单号不回的盲盒"——每件货物是一个独立包裹,不用提前打招呼,寄出就完了,收不到不补发,可能还会因为运输线路不同而错序到达。
2.3 边界属性:封装类设计的命门
数据报边界是UDP封装时绕不开的设计约束。发送端A连续发了两包数据(假设每包1KB),接收端B如果只调用了两次ReceiveFrom,恰好这两次调用各收到一包;但如果B的处理速度跟不上,内核缓冲区里积压了两包,B下一次ReceiveFrom只会取出其中一包——剩下的那包会继续留在内核缓冲区,等下次调用再取出。
这个行为意味着:UDP的"消息边界"只是内核在包级别做隔离,不代表应用层拿到的每一包都是"完整业务消息"。如果一个业务消息被拆成多个UDP包发送,接收端必须自己组装;如果一个UDP包包含了多个业务消息(极少数场景,发送端人为拼包),接收端也必须自己拆分。
我见过不少初学朋友写UDP收发,上来就默认"一次发送 = 一次接收 = 一条完整消息",这在99%的简单Demo里没问题,但在真正的业务系统里,必须把UDP包和业务消息这两个概念彻底拆开。设计Socket封装类的时候,我会明确约定:一个UDP包 = 一个业务消息,如果业务消息太大就拆包标记序号,如果太小就补个协议头方便对端校验。这个约定应该写进封装类的设计文档,作为使用者的契约。
3. 选UDP,你得到什么,又付出什么
3.1 什么时候非它不可
- 实时交互场景:游戏帧同步、语音通话、视频会议。这类场景对延迟的敏感度远高于对可靠性的需求——你丢失一帧游戏画面能忍,但卡顿半秒骂声一片。
- 海量连接与广播场景:设备发现(局域网广播)、传感器数据采集、流媒体分发(组播)。TCP在有N个接收方时,需要建N条连接逐一推送;UDP一条组播地址,整个组自动收到。
- 弱网容忍度高的业务:GPS定位点上报、日志上报、遥测数据。偶尔丢一包,下一次上报就补上了,业务无感知。
我那个采集项目选UDP的核心理由,本质是省连接、低开销:一万台设备如果每台都维持一条TCP长连接,服务器光维持FD和保活心跳就是一个巨大负担;UDP无连接,服务器一个socket就能收所有终端的包,按源IP和端口识别终端,内存占用小了一个量级。
3.2 代价清单:可靠性全要自己做
选UDP等于明确告诉业务方:"丢包、乱序、重复,我统统不管。"你必须在上层补齐以下能力:
- 序列号:每个数据报打上一个递增的序号,接收方据此检测乱序和空洞。
- 确认机制:接收方定期回
ACK(确认包),发送方根据ACK判断哪些包已送达。 - 重传策略:发送方超时未收到ACK,对特定序列号的数据报重传。这里注意UDP没有TCP的滑动窗口和拥塞控制,重传节奏必须自己把握,别把网络打爆。
- 去重:接收方根据序列号丢弃重复数据报。
- 流量控制:TCP有内核级缓冲和通告窗口,UDP完全没有。发送方如果疯狂往一个网段灌包,接收方缓冲区溢出的那一刻,就是丢包高峰的开始。
这些能力在项目里不需要一次性全会。我们后来实际只做了序列号+乱序重排+超时补发的子集,已经覆盖了95%的业务需求。但如果你的业务系统对可靠性要求高(比如金融交易、文件传输、数据库同步),我劝你别犟,老老实实上TCP或者TLS上层的可靠协议。UDP的正确使用姿势是"该可靠的地方自己补,不该可靠的地方别硬套"。
3.3 业界常用的补偿思路,供你参考
- RUDP(可靠UDP):在UDP之上实现类似TCP的确认、重传、滑动窗口,常见于游戏框架。
- QUIC:新一代低延迟协议,基于UDP但自带加密、重传、多路复用,已被HTTP/3采用。如果你是新项目又对连接管理烦恼,可以考虑QUIC类库。
- FEC(前向纠错):把N个原始包异或出M个冗余包一起发送,接收方收到任意N个包就能恢复全部数据。对音视频场景特别有用。
我们采集系统用的是轻量补偿:业务消息加1字节序列号,接收端维护最近100个序号做去重,发送端每30秒补发一次最近5秒内的"关键状态包"。简单有效,成本极低。
4. 原生Socket先跑通:UDP收发的最小系统
4.1 服务端三步走
用C#的System.Net.Sockets写一个最小UDP服务端,其实就三件事:
using System.Net; using System.Net.Sockets; using System.Text; // 1. 创建UDP socket并绑定本地端口 UdpClient server = new UdpClient(new IPEndPoint(IPAddress.Any, 9010)); Console.WriteLine("UDP 服务端启动,监听端口: 9010"); // 2. 接收数据报(阻塞式) while (true) { UdpReceiveResult result = await server.ReceiveAsync(); string msg = Encoding.UTF8.GetString(result.Buffer); Console.WriteLine($"收到来自 {result.RemoteEndPoint} 的消息: {msg}"); // 3. 可选:回包给客户端 byte[] resp = Encoding.UTF8.GetBytes("服务器已收到"); await server.SendAsync(resp, resp.Length, result.RemoteEndPoint); }需要注意的点:
UdpClient是Socket的浅封装,核心逻辑还是Socket.ReceiveFrom/SendTo,只是对外暴露了更友好的API。ReceiveAsync在连续调用之间会自动把多包排在内核缓冲区,但它不会保证每次拿到的包是"同一业务消息",因为UdpClient没有拆包能力。你在业务代码里还是要自己解析消息头。- 服务端在这种写法下是单线程处理,一包一处理。如果处理耗时大于包到达间隔,积压不可避免,后续章节会讲怎么改进。
4.2 客户端三步走
using System.Net; using System.Net.Sockets; using System.Text; UdpClient client = new UdpClient(); IPEndPoint serverEndpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9010); // 1. 发送数据 byte[] msgBytes = Encoding.UTF8.GetBytes("Hello UDP!"); await client.SendAsync(msgBytes, msgBytes.Length, serverEndpoint); // 2. 接收响应(可选) UdpReceiveResult result = await client.ReceiveAsync(); string respMsg = Encoding.UTF8.GetString(result.Buffer); Console.WriteLine($"收到响应: {respMsg}"); // 3. 关闭 client.Close();这里有个反直觉的地方:客户端不需要Bind/Connect就能发数据,因为SendAsync每次都会指定目标端点。系统会自动给这个UdpClient分配一个临时端口,服务端回复的时候就是回到这个临时端口。
如果客户端先调用了Connect(serverEndpoint),那么后续SendAsync就不再需要指定端点,并且socket过滤了非该服务端发来的包。这个"半连接"状态是UDP特有的——它只在本地维护了对端地址,并没有真正的线上握手。封装类里我会用这个特性做"虚拟连接"。
4.3 几个原语级陷阱,封装前必须知道
- 阻塞与丢包的关系:
ReceiveFrom在socket缓冲区为空时会阻塞线程。缓冲区溢出时,后续到达的包会被内核直接丢弃,所以接收线程的延迟直接等于丢包率。如果某个耗时操作(比如解析JSON、写数据库)放在了接收线程里,你的UDP socket在那一瞬间开始疯狂丢包。 Socket.ReceiveFrom的ref端点参数:每次调用都要传入一个EndPoint引用,调用后它会更新为发送方的IP和端口。很多人误以为这个参数是"筛选接收来源",其实是输出参数。- 非阻塞模式与异常:把socket设为非阻塞后,没有数据时
ReceiveFrom会抛SocketException(WouldBlock)。在封装类里,我建议用async/await或SocketAsyncEventArgs而非纯非阻塞循环,否则异常处理会变得很绕。
5. 自定义Socket类封装:从"能用"到"好用"
5.1 封装类设计目标与对外接口
封装不是为了炫技,是为了让业务方三行代码搞定UDP收发。我最终设计的封装类叫UdpSession,它对外暴露这些核心能力:
public class UdpSession : IDisposable { // 构造函数:绑定本地端口、指定远端地址(可选) public UdpSession(int localPort, IPEndPoint remoteEndpoint = null); // 生命周期 public void Start(); public void Stop(); // 发送 public bool Send(byte[] data, IPEndPoint remoteEndpoint = null); // 无连接时指定目标 public bool Send(string msg, IPEndPoint remoteEndpoint = null); // 字符串重载,内部转UTF8 // 接收事件 public event Action<IPEndPoint, byte[]> DataReceived; public event Action<SocketError> ErrorOccurred; // 虚拟连接与心跳 public void ConnectVirtual(IPEndPoint remoteEndpoint); // 指定远端,之后Send无需再填 public void KeepAlive(TimeSpan interval); // 定期发保活包,防NAT超时 // 状态属性 public bool IsRunning { get; } public int LocalPort { get; } }设计这个接口时有几个核心决策,我逐个说明白。
5.2 为什么要加"虚拟连接"和ConnectVirtual
UDP是无连接的,但业务侧很多时候需要一个"逻辑上的对端"。ConnectVirtual这个方法的本质是把远端端点保存为类的成员字段,之后调用Send(data)时自动带上这个端点——这个动作让调用方不必每次传入端点,代码语义上也更贴近"连接"的感觉。
从底层实现上,我推荐在Send里直接使用Socket.SendTo并每次传入目标端点,而不是调用Socket.Connect。原因有两条:
Socket.Connect在UDP下会改变socket的过滤行为——它只接受来自该端点的数据报。开发调试时,如果你需要同时收多个来源的包,这种行为反而是麻烦。- 保持"无连接"状态,你的
UdpSession才能灵活地做"一收多"(比如服务器端接收所有终端的上报)。如果你需要"一收一"过滤,可以单独在接收逻辑里判断RemoteEndPoint是否匹配。
5.3 完整的UdpSession实现(C#)
下面是我在实际项目里用过的核心实现,去掉了一些业务耦合的部分,保留了完整骨架:
using System; using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; public class UdpSession : IDisposable { private Socket _socket; private IPEndPoint _localEndpoint; private IPEndPoint _virtualRemote; // 虚拟连接端点,可空 private CancellationTokenSource _cts; private ConcurrentQueue<IPEndPoint> _pseudoModeQueue; public event Action<IPEndPoint, byte[]> DataReceived; public event Action<SocketError> ErrorOccurred; public UdpSession(int localPort = 0, IPEndPoint remoteEndpoint = null) { _localEndpoint = new IPEndPoint(IPAddress.Any, localPort); _virtualRemote = remoteEndpoint; _socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); _socket.Bind(_localEndpoint); // 增大收发缓冲区,减少溢出丢包 _socket.ReceiveBufferSize = 1024 * 1024; // 1MB,可调到4MB _socket.SendBufferSize = 1024 * 1024; // 1MB } public bool IsRunning { get; private set; } public void Start() { if (IsRunning) return; IsRunning = true; _cts = new CancellationTokenSource(); // 使用独立线程持续收包,避免阻塞调用方 Task.Run(() => ReceiveLoop(_cts.Token), _cts.Token); } public void Stop() { if (!IsRunning) return; IsRunning = false; _cts?.Cancel(); _cts?.Dispose(); _cts = null; } private void ReceiveLoop(CancellationToken token) { byte[] buffer = new byte[65535]; // UDPMaxSize - IP/UDP头,留够余量 EndPoint remoteEP = new IPEndPoint(IPAddress.Any, 0); while (!token.IsCancellationRequested) { try { int length = _socket.ReceiveFrom(buffer, 0, buffer.Length, SocketFlags.None, ref remoteEP); byte[] data = new byte[length]; Array.Copy(buffer, data, length); var sender = (IPEndPoint)remoteEP; DataReceived?.Invoke(sender, data); } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TimedOut) { // 超时是正常循环的一部分,忽略 } catch (SocketException ex) { ErrorOccurred?.Invoke(ex.SocketErrorCode); if (token.IsCancellationRequested) break; // 让CPU稍微休息,防止错误风暴 Thread.Sleep(10); } catch (ObjectDisposedException) { break; } } } public bool Send(byte[] data, IPEndPoint remoteEndpoint = null) { IPEndPoint target = remoteEndpoint ?? _virtualRemote; if (target == null) return false; try { _socket.SendTo(data, target); return true; } catch (SocketException ex) { ErrorOccurred?.Invoke(ex.SocketErrorCode); return false; } } public bool Send(string msg, IPEndPoint remoteEndpoint = null) { byte[] data = Encoding.UTF8.GetBytes(msg); return Send(data, remoteEndpoint); } public void ConnectVirtual(IPEndPoint remoteEndpoint) => _virtualRemote = remoteEndpoint; public void Dispose() { Stop(); _socket.Close(); _socket.Dispose(); } }看到这里你应该能发现,核心收包在ReceiveLoop里是单线程同步阻塞式的。这是我在性能与简单性之间权衡后的选择,后面会讲如何升级为多线程处理。
5.4 每个设计决策背后的理由
- 缓冲区设到1MB:默认的UDP接收缓冲区在Windows上只有8KB,一旦对端发包快于处理速度,内核会立即丢包。我在项目里被这个坑过:服务端在高峰期丢包率飙到10%,调大缓冲区后降到接近0。这是成本最低、效果最明显的优化手段。
- 接收线程与业务线程分离:
ReceiveLoop只负责把数据报从内核取出来,DataReceived事件触发后,业务逻辑在事件处理者那边执行。如果业务逻辑耗时,接收线程不会阻塞(前提是事件不要同步调用耗时函数——后面讲怎么处理)。 - 65535的接收缓冲:IPv4下UDP数据报最大长度为65507字节(65535 - 20字节IP头 - 8字节UDP头),超过这个大小的数据会被对端IP层拒绝,所以单次接收缓冲定为65535完全够用。我见过有人设成1024然后发现消息被截断的,那纯粹是缓冲区不够导致的问题。
- CancellationToken优雅退出:很多人写UDP循环用
while(true),停止时直接Close socket,但这会让接收线程抛一堆异常。用CancellationToken检查配合Socket.Close,能实现相对平滑的退出。
6. 进阶:状态事件、重连策略、拆包逻辑,把封装推向"生产可用"
6.1 数据风暴保护与背压机制
当流量超过处理能力时,DataReceived事件处理函数里如果做了耗时的数据库写入,接收线程就会被拖慢。一个值得推荐的实现是把接收事件的道处理放到独立线程池:
private void ReceiveLoop(CancellationToken token) { while (!token.IsCancellationRequested) { int length = _socket.ReceiveFrom(...); byte[] data = new byte[length]; // 关键:快速取包,把业务处理丢到线程池 _ = Task.Run(() => DataReceived?.Invoke(sender, data), token); } }但这样做的隐患是:如果业务处理比生产速度快,线程池里的任务会无限积压,内存暴涨。现实中的做法是加一个并发上限限量:
private SemaphoreSlim _slots = new SemaphoreSlim(200, 200); private void ReceiveLoop(CancellationToken token) { // ... await _slots.WaitAsync(token); _ = Task.Run(() => { try { DataReceived?.Invoke(sender, data); } finally { _slots.Release(); } }); }这个SemaphoreSlim就是一种背压机制:超过并发上限时,后续数据报在接收循环处等待。这是对"无限积压"的矫正,代价是接收暂停,但内核缓冲区填满后会丢弃新包——丢的是最晚的包,而不是让人处理不过来的包堆死整个进程。
6.2 "重连"在UDP里到底重什么
TCP断开后重连需要重新完成三次握手,而UDP没有握手,所以"重连"就变成一个和应用层状态相关的概念。我在封装类里加了这么一种策略:
- 应用层定义"虚拟连接超时"(比如10秒内没收到对端任何包,判定链路不可用)。
UdpSession.OnLinkLost事件通知业务层,业务层可以重新调用ConnectVirtual设置新的对端地址,或者触发上层资源重分配。- 内部保活:
KeepAlive(TimeSpan)启动一个定时器,每隔一段时间发一个应用层定义的保活包。它有两个作用:一是让中间NAT设备记住映射(很多NAT设备2~5分钟不活动就忘掉映射,导致对端发不回包);二是让对端感知到我方还活着。
这个"伪重连"设计,我在数个跨公网的项目中都验证过——NAT超时导致的"连不上"问题,用保活包基本能解决。
6.3 消息拆包与重组:什么时候做、怎么做
我在前面强调过"一个UDP包 = 一个业务消息"的约定。但总有业务消息超过65507字节上限的情况,比如传输一张几MB的图片。这时候必须在协议层做拆包重组。
拆包协议头我常用的设计是:
| 字段 | 长度 | 说明 |
|---|---|---|
| Magic | 2字节 | 固定0x5A5A,用于校验和快速识别 |
| MsgId | 4字节 | 业务消息唯一ID |
| TotalCount | 1字节 | 总包数 |
| CurrentIndex | 1字节 | 当前包序号(从0开始) |
| PayloadLen | 2字节 | 本包数据长度 |
| Payload | 0~65500字节 | 真正的业务数据 |
接收端维护一个Dictionary<uint, PacketAssembler>,按MsgId聚合子包,等所有序号齐全后拼接并触发MessageCompleted事件;如果超过X秒没集齐,清理缓存防止内存泄漏。
这个机制我放在UdpSession之外,作为独立的UdpMessageFramer组件存在。我觉得封装类不用把协议解析也包进去,否则职责过多,新手看不懂也不方便定制。封装类管传输管道,协议解析交给业务层,这个边界应该保持。
6.4 收包回调到底该用事件还是消息队列
很多封装类把收包直接暴露为事件,但要注意:如果你在事件里也做了耗时逻辑,还是会回头坑接收线程。我在项目里的最终方案是,组装了一个ConcurrentQueue<UdpMessage>,接收线程只入队,业务侧有一个消费者'Pull'方法取消息:
private ConcurrentQueue<(IPEndPoint sender, byte[] data)> _rxQueue = new(); public void Start() { /* 启动接收线程,入队 */ } public bool TryDequeue(out IPEndPoint sender, out byte[] data) { if (_rxQueue.TryDequeue(out var item)) { sender = item.sender; data = item.data; return true; } sender = null; data = null; return false; }这种做法让UdpSession彻底解耦处理逻辑——调用方自己决定是起专用线程消费队列,还是在定时器里批量拉取。我们后来在某个日志采集系统里就是这么干的,配合批量写库,吞吐量翻了一倍。
7. 实战中的那些坑:从缓冲区到NAT,一次性说清楚
7.1 缓冲区、丢包与"片刻的爽快"
调试UDP时最迷惑人的现象是:局域网测试一切正常,一到公网就丢包。原因往往不是你的代码,而是网络链路中某个路由器的缓冲区。公网链路上每一跳路由器的转发能力不同,突发流量来了就随机丢。
应对经验:
- 发送端能
SendTo就尽快发,不要攒批;攒批会让网络出现瞬时尖峰,更容易触发中间节点丢包。 - 接收端
ReceiveBufferSize设大一点,但也不要无脑大——缓冲区越大,积压的旧包越多,实时性越差。我们最后取1MB,兼顾实时和容错。
7.2 多网卡与IP地址绑定
如果你的服务器有两块网卡(比如一个内网、一个公网),IPAddress.Any绑定会监听所有地址,但回复包所用的源IP由内核路由表决定,不一定是你期望的那块网卡。
解决方式是Bind到具体网卡的IP,或者根据来源网段选择回复的源地址。封装类里我留了一个LocalIPAddress配置项,默认Any,但文档里明确标注了多网卡场景必须显式指定。
7.3 NAT穿透:别一上来就想着"打洞"
跨公网的UDP通讯,最大障碍就是NAT。很多刚入行的朋友一听到"UDP做穿透",立刻就想STUN、TURN那套打洞方案。实际上,如果你设备与服务器之间是客户端主动连服务器(即服务器有公网IP),那根本不需要打洞——客户端只要定期发保活包,NAT映射就维持住了,服务器随时能回包。
真正需要打洞的是两个内网设备点对点沟通。这种场景我建议先别自己造轮子,用现成的P2P框架或者信令中转方案,避免陷入NAT类型的深坑。我的采集系统后来就改成了"终端主动上报+服务器长期响应"的模式,完全绕开了打洞的复杂度。
7.4 序列号与去重:几乎免费的高性价比增强
给每个业务消息加一个2字节的序列号,接收端根据序列号做乱序重排和去重,这个成本低到可以忽略,但能解决的问题却很致命——尤其是遥控指令类业务,一条指令被重复执行两次,后果可能是设备状态错误。
我在封装类里给Send方法加了可选参数:
public bool Send(byte[] data, ushort seq, IPEndPoint remote = null) { // 外面凑好头,这里只做透传 return Send(data, remote); }序列号生成逻辑放在业务层,避免你的封装类和具体业务协议绑定。
8. 最后聊点实在的:封装类落地后的取舍
这个UdpSession我在两个真实项目里迭代了三版,第一版就是上面那个"事件+单接收线程"的雏形,第二版加上了虚拟连接和保活,第三版把收包队列独立出来,才算真正扛住了生产流量。
如果你要自己去实现一个UDP封装类,我给三条建议:
- 先定义边界,再写代码。明确哪些是"传输层职责"(socket生命周期、收发包、缓冲区配置),哪些是"协议层职责"(消息头、序列号、拆包组包)。职责不清晰,后面扩展一定别扭。
- 把调试手段内置。封装类里加一个
EnableTrace,打开后把每次收发包的端点、长度、序号打出来。公网排障时,没有包级日志等于盲人摸象。 - Stop方法一定要容错。实际开发中总有人忘记调用,或者在回调里再次调用Stop,此时你的类必须保证不抛异常、不卡死。C#里对
_cts?.Cancel()这类空安全写法,不是风格问题,是工程素养。
UDP没有TCP那样"开箱即用的可靠性",但也正因为没有,你拥有了对延迟、流量、连接模型的完全控制权。把封装类设计好,UDP完全能成为一套又快又稳的通讯底座。希望这篇内容能把你在UDP踩坑的时间省下来,不管最后你是照着敲代码,还是受它启发设计了更适合业务的协议,都算值得。