
简介面向高并发网络通信场景一套基于完成端口IOCP的C#高性能TCP服务器与客户端源码包实现高速报文收发并保持较低CPU占用适合需要快速搭建稳定网络服务或研究IOCP机制的.NET开发者。压缩包共59个文件整体约122KB核心为13个.cs源码、可直接运行的exe、config配置以及csproj/sln工程文件另有dll与pdb供调试参考目录按IocpClient与IocpServer两端分置便于对照阅读。作者在双核2G内存环境下验证服务器可稳定承载5000以上客户端连接默认监听9900端口自带客户端与服务器完整通讯代码可直接进行高并发数据收发性能测试。资源内封装的网络通信类可引入项目复用附带“源码必读.txt”说明开发环境与常见问题处理对希望掌握IOCP高并发编程的中高级C#开发者有较高参考价值。目前已有1302人学习下载。1. 用 C# 写高性能 TCP 服务器和客户端最值钱的不是 Socket 语法拿 C# 搭 TCP 服务器和客户端很多人第一个想法是网上搜一段 Socket 代码跑通就完事但真正做上位机、设备数据采集、长连接推送的人很快就会撞上一堵墙单客户端连接好好的十几个客户端同时上来就开始丢包、端口占用、界面卡死甚至服务器直接假死。这套 C# 完全端口高性能网络 TCP 服务器和客户端源码解决的就是这一整条链路而不是给你一段能 ping 通的 Demo。它把 listen、accept、收发缓存、粘包拆包、心跳重连这些 TCP 工程里绕不开的环节都做成了可以直接改的类适合两类人一类是做上位机需要和仪器、PLC、第三方服务对接的 C# 工程师另一类是刚开始写网络服务、想搞清楚高性能服务器内部到底该怎么组织的后端新手。你能照着它把服务器起起来、把客户端连上去然后在真实压力下看到哪些参数决定生死。2. 先看架构再动手异步模型、缓冲池和连接上下文是高性能的三大支柱2.1 从同步阻塞到异步 Socket这套源码的选型逻辑TCP 服务端最原始的写法是Accept()一个连接就开一个Thread在里面Receive()收数据。连接少的时候没问题连接一多线程数量膨胀上下文切换开销直接压垮 CPU这是同步阻塞模型的硬伤。这套源码里能看到的标准做法是监听 Socket 用异步AcceptAsync收发用基于SocketAsyncEventArgs的异步操作回调由操作系统 IO 线程池承载业务逻辑再投递到托管线程池。两者的差别不只是“写法更高级”而是线程模型的本质区别。同步模型里一个线程在Receive()上阻塞时什么也干不了异步模型里SocketAsyncEventArgs在操作完成前不占用托管线程由 IOCP 完成端口在数据到达时回调。高并发连接场景下异步模型可以用少量线程撑起上千个连接这套源码也是这么组织的。实际改的时候你只需要关心中间几层不需要重新发明一遍。核心链路是监听 Socket异步 Accept → 每个接入连接封装成一个 ConnectionContext → 分配 SocketAsyncEventArgs收发复用同一个对象 → 收到数据进入缓冲区 → 解析包头 → 交给业务回调2.2 连接上下文与缓冲区池高性能的另一半不在 Socket源码中你会看到一个很关键的类通常叫ConnectionContext或Session它把每个 TCP 连接的“家底”都存在一起。我拆过类似项目这类上下文至少要盖住下面这些字段字段类型作用Idlong连接唯一编号用于日志追踪SocketSocket当前连接的原生 Socket 引用ReceiveSAEASocketAsyncEventArgs接收用的异步参数对象SendSAEASocketAsyncEventArgs发送用的异步参数对象ReceiveBufferbyte[]接收缓冲区默认 8KB 或 64KBSendQueueQueuebyte[]发送队列防止写半包LastActiveTimeDateTime最后活动时间用于心跳判断一个很容易被忽略的细节是SocketAsyncEventArgs对象应该池化复用而不是每个连接每次都 new。在 .NET Framework 时代频繁创建 SAEA 会造成 GC 压力即使 .NET Core / .NET 5 做了很多优化习惯上仍然保留 SAEA 池源码里一般会有个SocketAsyncEventArgsPool按“先取后还”的方式循环使用。我之前一个数据采集服务从每连接 new SAEA 改成从池里取GC 频率肉眼可见地降下来。public SocketAsyncEventArgs Pop() { lock (_pool) { return _pool.Count 0 ? _pool.Pop() : new SocketAsyncEventArgs(); } } public void Push(SocketAsyncEventArgs item) { item.SetBuffer(null, 0, 0); lock (_pool) { _pool.Push(item); } }这段代码的逻辑很简单取的时候先看池子里有没有空闲对象有就复用没有才创建归还时清掉 Buffer 引用避免上个连接的数据残留在内存里。这里有个参数细节值得注意SetBuffer(null, 0, 0)是归还前必须做的清理否则下一个连接用这个 SAEA 时可能读到上一个连接的脏数据。池的初始容量我一般会配成预期并发连接数的 1.5 倍不要和最大连接数完全相同否则峰值瞬间会频繁 new 对象。2.3 拆包与粘包没有协议边界高性能就是空中楼阁TCP 是流协议业务上一个完整的数据包到了内核缓冲区可能被切一刀变成两块也可能被粘成一个大数据包一起到达这就是俗称的粘包和半包。源码能高性能工作的前提是它定义了一个明确的协议边界。最常见的做法是长度前缀协议即每个包先发 4 字节包头表示后面的负载长度。收到数据后先攒够 4 字节解析出长度再等负载收满一包。源码里一般会有一个PacketParser或MessageDecoder内部维护一个临时缓冲区和当前包状态机。public class LengthPrefixDecoder { private byte[] _buffer new byte[1024]; private int _offset; private int _payloadLength; public Listbyte[] Decode(byte[] data, int length) { var packets new Listbyte[](); if (_offset length _buffer.Length) { Array.Resize(ref _buffer, Math.Max(_buffer.Length * 2, _offset length)); } Array.Copy(data, 0, _buffer, _offset, length); _offset length; while (true) { if (_offset 4) break; _payloadLength BitConverter.ToInt32(_buffer, 0); if (_offset 4 _payloadLength) break; var payload new byte[_payloadLength]; Array.Copy(_buffer, 4, payload, 0, _payloadLength); packets.Add(payload); _offset - 4 _payloadLength; Array.Copy(_buffer, 4 _payloadLength, _buffer, 0, _offset); } return packets; } }这段解码器的逻辑是先把新收到的数据拼到内部缓冲区然后循环检查是否有完整包。第一个判断是“连 4 字节头都没凑够不处理”第二个判断是“头部有了但负载还没收完等下一次 Receive”两个条件都满足才切出一个完整负载。Array.Copy把剩余数据搬到缓冲区头部是保证下一次拼接不会越界的关键。用这个类时要额外留神BitConverter.ToInt32读取的字节序。默认是小端序如果你的服务端要对接 C 或 Java 写的设备它们可能用大端序解析出来的长度会变成天文数字直接导致缓冲区无限扩容。源码里通常会在协议初始化时指定Endianness建议你改动前先确认对端协议。3. 服务端落地监听参数、连接管理和业务分发这样接才稳3.1 监听 Socket 初始化bind 之前要决定的三件事服务端第一步是创建监听 Socket这一步的参数一般人抄代码不会多想但恰恰是高并发下翻车的高发区。var listenSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listenSocket.Bind(new IPEndPoint(IPAddress.Any, 9000)); listenSocket.Listen(1024);这里三个关键参数分别解决三个问题ReuseAddress让服务器重启时能立刻重新绑定同一端口否则你在开发环境调试时杀进程后再启动会经常碰到“地址已在使用”的报错IPAddress.Any表示监听本机所有网卡地址如果只想让内网特定网卡访问就改成对应的IPAddress.Parse(192.168.1.10)Listen(1024)是内核监听队列长度表示还没来得及Accept的连接可以排多长的队。按我自己的经验Listen的 backlog 在客户端连接数几百这个量级时设置成 512 到 1024 就够了超过 5000 并发时再往上调同时要配合net.core.somaxconn的系统参数一起改只改 C# 这一层没意义。3.2 Accept 循环用异步接入避免连接堆积建立监听后就是 Accept 循环。同步Accept()在监听 Socket 上阻塞每个连接到来才能继续处理不过来时 backlog 满了新连接直接被内核拒绝这在压力测试里表现为客户端连接超时。源码里的异步 Accept 循环一般长这样private void StartAccept(SocketAsyncEventArgs acceptEventArgs) { bool pending _listenSocket.AcceptAsync(acceptEventArgs); if (!pending) { ProcessAccept(acceptEventArgs); } } private void ProcessAccept(SocketAsyncEventArgs e) { if (e.SocketError SocketError.Success) { var clientSocket e.AcceptSocket; var connection new ConnectionContext(clientSocket); _connections.TryAdd(connection.Id, connection); StartReceive(connection); } StartAccept(e); }这段代码里有个关键点AcceptAsync返回bool表示操作是否同步完成。返回true代表操作还在内核中异步进行完成后会触发Completed事件返回false代表数据已经就绪不用等回调直接处理即可。两种情况都要走到ProcessAccept很多新手只处理了回调分支导致高并发时偶发丢连接。e.AcceptSocket拿到新连接后立刻创建ConnectionContext并注册到ConcurrentDictionary后续所有查找都是 O(1)。我在自己项目里会额外加一步ProcessAccept里先判断当前总连接数是否达到上限超过就直接把AcceptSocket关掉。这是保护服务器不被恶意连接打满的常用兜底源码里如果有类似配置项上线前要按业务上限核对一遍。3.3 接收数据与业务分发回调里不要碰业务逻辑接收过程是整条链路最容易被写坏的地方。正确的姿势是SocketAsyncEventArgs完成回调只做数据搬运——把字节交给解码器解析出完整包后投递给业务线程池回调立即返回。private void OnReceiveCompleted(object sender, SocketAsyncEventArgs e) { if (e.SocketError ! SocketError.Success || e.BytesTransferred 0) { CloseConnection(e); return; } var packets _decoder.Decode(e.Buffer, e.BytesTransferred); foreach (var packet in packets) { ThreadPool.QueueUserWorkItem(_ HandleMessage(e.UserToken as ConnectionContext, packet)); } _receiveSAEA.SetBuffer(_receiveSAEA.Offset, _receiveSAEA.Buffer.Length); bool pending (e.UserToken as ConnectionContext).Socket.ReceiveAsync(e); if (!pending) { OnReceiveCompleted(this, e); } }两个细节必须说明。第一BytesTransferred 0表示对端关闭这是 TCP 半关闭的标准信号必须在这里处理清理逻辑否则连接永远不会释放。第二SetBuffer(Offset, Length)用来重置接收偏移如果不重置第二次接收的数据会从上次的偏移位置开始写收到的内容就是错位的。这个问题排查起来很隐蔽现象是第一个包正常第二个包开始全部乱码。业务分发用ThreadPool.QueueUserWorkItem是常见做法但如果你要保证同一连接的消息按顺序处理就不能无脑丢线程池否则多线程同时处理同一个连接的包会乱序。我一般的做法是每个ConnectionContext里维护一个按 FIFO 顺序执行的消息队列或者用SemaphoreSlim把同一连接的处理逻辑串行化。3.4 Idle 清理与心跳检测不活跃连接必须定期收尸如果没有心跳和空闲检测客户端拔网线、断电服务器端的连接会一直挂着最终把连接数拖满。源码里通常有一个后台定时器每 10 秒扫描一次所有连接找出超过 N 秒没有数据活动的连接强制关闭。private void CheckIdleConnections(object state) { var threshold DateTime.Now.AddSeconds(-_idleTimeoutSeconds); foreach (var kvp in _connections) { if (kvp.Value.LastActiveTime threshold) { CloseConnection(kvp.Value); } } }LastActiveTime的更新时机是收到任何字节时更新包括心跳包。这里有个容易被忽略的坑有些设备断电后 TCP 连接不会立刻断开也没有 FIN 报文送到服务器只能靠超时清理。_idleTimeoutSeconds我一般设成心跳间隔的 3 倍比如设备每 10 秒发一次心跳超时设为 30 秒太短会导致正常连接被误杀太长则收尸不及时。4. 客户端实现连接、心跳和重连要按对端协议定制4.1 客户端 Socket 初始化NoDelay 和连接超时是必须显式控制的客户端代码在源码里同样是一等公民它是和服务端配合的调试利器。客户端连接服务器最常踩的坑是 Nagle 算法小数据包被内核缓存一段时间再发出去交互式请求明显延迟。var clientSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); clientSocket.NoDelay true; clientSocket.SendTimeout 3000; clientSocket.ReceiveTimeout 3000; var task clientSocket.ConnectAsync(new IPEndPoint(IPAddress.Parse(192.168.1.100), 9000)); if (!task.Wait(5000)) { throw new TimeoutException(连接超时5秒内未完成 TCP 三次握手); }NoDelay true表示禁用 Nagle每条小消息立即发送适合需要低延迟的实时控制如果业务是批量上传大文件把它设为 false 反而能提高吞吐。ConnectAsync返回的Task在超时控制上要注意很多封装直接把ConnectAsyncawait导致对端 IP 不可达时卡十几秒甚至几十秒才抛异常。Task.Wait(5000)是我惯用的后备方案超过 5 秒直接判超时做上位机的人会很有共鸣——连不上设备时早点报错比卡死强得多。4.2 心跳与断线重连要在业务静默时维持通路很多协议栈设备一两个小时不发一包业务数据如果中间网络设备把空闲连接清了你需要感知到并自动重连。客户端的标准做法是后台定时发心跳帧同时记录最后一次收到服务端数据的时间连续超时则重启连接逻辑。private async void HeartbeatLoop(CancellationToken token) { while (!token.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(10), token); var lastReceived Interlocked.Read(ref _lastReceiveTicks); if (Environment.TickCount64 - lastReceived 30000) { Reconnect(); return; } SendHeartbeatPacket(); } }心跳包的格式必须严格跟着协议走不能自己发明。源码里通常留了一个BuildHeartbeatPacket方法让你按业务重写有的设备心跳是 1 字节的0x00有的要求带固定魔数有的用业务层的特定命令。30000这个阈值取的是“心跳间隔 × 3”太敏感会在服务端 GC 停顿或网络抖动时频繁误判重连。重连逻辑里还要注意退避连续失败时按 1、2、4、8 秒递增不能死循环每 3 秒重连一次否则服务器刚恢复就会被你的客户端连接风暴冲垮。4.3 收发缓冲区的容量匹配客户端不是越简单越好客户端收到的数据包大小通常比服务端小但缓冲区设计同样不能省。很多新手在客户端把ReceiveBuffer设成 1024 字节以为够了结果对端发来一个 2KB 的响应就直接截断。源码里服务端和客户端的ReceiveBuffer往往是可配置的我在实际项目中客户端收到数据后还要做一次合法性校验比如魔数不对、长度超上限就断开连接防止对端异常数据把客户端内存撑爆。这个策略在对接第三方厂商设备时尤其重要设备逻辑有 bug 发来大包不能让它把上位机拖死。4.4 联调时的抓包验证用数据而不是猜客户端写完后第一步不是连真实服务器而是先用本地回环测试再用 Wireshark 抓包验证。重点关注三点三次握手的 SYN/SYN-ACK/ACK 是否完整数据包是否有粘包现象关闭连接时 FIN/ACK 是否正确交互。源码里如果有自带的 TestClient 或 Debug 模式保留它是很有价值的它能帮你确认对端协议差异到底在包头还是负载。5. 避坑排查端口、缓冲区、粘包与连接管理的五个常见翻车点5.1 服务器重启后报“地址已在使用”现象开发调试时关闭服务器进程立刻重新启动服务Bind抛SocketException: Address already in use。原因主动关闭的 TCP 连接会进入 TIME_WAIT 状态持续 2MSL约 60 秒期间端口处于半释放状态。没设置ReuseAddress时新进程绑定同一端口会被拒绝。解决创建监听 Socket 后立即执行SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true)。注意这条选项必须写在Bind之前写在后面无效。Windows 和 Linux 行为有细微差异Linux 上ReuseAddress加上ReusePort才能在多进程监听同一端口时生效单进程服务用前者就够。5.2 第一个包正常第二个包开始乱码现象客户端正常发送第一包数据服务器正确解析紧接着发第二包服务器解析出来的内容错位、长度异常。原因这是典型的接收缓冲区没有重置偏移。SocketAsyncEventArgs.Buffer是复用对象上一次接收后内部偏移停留在已读位置第二次直接写入导致数据从错误位置开始拼接。解决每次完成回调准备下一次ReceiveAsync前调用SetBuffer(e.Offset, e.Buffer.Length)复位。同时把解码器的合并逻辑做成无状态增量式不要每次新包从头初始化一次。5.3 客户端偶发报错 10054 / 连接被重置现象客户端长时间运行后某个时刻Receive抛SocketException: 远程主机强迫关闭了一个现有的连接错误码 10054。原因服务端主动关闭了连接——可能是超时收尸可能是异常崩溃也可能是对端网络设备发 RST。客户端感知到 RST 后就会抛这个异常。对某些协议设备来说出现 10054 是正常的运维事件不是代码 bug。解决在客户端把 10054 当成重连触发条件之一不要当成致命异常往上抛。处理顺序是捕获异常 → 记录日志和当前连接状态 → 清理旧 Socket → 走断线重连流程。注意日志不要打太多刷屏用LogLevel.Information记录一次即可。5.4 连接数一多接收回调迟迟不触发现象本地测试 10 个客户端都正常压测升到 500 连接后部分连接收数据明显延迟或者完全不回调。原因回调查里做了耗时操作——比如直接在回调里解析 JSON、写数据库、锁竞争把 IOCP 线程池阻塞了。回调线程被阻塞后续所有这个线程承载的 IO 完成通知全部排队。解决回调里只做字节搬运和包解析业务处理全部丢业务线程池实在要做同步耗时操作单独开消费者队列。如果用了锁检查锁粒度不要锁整段解析过程。这个问题的排查方法是抓 dump 看线程栈看到OnReceiveCompleted卡在DbCommand.Execute上就说明业务太重了。5.5 Nagle 与延迟 ACK 相互作用导致写口卡顿现象客户端发送小包后被服务器延迟几十毫秒才响应不影响正确性但交互明显变慢。原因TCP 的 Nagle 算法会攒小包延迟 ACK 机制会等待 40ms 再回 ACK两者叠加造成“写完等 ACK”的假阻塞。解决低延迟交互场景在双方 Socket 上都设置NoDelay true。但如果传输大量文件、追求吞吐最大化关掉 Nagle 不一定更好要按业务特征选。这个现象在 Modbus TCP、自定义短指令协议里很典型很多设备厂商的 SDK 默认关了 Nagle你复用它时保持一致就行。6. 进阶验证用并发压测把边界试出来别等上线再翻车拿到这套源码后最值得做的一步是写一个压测脚本把服务器和客户端都推到真实业务量级以上确认瓶颈在哪儿。我通常的做法是写一个小控制台程序同时建立 500 个 TcpClient 连接每个连接循环发送业务包统计服务器处理后的回包数量和延迟分布。var clients new ListTcpClient(); for (int i 0; i 500; i) { var client new TcpClient(); await client.ConnectAsync(127.0.0.1, 9000); clients.Add(client); } var stopwatch Stopwatch.StartNew(); await Parallel.ForEachAsync(clients, async (client, _) { var stream client.GetStream(); var payload Encoding.UTF8.GetBytes(hello); var header BitConverter.GetBytes(payload.Length); await stream.WriteAsync(header.Concat(payload).ToArray()); await stream.ReadAsync(new byte[1024], 0, 1024); }); Console.WriteLine($500 连接并发耗时: {stopwatch.ElapsedMilliseconds} ms);压测时我只会看三个指标500 连接全量握手耗时、并发回包成功率、服务端 CPU 和内存是否有持续增长。先跑 1 分钟看 GC 频率再跑 30 分钟看内存曲线最后把客户端数量翻一倍看系统熔断前崩溃的形态——主动拒绝还是卡死。这一步做下来比读十遍源码都清楚线程池配置和缓冲区大小该怎么调。从那以后我每次写 TCP 服务落地前都强制把压测脚本跑一遍连带着把客户端的超时参数、重连退避一起调了省得上线后半夜被叫起来查 10054。这套源码的价值也在这里它给你的是一个能验证、能改、能跑的起点希望帮到你。本文还有配套的精品资源点击获取