C#高性能TCP服务器与客户端:异步IO、粘包处理与断线重连实战
简介由C#语言基于完成端口IOCP实现的高性能TCP网络通信完整源码面向需要编写高并发服务端与客户端的.NET开发人员。资源包含IocpServer、IocpClient及上下文对象池等核心模块可直接在Visual Studio 2010/.NET 4.0环境中运行默认监听9900端口适合学习IOCP原理或快速搭建即时通讯、长连接服务。压缩包共59个文件大小仅122KB以13个cs源码文件为主另含项目配置、可执行程序、资源文件与调试符号目录结构清晰便于按模块对照阅读项目同时提供服务端与客户端双向通讯演示可直接运行验证。当前已有1302人学习下载。代码封装了网络通讯类提供高并发下数据收发测试实测双核2G内存即可支撑5000以上客户端连接同时给出异步Socket、对象池与并发模型的落地示范并整理了本机IP更换、防火墙拦截等常见调试注意事项是一份轻量实用、可直接改用的C#网络编程参考。1. 从“能跑”到“能扛”C#高性能TCP服务器/客户端到底在解决什么问题做工业上位机、自研网关或者游戏后端的人基本都经历过同一个剧本用C#写一个TCP服务端第一版用同步Accept加上每连接一个线程在线数一两百就开始卡CPU飙到八九成客户端稍微多点就握手超时。标题里的“c#完全端口高性能网络TCP服务器和客户端源码”我理解它就是一套把Socket生命周期、收发缓冲、粘包处理、重连机制都管起来的完整C# TCP通讯框架拿来能在自己的项目里直接跑。真正要解决的问题不是“怎么连上”而是怎么让连接在低资源占用下保持稳定、不丢包、不粘包、断线能自己恢复。适合读的人写上位机与PLC/设备通讯的、做自定义TCP协议网关的、给硬件调试工具做服务端的。2. 高性能TCP服务端从Socket到异步IO的建模思路2.1 为什么用异步Socket而不是自己开线程很多老代码喜欢这么干AcceptTcpClient之后立刻new Thread让每个连接独占一个线程做阻塞读写。短时间能跑但别被假象骗了——一个线程默认栈空间 1MB1000 个连接光线程栈就占掉约 1GB 虚拟内存而且线程切换要进内核吞吐量一旦上来大部分CPU时间都耗在上下文切换上。异步IO的价值在于不再为每个连接准备一个线程而是让一小批线程池线程去处理成千上万个Socket的就绪事件。C#里最直接的做法是用TcpListener.AcceptTcpClientAsync()配合await底层走的是 IOCP 完成端口属于系统级的最优路径。对“高性能”这个词我的理解是保证一定并发下不出现性能悬崖而不是单纯追求单连接传大文件的速度。2.2 最小服务端骨架监听、Accept、接收循环我一般先写一个能挂住的骨架不掺业务逻辑验证IO模型跑得通public sealed class TcpServer { private readonly TcpListener _listener; private readonly ConcurrentDictionarylong, ClientSession _sessions new(); private readonly CancellationTokenSource _cts new(); private long _nextSessionId; public TcpServer(int port) { _listener new TcpListener(IPAddress.Any, port); } public async Task StartAsync() { _listener.Start(512); while (!_cts.IsCancellationRequested) { try { TcpClient tcp await _listener.AcceptTcpClientAsync(_cts.Token); long id Interlocked.Increment(ref _nextSessionId); var session new ClientSession(id, tcp, this); _sessions[id] session; _ session.RunAsync(); // 不等待IO由异步内部调度 } catch (OperationCanceledException) { break; } catch (Exception ex) { LogError(Accept失败, ex); } } } }代码逻辑说明ConcurrentDictionary用来登记所有在线会话方便后续做广播、踢人、统计。_ session.RunAsync()是关键——它不是丢任务而是让会话的Receive循环在线程池上独立执行服务端主循环能立刻回去做下一次Accept这正是异步模型和“每连接一线程”的本质区别。_listener.Start(512)里的512是监听队列 backlog代表内核里等待被Accept的连接数上限如果瞬间连接数超过这个值新的TCP三次握手请求会被内核丢弃客户端表现为连接超时。2.3 连接会话与收发缓冲区的设计会话类负责两条事接收数据的循环和发送数据的排队。接收部分最底层的逻辑这样写public async Task RunAsync() { var buffer new byte[8192]; try { using (_tcp) using (var stream _tcp.GetStream()) { while (!_cts.IsCancellationRequested) { int read await stream.ReadAsync(buffer, 0, buffer.Length); if (read 0) break; // 对端正常关闭 HandlePayload(buffer.AsSpan(0, read)); } } } catch (Exception ex) { LogError($会话{Id}异常, ex); } finally { _server.OnSessionClosed(this); } }read 0的边界很容易被漏掉TCP协议栈在收到对端FIN后ReadAsync返回0而不是抛异常如果这里不break会陷入死循环空转。8KB的缓冲区本身不算大但接收循环每读一段就交给上层处理缓冲区不是越大越好——它只负责一次系统调用能取回多少字节粘包处理靠的是应用层协议不是缓冲区尺寸。发送侧必须避免多线程同时写同一个NetworkStream常见的做法是在会话里放一个Channelbyte[]发送方往Channel里写一个独立的发送Task消费并写进Streamprivate readonly Channelbyte[] _sendQueue Channel.CreateUnboundedbyte[](); public ValueTask SendAsync(byte[] packet) { return _sendQueue.Writer.WriteAsync(packet); }这个设计把“业务线程”和“Socket写线程”解耦不会出现两个线程一起调用WriteAsync导致的Interlocked错乱。通道的背压可以后面再加先把结构立住。3. 客户端与协议设计粘包、半包、心跳和断线重连3.1 协议头怎么定4字节长度加消息体还是带消息类型TCP是流协议没有消息边界。你调一次Send发出去的数据对端可能分三次Receive才收全也可能一次Receive里收到两条消息。解决这个问题的唯一方法是定义Application Layer协议头。小型项目最稳的是“4字节总长度 业务数据”长度字段给的数值是从长度字段之后到整个包结束的字节数。这样做能覆盖绝大多数场景比如Modbus TCP的协议头也是这个思路前6字节固定MBAP头后面长度字段告诉你要等多少字节。如果业务需要区分消息类型就把协议头扩成“4字节总长度 2字节消息类型 可变消息体”不要自己发明花哨的分隔符——用\r\n切分消息只在文本协议里靠谱二进制数据里一个0x0D0A就能让你翻车。长度字段必须统一网络字节序Big Endian。C#在x86/Arm小端机器上要用BinaryPrimitives.WriteInt32BigEndian手写BitConverter一定要处理大小端转换public static byte[] EncodePacket(byte[] messageType, byte[] payload) { int totalLength payload.Length 2; // 消息类型占2字节 var header new byte[6]; BinaryPrimitives.WriteInt32BigEndian(header, totalLength); header[4] (byte)(messageType 8); header[5] (byte)(messageType 0xFF); return header.Concat(payload).ToArray(); }参数说明totalLength不包含自身那4字节只包含剩余字段的总字节数。对端解码时先读够4字节头再从长度字段解析出“后续还得读多少字节”。这个设计在协议演进时很省事比如以后要加认证字段在消息类型后面追加即可客户端收到未知消息类型可以直接跳过。3.2 客户端异步连接与心跳客户端最基本的连接代码需要支持超时不能无限等public async Taskbool ConnectAsync(string host, int port, int timeoutMs) { using var cts new CancellationTokenSource(timeoutMs); var tcp new TcpClient(); try { await tcp.ConnectAsync(host, port, cts.Token); _stream tcp.GetStream(); _ ReceiveLoopAsync(); StartHeartbeat(); return true; } catch (Exception ex) { tcp.Dispose(); return false; } }ConnectAsync的timeoutMs由CancellationTokenSource控制这个和同步TcpClient.Connect的阻塞行为完全不同同步API遇到网络黑洞可能挂几分钟才超时异步配合Token能做到秒级失败。心跳是TCP实战里必须加的东西TCP的KeepAlive默认要两小时才探测一次很多时候服务端进程被路由器或云平台断开客户端自己根本感知不到直到下一次写数据才报异常业务上表现为“卡死没响应”。我在客户端里用一个10~30秒的定时器发送心跳包服务端如果连续三个心跳周期没收到任何数据可以判定连接死了主动释放资源。3.3 断线重连的退避参数断线重连写的太激进服务端还没重启完客户端已经用几万次Connect把端口占满。常见的做法是指数退避第一次失败等1秒第二次2秒第三次4秒……最大值设30秒或60秒并且加入随机抖动避免大量客户端同时重连造成同步风暴private async Task ReconnectLoopAsync() { int delayMs 1000; while (!_cts.IsCancellationRequested) { if (await ConnectAsync(_host, _port, 3000)) { delayMs 1000; await WaitForDisconnectAsync(); } else { delayMs Math.Min(delayMs * 2, 30000) Random.Shared.Next(0, 500); await Task.Delay(delayMs); } } }注意WaitForDisconnectAsync是等到接收循环退出才返回而不是连接成功就立刻循环。重连期间的业务数据要么丢弃、要么放进队列等恢复后重发具体看业务容忍度上位机读设备实时状态直接丢弃等到重连后重新要一帧快照就可以做指令下发的就得把未确认的消息缓存下来。4. 参数调优与缓冲区让吞吐量从“能用”到“够硬”4.1 Socket缓冲区大小、NoDelay、KeepAlive怎么设置缓冲区设置是性能调优里最容易被玄学化的一环。没有放之四海皆准的数字但要理解两个缓冲区的角色内核收发缓冲区和C#侧读写缓冲区。TcpClient.SendBufferSize和ReceiveBufferSize设置的是内核Socket缓冲区它们会影响TCP窗口大小从而影响吞吐。对局域网内高频短消息收发各设 8KB~16KB 足够对跨机房传输大块数据可以调到 128KB~256KB。但别盲目调大——如果链路质量差大缓冲区会让丢包后的重传代价更高。发小包时必须关掉Nagle算法tcp.NoDelay true。否则一个10字节的包会在内核里攒着等确认延迟可能到40ms以上这在运动控制和设备实时响应场景里完全不能接受。KeepAlive的参数比较反直觉TcpClient上只能开或关真正的时间参数在注册表里HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\KeepAliveTime默认是7200秒。所以应用层心跳永远是必须的不要指望操作系统替你发现死链。tcp.NoDelay true; tcp.ReceiveBufferSize 16 * 1024; tcp.SendBufferSize 16 * 1024; tcp.LingerState new LingerOption(true, 2);LingerState上面这个设置很多人不知道关闭连接时允许系统在最多2秒内把尚未发送的数据发完再发FIN。默认值是“立即发送RST”会导致应用层写了一半的数据被直接丢弃。对需要可靠收尾的协议这个参数能让正常关闭更温和。4.2 背压与流量控制SendAsync排队和取消前面给客户端和服务端都设计了发送队列队列不做长度限制就会内存爆掉。生产环境里我给发送队列加了一个上限比如每条消息最大1MB队列累计不超过64MB超了就断开这个会话而不是继续吞var options new BoundedChannelOptions(1024) { FullMode BoundedChannelFullMode.Wait, SingleReader true, SingleWriter false }; var _sendQueue Channel.CreateBoundedbyte[](options);BoundedChannelFullMode.Wait表示队列满时写方等待相当于把背压传递回业务调用方。这比DropWrite强——直接丢消息在命令下发场景里会造成设备静默错误。但等待也有风险同步业务线程在调用SendAsync时会堵住所以发送接口最好改成异步并允许上层传取消令牌。高性能TCP服务器的隐蔽瓶颈往往不在Receive而在Send侧业务速度超过链路消耗速度时无界队列吃光内存是必然结果。4.3 连接复用与客户端连接池客户端代码如果高频做“连接→发一条消息→关闭”性能会非常难看。TCP三次握手加四次挥手在小包场景下占的往返时间比重太大正确做法是复用连接。进程内一个设备地址只维护一个TcpClient用SemaphoreSlim把并发发送变成独占写public class TcpClientPool { private readonly string _host; private readonly int _port; private TcpClient _current; private readonly SemaphoreSlim _sendLock new(1, 1); public async Task SendAsync(byte[] packet) { await _sendLock.WaitAsync(); try { if (_current null || !_current.Connected) await ReconnectCoreAsync(); await _current.GetStream().WriteAsync(packet); } finally { _sendLock.Release(); } } }_current.Connected属性只能反应上一次IO的状态不能真正确认在线所以实际判断要在WriteAsync抛异常后进入重连。这里用SemaphoreSlim保证同一时刻只有一个线程在写Stream避免线程间互相踩踏。5. 避坑/常见问题TCP编程里最容易翻车的四个现场5.1 收到“远程主机强迫关闭了一个现有的连接”10054现象服务端日志频繁抛SocketException: ConnectionReset客户端程序崩溃退出。原因对端进程崩溃、被kill掉、或者网卡复位时操作系统会发送RST包而不是正常的FIN。这个时候服务端如果正在ReadAsync会直接抛异常而不是收到read 0。解决把read 0和异常分开处理异常场景不要当成正常业务路径记录日志后释放会话。同时检查强制关闭是否有上层原因比如LingerState设置不当导致发RST。5.2 粘包/半包一次Receive读到的不是一条完整消息现象服务端收到的第一条消息完整第二条开始字段错位或者解析随时报长度非法。原因TCP是字节流HandlePayload(byte[])必须自己拆分消息。解决在接收循环里维护一个MemoryStream缓存区。每读到一段数据就追加进缓存然后循环尝试缓存不足4字节先退出从缓存头解析长度字段长度不够再退出够就取出完整包交给业务处理并移除缓存头部相应字节。这个模式在所有语言里都一样没见过哪个TCP框架能替你解决应用层边界。5.3 异步回调里的异常被线程池吞掉服务端悄悄不响应现象服务端运行几天后突然所有客户端连不上日志没任何错误。原因async void方法内的异常会冒泡到线程池全局直接导致进程崩溃但如果你写了async Task却在调用时没有await某个异常会变成“未观察异常”可能被GC悄悄忽略。解决所有顶层异步循环必须包try-catch至少让异常打到日志。我习惯在RunAsync的入口和出口都打日志看到会话异常退出比进程静默跑飞要容易定位得多。5.4 KeepAlive不是万能的默认两小时才探测一次现象客户端程序挂机一夜第二天醒来发现连接断了但进程没退出数据发不出去。原因网络中间设备比如NAT网关长时间没流量会把连接表老化删除而TCP两端都感知不到。解决应用层心跳是必须的不是可选项。心跳间隔建议小于30秒连续3个心跳没回应就主动断开重连。注意心跳包本身也要走协议编码不要直接发裸字节避免混淆业务解析器。6. 性能验证与进阶压测脚本、内存与CPU调优、最后一点习惯服务端写完一定要压测。压测不需要复杂工具自己写一个多线程客户端批量建立连接、持续发消息就行。我通常这样测先测100个连接每个连接每秒发20条小包观察服务端CPU和内存再逐步加到500、1000。重点看两个指标CPU占比和线程池活跃线程数。如果连接数增加时线程数也线性增长说明某个地方用了同步阻塞异步模型没有生效。用dotnet-counters可以实时看threadpool-queue-length这个值如果持续大于0说明底层调用频繁阻塞线程池。进阶调优里还有一个关键对象SocketAsyncEventArgs。如果追求极限性能不要每一次IO操作都new一个而是复用SAEA对象配合BufferManager做缓冲区PreAllocation。这个做法在4000连接场景下能明显降低GC压力。但对于大多数工业上位机、设备网关几百连接规模用async/await已经够用SAEA对象池反而是过度设计——它的代码复杂度和调试成本对团队不是零开销。我的一个血泪教训是协议设计千万要先于编码。以前接手过一个采集项目第一版协议只定义了报文头没定义长度字段接收端靠猜固定长度切包设备上报频率一高就串包后来花了整整一周改协议。如果一开始就定义好“4字节长度(网络序)2字节类型可变体”后面的缓冲区设计、会话管理、重连机制全都顺理成章。还有一件事我不会再省每个连接的第一条消息必须是握手包由客户端主动发设备标识这样服务端才能在意外重连时识别是不是同一个终端。希望帮到你。本文还有配套的精品资源点击获取