
简介一套面向 C# 高性能网络并发场景的完成端口IOCP示例工程适合需要构建大容量长连接服务端的中高级开发人员。核心基于 SocketAsyncEventArgs 完成异步套接字通讯封装服务端集成 log4net 日志查看、连接列表、上传下载、远程文件流与吞吐量协议可支撑六万五千余个长连接本地回环实测命令交互速度达 250MB/S约为千兆网卡两倍吞吐量扩展场景还可支撑六万五千以上连接与四百兆网络吞吐量。压缩包约 3.35MB共 300 个文件以 C# 与 Delphi 源码、可执行程序及说明文档为主目录结构清晰便于按需参考。已有 1164 人学习下载。读者可直接获得服务端源码、压力测试手段与完成端口关键实现理解从连接管理、数据收发到日志监控的完整设计闭环作为高并发通讯架构设计、性能调优和工程对比的参考样例。1. 这项目解决什么问题一个可跑通的高并发 Socket 样板几万条长连接同时挂着、每条连接随时可能踢一条消息进来这不是互联网大厂才有的场景。写即时通讯网关、搭硬件采集平台、给手游做消息推送通道单机连接数轻松破万。我拆这个 C# 完成端口IOCP项目时最打动我的是它的驱动模型用固定数量的线程服务几万个 Socket核心是操作系统的完成端口线程数不跟着连接数走CPU 不会被上下文切换烧干。项目是个完整可运行的 Socket 并发例子包含服务端、压测客户端和配套说明。适合第一次碰 IOCP 的入门者照着跑通也适合正在设计网关三层结构的开发者当骨架参考。看完核心代码你能把连接状态、缓冲区、收发回调这条链路彻底理清然后动手改成自己的协议。2. 架构与选型完成端口为何是 Windows 高并发的最优解2.1 为什么是完成端口而不是异步 Socket先看一个实际对比。传统的阻塞模式一个连接一个线程逻辑简单但线程开销巨大到了万级连接光线程栈就占掉几百 MB 内存线程切换也让 CPU 打满。用BeginAccept/BeginReceive这套异步 API 能缓解线程压力但每次异步操作都要创建句柄和缓冲区GC 压力明显连接频繁断开重连时性能会肉眼可见地降下来。完成端口IOCPInput/Output Completion Port是 Windows 内核提供的 IO 完成通知机制。你把 Socket 句柄关联到完成端口上系统在 IO 完成时把结果丢进一个队列用户态线程从队列里取完成包处理。线程数量可以由创建端口时指定一般设为核心数 × 2CPU 密集和 IO 密集都能覆盖。下表是三者的直观差异模式线程模型万级连接表现主要瓶颈阻塞式一连接一线程内存与切换开销大线程上下文切换传统异步线程池 每操作分配可用但 GC 频繁句柄与缓冲分配IOCP固定线程 完成队列稳定支撑业务处理逻辑这个 C# 项目选 IOCP 并不追求「最佳」而是追求「Windows 上的最优解」。项目的设计目标很明确高并发、大容量、低分配。如果目标平台是 Linux 或跨平台一般会改用 SocketAsyncEventArgs 在非 Windows 平台映射 epoll 的能力但核心思想一致——把等待交给系统把事件交回业务。2.2 服务端的三层结构监听、IO、业务项目在架构上不是裸写 Socket而是分了清晰的三个层次监听层、IO 层、业务层。监听层只负责 Accept 新连接不碰数据IO 层管收发和缓冲区的复用业务层拿到完整数据包后才触发回调方法。这样做的最大好处是每一层都能独立替换比如以后接入自己的协议解析器只需要改业务层不必动收发链路。代码里对应关系是这样的监听层AcceptAsync循环接受新连接后取出预分配的SocketAsyncEventArgs交给 IO 层。IO 层ProcessReceive/ProcessSend两个方法处理Completed回调、判断粘包拆包、维护写队列。业务层OnDataReceived之类的回调入口把字节流转成你的业务对象。这种分层在项目里是显式的。你在目录里能看到Server主体类、AsyncUserToken连接状态类、SaeaPool对象池类每个类职责单一。拆分的关键是不要让 Socket 操作和业务逻辑混在同一个回调里否则一旦业务处理变慢线程池里的 IOCP 线程会被占满后续完成包没人取吞吐瞬间掉下去。2.3 SocketAsyncEventArgs 与对象池设计C# 里做 IOCP 不需要直接 P/InvokeCreateIoCompletionPort.NET 的SocketAsyncEventArgs已经把完成端口封装好了。它做的事情是提交异步接受/收发操作系统完成时触发Completed事件。这个类的价值在于可复用——同一个实例可以多次提交操作前提是正确重置状态。项目里围绕 Saea 做了两层复用。第一层是SaeaPool维护一个ConcurrentStackSocketAsyncEventArgs避免反复 new第二层是接收缓冲预分配每个连接拿到固定大小的byte[]作为接收区不再按消息大小动态分配。这两层合起来就是 IOCP 性能的关键减少对象创建减少 GC 压力让完成端口的线程只做搬运和通知。我一般会建议把池容量做成可配置参数启动时预热。这个项目里默认池容量和最大连接数都写在Server的构造参数里按SocketAsyncEventArgs的占用估算一个实例几十字节加一个缓冲块几万个连接也就几百 MB 内存合理。3. 核心模块拆解连接状态、缓冲管理、收发回调3.1 AsyncUserToken一个连接一个状态机无论连接数多少每条 TCP 连接在服务端都必须有独立的上下文。这个项目里叫AsyncUserToken它把 Socket、接收缓冲区、写队列、当前收发状态捆在一起。没有这个 Token回调回来你根本不知道是哪个连接的事件。internal class AsyncUserToken { public Socket Socket { get; set; } // 接收缓冲区预分配固定大小避免每条消息都触发 GC public byte[] ReceiveBuffer { get; set; } // 当前缓冲中有效数据的起点和长度 public int ReceiveOffset { get; set; } public int ReceiveLength { get; set; } // 发送队列业务线程投递数据IO线程串行写出 public Queuebyte[] SendQueue { get; private set; } // 标记当前是否正在发送避免多个线程同时操作同一个 Socket public bool IsSending { get; set; } public DateTime LastActiveTime { get; set; } public AsyncUserToken(int bufferSize) { ReceiveBuffer new byte[bufferSize]; SendQueue new Queuebyte[](); LastActiveTime DateTime.Now; } }逻辑说明ReceiveOffset和ReceiveLength是拆包的基础。每次收到数据后有效数据落在ReceiveBuffer[ReceiveOffset .. ReceiveOffset ReceiveLength]解析完一部分就调整 Offset不够一个完整包就留在缓冲里等下一次接收。SendQueue加IsSending组成发送状态机保证同一时间只有一个线程调用SendAsync后面写发送链路时会看到它的作用。这个 Token 一个连接只有一个实例从 Accept 成功创建到连接关闭释放。设计上不需要加锁的点绝对不加锁比如ReceiveOffset只由 IO 回调线程改需要加锁的是SendQueue因为业务线程和 IO 回调可能同时碰它。3.2 SaeaPool复用 SocketAsyncEventArgs 的关键每次 Accept 一个连接至少要两个SocketAsyncEventArgs一个接收操作、一个发送操作。如果连接高频建立断开每次都 newGC 马上成为瓶颈。SaeaPool 就是干这个的。internal class SaeaPool { private readonly ConcurrentStackSocketAsyncEventArgs _pool; private readonly int _capacity; public SaeaPool(int capacity) { _capacity capacity; _pool new ConcurrentStackSocketAsyncEventArgs(); PreAllocate(); } private void PreAllocate() { for (int i 0; i _capacity; i) { var saea new SocketAsyncEventArgs(); // Completed 事件统一挂到同一个处理入口 saea.Completed OnIoCompleted; _pool.Push(saea); } } public SocketAsyncEventArgs Pop() _pool.TryPop(out var saea) ? saea : new SocketAsyncEventArgs(); public void Push(SocketAsyncEventArgs saea) { // 复位关键字段避免脏数据带到下一次复用 saea.AcceptSocket null; saea.SetBuffer(null, 0, 0); saea.RemoteEndPoint null; _pool.Push(saea); } }参数说明capacity决定池子大小一般设置成最大连接数的两倍加若干余量因为每个连接要两个 Saea。OnIoCompleted是所有操作共享的回调入口内部通过saea.LastOperation区分是 Accept、Receive 还是 Send。这个事件的挂接只需要一次对象从池里拿出来直接可用省掉频繁挂事件的成本。3.3 Completed 回调的流转逻辑IO 完成事件是整个项目的中枢。一次 Accept 完成回到回调里一次 Receive 完成也回到回调里。回调入口要按操作类型分发到不同处理方法。private void OnIoCompleted(object sender, SocketAsyncEventArgs e) { switch (e.LastOperation) { case SocketAsyncOperation.Accept: ProcessAccept(e); break; case SocketAsyncOperation.Receive: ProcessReceive(e); break; case SocketAsyncOperation.Send: ProcessSend(e); break; } }逻辑说明LastOperation是 Saea 自带的属性操作系统完成 IO 后会自动填好。这里最怕的是业务逻辑写进ProcessReceive后耗时过长因为事件回调跑在 IOCP 线程上。项目里的做法是拆包只做简单复制和长度判断业务数据的实体化丢给后续线程池或独立任务处理。4. 完整实现初始化、监听、收发链路与压测4.1 服务端启动与监听循环服务端启动要做三件事创建 SaeaPool、启动监听 Socket、开始 Accept 循环。Accept 用AcceptAsync这是 IOCP 流程里最容易写错的一步因为接受新连接后要立刻为下一次 Accept 准备新的 Saea。public class AsyncTcpServer { private Socket _listenSocket; private SaeaPool _saeaPool; private int _maxConnections; public AsyncTcpServer(int maxConnections, int bufferSize) { _maxConnections maxConnections; _saeaPool new SaeaPool(maxConnections * 2); } public void Start(int port) { _listenSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _listenSocket.Bind(new IPEndPoint(IPAddress.Any, port)); _listenSocket.Listen(512); // 预先投递一个 Accept 请求到完成端口 var acceptArgs new SocketAsyncEventArgs(); acceptArgs.Completed OnIoCompleted; StartAccept(acceptArgs); Console.WriteLine($server started on {port}); } private void StartAccept(SocketAsyncEventArgs acceptArgs) { acceptArgs.AcceptSocket null; // 返回值 false 表示操作同步完成true 表示等待完成端口通知 if (!_listenSocket.AcceptAsync(acceptArgs)) { ProcessAccept(acceptArgs); } } }逻辑说明AcceptAsync的返回值是反直觉的——返回true表示异步挂起等完成事件返回false表示刚好同步完成了要直接处理否则这个连接就没人接。所以无论哪种情况都要调用ProcessAccept只是入口路径不同。Listen(512)的 512 是内核 accept 队列长度高并发下可以调大。4.2 接收链路从完成回调到粘包拆包ProcessAccept拿到新的客户端连接后要从池里取一个 Saea 和一个 Token投递第一次 Receive。这里有个常见错误复用 accept 用的 Saea 来收发数据会导致 Accept 操作状态丢失连接一多就乱套。项目里是 Accept 专用的 Saea 归还池中新的 Receive Saea 另行分配。private void ProcessAccept(SocketAsyncEventArgs acceptArgs) { if (acceptArgs.SocketError SocketError.Success) { var clientSocket acceptArgs.AcceptSocket; var token new AsyncUserToken(8192) { Socket clientSocket }; // 从池里取一个 Saea 用于后续接收 var receiveArgs _saeaPool.Pop(); receiveArgs.UserToken token; receiveArgs.SetBuffer(token.ReceiveBuffer, 0, token.ReceiveBuffer.Length); if (!clientSocket.ReceiveAsync(receiveArgs)) { ProcessReceive(receiveArgs); } } // 归还 Accept Saea继续为新连接服务 var newAcceptArgs new SocketAsyncEventArgs(); newAcceptArgs.Completed OnIoCompleted; StartAccept(newAcceptArgs); }接收完成的回调ProcessReceive负责处理数据、拆包、再投递下一次接收。这里贴核心逻辑private void ProcessReceive(SocketAsyncEventArgs e) { var token e.UserToken as AsyncUserToken; if (e.SocketError ! SocketError.Success || e.BytesTransferred 0) { CloseClient(token); return; } int offset token.ReceiveOffset; int length token.ReceiveLength; int received e.BytesTransferred; // 新区间原有有效数据 新到达数据 token.ReceiveLength length received; // 拆包循环解析出完整包直到缓冲中剩余数据不足一个包头 while (true) { int headerSize 4; // 假设包头 4 字节表示包长 if (token.ReceiveLength headerSize) break; int bodyLength BitConverter.ToInt32(token.ReceiveBuffer, token.ReceiveOffset); if (bodyLength 0 || bodyLength token.ReceiveBuffer.Length - headerSize) { CloseClient(token); return; } int totalLen headerSize bodyLength; if (token.ReceiveLength totalLen) break; // 半包等待下次 byte[] payload new byte[bodyLength]; Buffer.BlockCopy(token.ReceiveBuffer, token.ReceiveOffset headerSize, payload, 0, bodyLength); // 消费掉这个包偏移前移 token.ReceiveOffset totalLen; token.ReceiveLength - totalLen; // 业务层回调 OnDataReceived(token, payload); } // 缓冲满了但还没拆出完整包说明预分配不够关闭连接防止内存疯涨 if (token.ReceiveLength token.ReceiveBuffer.Length) { CloseClient(token); return; } // 继续投递接收 e.SetBuffer(token.ReceiveOffset, token.ReceiveBuffer.Length - token.ReceiveOffset); if (!token.Socket.ReceiveAsync(e)) { ProcessReceive(e); } }逻辑说明每次接收完成后先算当前缓冲里有多少有效数据然后循环尝试拆包。拆出一个完整包就回调业务层剩余数据保留。拆完后把ReceiveBuffer剩余空间投递为下一次接收目标。注意关闭条件如果缓冲满且凑不出完整包说明包长超过缓冲大小继续等只会无限拉长直接断开更安全。参数说明包头 4 字节是本例约定的协议BitConverter.ToInt32大端小端要看客户端定义跨平台通信要显式用BinaryPrimitives类读固定端序。SetBuffer的 offset 参数配合ReceiveOffset使用保证新数据落在有效数据之后不会覆盖未拆完的半包。4.3 发送链路写队列与 IsSending 标记发送最大的坑是多个线程同时调SendAsync导致数据交错。项目用「单飞」模式解决任何时刻只有一个正在进行的发送其余数据排队。public void SendMessage(AsyncUserToken token, byte[] data) { lock (token.SendQueue) { token.SendQueue.Enqueue(data); if (token.IsSending) return; // 已有发送在飞等回调 token.IsSending true; } SendFromQueue(token); } private void SendFromQueue(AsyncUserToken token) { byte[] payload; lock (token.SendQueue) { if (token.SendQueue.Count 0) { token.IsSending false; return; } payload token.SendQueue.Dequeue(); } var sendArgs GetSendSaea(token); sendArgs.SetBuffer(payload, 0, payload.Length); if (!token.Socket.SendAsync(sendArgs)) { ProcessSend(sendArgs); } } private void ProcessSend(SocketAsyncEventArgs e) { var token e.UserToken as AsyncUserToken; if (e.SocketError ! SocketError.Success) { CloseClient(token); return; } SendFromQueue(token); // 继续发下一条 }逻辑说明IsSending是发送链路的闸门。第一次调用时入队并置为true立即发起异步发送发送完成回调里继续拿队列下一条直到队列清空才把IsSending置回false。这样同一时刻只有一个SendAsync在途TCP 数据顺序有保证。加锁范围只覆盖队列操作不覆盖 Socket 调用本身避免长时间持锁阻塞业务线程。4.4 客户端压测验证并发能力项目自带一个压测客户端用Task.Run并发建立连接每个连接循环发固定格式的包并等待回包。核心代码值得贴一下因为它能直接验证服务端的吞吐class StressClient { public static async Task RunAsync(int connectionCount, int messageCount) { var tasks new ListTask(); for (int i 0; i connectionCount; i) { int index i; tasks.Add(Task.Run(async () { using var client new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await client.ConnectAsync(127.0.0.1, 8888); for (int j 0; j messageCount; j) { byte[] body Encoding.UTF8.GetBytes($msg-{index}-{j}); byte[] header BitConverter.GetBytes(body.Length); var packet header.Concat(body).ToArray(); await client.SendAsync(packet, SocketFlags.None); await client.ReceiveAsync(new byte[4], SocketFlags.None); } })); } await Task.WhenAll(tasks); } }参数说明connectionCount压连接数messageCount压单连接消息频率。建议先跑 1000 连接 × 100 条消息看任务是否全部完成再逐步升到 5000、10000 连接观察服务端内存和线程数是否平稳。正常情况下线程数始终维持在预设的 IOCP 线程数附近内存增长平缓——如果线程数跟着连接数涨说明代码里某处又在用同步模式了。5. 避坑指南IOCP 跑不通的 5 个常见原因5.1 现象Saea 复用后接收缓冲区内容错乱根源SocketAsyncEventArgs在SetBuffer后再次投递时没有重置BytesTransferred或上次缓冲残留没清干净。解决每次投递前主动SetBuffer指定新的 offset 和 count拆完包消费掉的数据通过移动ReceiveOffset处理不做清零。在SaeaPool.Push里把缓冲重置为 0也能避免归还池后再取出时的脏数据问题。5.2 现象高并发下大量SocketException: An existing connection was forcibly closed根源最常见的原因是拆包逻辑出 bug半包判断不对导致服务端误认为协议非法主动CloseClient另外客户端异常退出也会触发这个异常。解决先在ProcessReceive里打印SocketError和BytesTransferred区分是远端重置还是本地关闭。拆包边界条件要在本地写好尤其包长等于缓冲区长度的临界情况缓冲区满的判断要放在拆包之前或之后仔细核对。5.3 现象发送数据只有前几个字节根源SendAsync不保证一次把整个 buffer 发完它只保证至少发了一段剩余部分要由BytesTransferred算出来后继续发。解决发送回调里判断e.BytesTransferred是否等于当前 buffer 长度不等于就偏移续发。很多从阻塞模式转过来的开发者容易漏掉这一点。项目中通过写队列一次只能发一个完整包的约定规避了这个问题但如果后续改造支持超大包必须补上续发逻辑。5.4 现象AcceptAsync 运行一段时间后不再接受新连接根源ProcessAccept里没有在每次处理后重新投递 Accept或者 Accept 专用的 Saea 被拿去当收发 Saea状态被污染。解决每次ProcessAccept结束前确保用一个干净的 Saea 调用StartAccept。项目里单独维护 Accept Saea不和收发 Saea 混用就是这个原因。监控时留意连接数是否稳定在某个阈值戴了帽子就说明 Accept 循环断了。5.5 现象内存占用随连接数线性翻倍根源AsyncUserToken里的SendQueue无限增长业务侧往下游推送消息太快IO 线程来不及发出去。解决给SendQueue加上限超过阈值直接断开或丢弃并记录日志同时检查SendFromQueue有没有正确在发送失败时清空队列。这个项目里IsSending保证单飞但如果业务线程一直入队队列还是会涨所以生产环境一定要设上限这是血泪经验。6. 二次开发把例子改造成带协议的网关框架6.1 先定义协议再改拆包例子里的 4 字节包长是演示用实际项目要按业务定义协议头。常见做法是包头固定 8 字节前 4 字节是包体长度后 4 字节是命令字包体放 JSON 或 MessagePack。public static class PacketProtocol { public const int HeaderSize 8; public static byte[] Encode(int cmd, string json) { byte[] body Encoding.UTF8.GetBytes(json); byte[] header new byte[HeaderSize]; BitConverter.TryWriteBytes(header.AsSpan(0, 4), body.Length); BitConverter.TryWriteBytes(header.AsSpan(4, 4), cmd); return header.Concat(body).ToArray(); } public static (int cmd, int bodyLen) DecodeHeader(byte[] buffer, int offset) { int bodyLen BitConverter.ToInt32(buffer, offset); int cmd BitConverter.ToInt32(buffer, offset 4); return (cmd, bodyLen); } }改造时只动ProcessReceive里的拆包部分先把 8 字节完整读出来再按bodyLen判断是否凑够一个包传到业务层的是一个Packet对象而不是裸字节数组。OnDataReceived里用DecodeHeader取出命令字走分发器找到对应处理器。6.2 加心跳与断线重连长连接必须处理半开连接。一个稳定做法是AsyncUserToken里记录LastActiveTime接收任何数据都刷新每个连接额外挂一个 30 秒心跳包服务端开一个定时任务扫描所有 Token超过 90 秒没活跃的调用CloseClient。客户端则定时发心跳包收到心跳回包后重置计数。扫描定时器注意别和 IOCP 线程抢锁简单做法是每 10 秒遍历一次 Token 字典对超过阈值的连接先标记再关闭。关闭操作要放到 IO 线程外执行避免在锁内直接调Socket.Close造成阻塞。6.3 改造验证计数器、日志与压测对比改完不能只说「看上去能跑」。我会在服务端加两个计数器累计接收包数、累计发送包数配合每秒打印吞吐再统计当前连接数峰值。压测时分别用改造前和改造后的服务跑同一组数据对比吞吐和 GC 次数用 .NET 的性能计数器看 Gen0/Gen1 回收频率。验证步骤是固定的先用 1000 连接跑通功能确认心跳包在日志里按时出现再升到 5000 连接跑 10 分钟看连接数是否稳定、内存是否收敛最后压最大连接数观察SocketException出现的频率。任何日志异常第一时间去翻ProcessReceive和SendFromQueue的边界条件这两个方法不守住一切压测数字都没有意义。从那以后我每次改完收发链路都会强制把这三个计数器跑一遍——包数、连接数、异常数——数字对不上就不上生产。这套流程也是从拆这个 IOCP 项目之后养成的习惯。希望帮到你。本文还有配套的精品资源点击获取