简介:这份资源面向需要深入理解 Windows 完成端口(IOCP)与 SocketAsyncEventArgs 高并发模型的中高级 C# 开发者,围绕大容量长连接通讯场景,提供可运行的性能与压力测试示例。示例涵盖通讯封装、服务端日志查看、SOCKET 列表管理、上传下载、远程文件流及吞吐量协议等模块,支持 65535 个长连接,在 127.0.0.1 环境下命令交互速度可达 250MB/S,服务端采用 C# 编写并集成 log4net 日志组件。压缩包共 300 个文件,约 3.35MB,以 cs 源码、dll 依赖库、res 与 dfm 界面资源、pas 及 cpp 辅助代码、cfg 配置和 bmp/cur 图标素材为主,另含 sln、csproj 等工程文件,便于直接编译调试。已有 1164 人学习,适合借此研究 IOCP 连接调度、吞吐量优化与日志排错思路。
1. 从一台压测机被打满说起:这个 IOCP 例子到底解决什么问题
去年帮一个做工业数据采集的团队看服务端,现场是这样的:几十台设备通过 TCP 长连接往一台 Windows 服务器上报数据,连接数一过两千,CPU 没满、内存没爆,但消息延迟开始抖,偶尔还丢包。抓了半天发现瓶颈不在业务逻辑,而在 socket 模型——每个连接开一个线程阻塞收数据,线程上下文切换把机器拖垮了。后来换成完成端口(IOCP)重写收发层,同样硬件下连接数翻了几倍,延迟曲线也平了。这份「C# 高性能大容量 SOCKET 并发完成端口例子」,讲的就是这类场景的最小可跑骨架:用 C# 把 IOCP 的 Accept、Receive、Send 三条异步链路串起来,做成一个能扛大量并发连接的 TCP 服务端 Demo。适合谁?正在写 Windows 下的长连接网关、采集服务、推送服务,或者被BeginReceive回调地狱和线程池饥饿折腾过,想看清 IOCP 在 C# 里到底怎么落地的人。它不是框架,是一份能编译、能压测、能照着改的工程样例。
2. 架构拆解:IOCP 在 C# 里的三层结构
2.1 为什么是完成端口,而不是每连接一线程
先把选型逻辑说清楚,不然读代码会没有方向感。传统阻塞模型里,一个连接占一个线程,线程在Receive上挂起,操作系统帮你等数据。连接数上千后,线程栈内存和调度开销就成了硬成本,而且大部分线程长期处于等待状态,纯浪费。IOCP 的思路反过来:少量工作线程守着完成端口,内核把「某个 socket 的某个异步操作完成了」这件事打包成完成包投递过来,线程取一个处理一个,处理完继续取。线程数固定,连接数可以很大,这就是「大容量并发」的来源。
C# 里有两套写法能碰到 IOCP:一套是SocketAsyncEventArgs配合AcceptAsync/ReceiveAsync/SendAsync,底层直接走完成端口;另一套是后来基于Task的异步 API,内部同样落到 IOCP,但把完成包调度藏进了线程池。这个例子走的是前一套,原因是它把缓冲区复用、完成事件回调、连接生命周期都摊开给你看,改起来可控。常见做法是:监听 socket 只负责 Accept,每个客户端连接持有一个SocketAsyncEventArgs用于收、一个用于发,缓冲区从池里借,避免每次收发都new byte[]。
2.2 目录与模块划分
这类例子的工程结构一般不会太复杂,但模块边界要清楚,否则二次开发时容易把协议解析塞进 IO 回调里,越写越乱。按这个项目的定位,合理的组织大致是这样:
IocpSocketServer/ ├── Program.cs // 入口,启动监听、注册信号 ├── Server/ │ ├── IocpServer.cs // 监听、Accept 循环、连接管理 │ ├── Connection.cs // 单连接状态机:收/发/关闭 │ └── BufferManager.cs // 缓冲区池,按块分配复用 ├── Protocol/ │ ├── PacketParser.cs // 粘包/半包处理,长度前缀解析 │ └── MessageHandler.cs // 业务分发,与 IO 层解耦 └── Config/ └── ServerConfig.cs // 端口、最大连接数、缓冲区大小关键设计点在于Connection和MessageHandler之间要有一层边界:IO 层只负责把字节流攒成完整包,业务层只处理完整包。很多新手把 JSON 反序列化直接写在ReceiveAsync的回调里,一旦包不完整就抛异常,整个连接状态就乱了。这个例子把解析单独抽出来,就是为了让你改协议时不动 IO 代码。
2.3 一次收发的完整链路
把链路走一遍,后面看代码才不迷路。服务端启动时创建监听 socket,绑定端口后调用AcceptAsync,传入一个专门用于 Accept 的SocketAsyncEventArgs。当有客户端连入,完成事件触发,回调里拿到新 socket,为它创建Connection对象,立刻发起第一次ReceiveAsync,然后把 Accept 用的 args 重置,再次投递AcceptAsync等待下一个连接——注意这一步必须重新投递,否则只能接一个连接,这是最经典的翻车点。
收数据时,ReceiveAsync完成后检查BytesTransferred,把数据追加到该连接的接收缓冲区,交给PacketParser判断是否凑齐一个完整包。凑齐就回调业务层,没凑齐就继续投递下一次ReceiveAsync。发数据时,业务层把要发的字节交给Connection.Send,如果当前没有发送在进行,就直接SendAsync;如果已有发送在途,就追加到待发队列,等发送完成回调里再取下一个。这个「发送串行化」是必须的,同一个 socket 上并发调SendAsync会导致数据交错,血泪经验。
3. 核心代码拆解:Accept、Receive、Send 三段怎么落地
3.1 监听与 Accept 循环
先看服务端启动和 Accept 的骨架。下面这段是典型写法,参数含义我在注释里标清楚:
public class IocpServer { private Socket _listenSocket; private SocketAsyncEventArgs _acceptArgs; private readonly int _maxConnections; private int _currentConnections; public void Start(int port, int backlog = 512) { _listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // 关闭 Nagle,小包场景降低延迟;吞吐优先可保留默认 _listenSocket.NoDelay = true; _listenSocket.Bind(new IPEndPoint(IPAddress.Any, port)); _listenSocket.Listen(backlog); // backlog 是内核等待队列长度 _acceptArgs = new SocketAsyncEventArgs(); _acceptArgs.Completed += OnAcceptCompleted; StartAccept(_acceptArgs); } private void StartAccept(SocketAsyncEventArgs args) { args.AcceptSocket = null; // 复用前必须清空 if (!_listenSocket.AcceptAsync(args)) { // 同步完成也要走同一个回调,保证逻辑单一路径 OnAcceptCompleted(_listenSocket, args); } } private void OnAcceptCompleted(object sender, SocketAsyncEventArgs args) { if (args.SocketError == SocketError.Success) { var client = args.AcceptSocket; client.NoDelay = true; if (Interlocked.Increment(ref _currentConnections) > _maxConnections) { Interlocked.Decrement(ref _currentConnections); client.Close(); } else { var conn = new Connection(client, this); conn.BeginReceive(); // 立刻投递第一次收 } } // 无论成功失败都要重新投递,否则 Accept 循环断掉 StartAccept(args); } }逻辑说明:AcceptAsync返回false表示操作已同步完成,此时不会触发Completed事件,所以必须手动调一次回调,否则这个连接就丢了——这是SocketAsyncEventArgs模型里最容易忽略的细节。backlog参数控制内核里已完成三次握手但还没被应用层取走的连接队列长度,压测时如果看到大量连接被拒,先调大它。_maxConnections的计数用Interlocked保证多线程下准确,超限直接关掉新连接,避免被拖垮。
3.2 接收与粘包处理
接收侧的核心是把字节流攒成完整包。下面这段展示Connection的接收循环和解析入口:
public class Connection { private readonly Socket _socket; private readonly SocketAsyncEventArgs _recvArgs; private readonly byte[] _recvBuffer; // 单次接收缓冲区 private readonly MemoryStream _pending; // 未凑齐的残留数据 private readonly PacketParser _parser; public Connection(Socket socket, IocpServer server) { _socket = socket; _recvBuffer = new byte[8192]; // 常见取值 4K~64K,按包大小调 _pending = new MemoryStream(); _parser = new PacketParser(); _recvArgs = new SocketAsyncEventArgs(); _recvArgs.SetBuffer(_recvBuffer, 0, _recvBuffer.Length); _recvArgs.Completed += OnReceiveCompleted; } public void BeginReceive() { if (!_socket.ReceiveAsync(_recvArgs)) { OnReceiveCompleted(_socket, _recvArgs); } } private void OnReceiveCompleted(object sender, SocketAsyncEventArgs args) { if (args.SocketError != SocketError.Success || args.BytesTransferred == 0) { Close(); // 0 字节代表对端正常关闭 return; } _pending.Write(_recvBuffer, 0, args.BytesTransferred); var data = _pending.ToArray(); int consumed = _parser.Parse(data, out var packets); // 把已消费部分从残留缓冲里剔除 _pending.SetLength(0); if (consumed < data.Length) _pending.Write(data, consumed, data.Length - consumed); foreach (var pkt in packets) MessageHandler.Dispatch(this, pkt); BeginReceive(); // 继续收下一批 } }逻辑说明:BytesTransferred == 0是对端关闭连接的信号,必须走Close释放资源,否则连接对象泄漏。_pending用MemoryStream暂存半包,Parse返回已消费的字节数,剩下的留到下次拼接。PacketParser的职责是按协议约定(常见是 4 字节长度前缀)切包,它不碰 socket,纯内存操作,方便单测。缓冲区大小 8192 是经验值,如果你的单包经常超过这个数,调大到 16384 或 32768,减少拼接次数。
3.3 发送串行化与队列
发送侧要解决的是「同一连接上不能并发发」的问题。做法是维护一个待发队列和一个「是否在发送中」的标志:
private readonly Queue<byte[]> _sendQueue = new Queue<byte[]>(); private bool _sending; private readonly object _sendLock = new object(); public void Send(byte[] data) { lock (_sendLock) { _sendQueue.Enqueue(data); if (_sending) return; // 已有发送在途,排队即可 _sending = true; } SendNext(); } private void SendNext() { byte[] buffer; lock (_sendLock) { if (_sendQueue.Count == 0) { _sending = false; return; } buffer = _sendQueue.Dequeue(); } _sendArgs.SetBuffer(buffer, 0, buffer.Length); if (!_socket.SendAsync(_sendArgs)) OnSendCompleted(_socket, _sendArgs); } private void OnSendCompleted(object sender, SocketAsyncEventArgs args) { if (args.SocketError != SocketError.Success) { Close(); return; } SendNext(); // 发完一个再取下一个 }逻辑说明:_sending标志保证同一时刻只有一个SendAsync在途,队列保证顺序。lock的范围要小,只包住队列操作,不要包住SendAsync调用,否则回调里再进锁会死锁。SendAsync同样有同步完成的情况,必须手动调回调。如果业务层发送频率很高,队列会涨,常见做法是加一个上限,超了就断开慢客户端,防止内存被撑爆。
4. 跑起来与压测:从编译到看到连接数
4.1 环境与启动步骤
这个例子面向 .NET 平台,用dotnetCLI 或 Visual Studio 都能跑。步骤不复杂,但顺序别乱:
# 1. 确认 SDK 版本(项目一般要求 .NET 6 及以上) dotnet --version # 2. 还原依赖并编译 dotnet build -c Release # 3. 启动服务端,默认监听 9000 端口 dotnet run -c Release --project IocpSocketServer # 4. 另开终端,用自带客户端或 telnet 验证连通 # 发一条按协议封装的测试包,观察服务端日志编译用Release,Debug下 JIT 优化关闭,压测数据没有参考价值。启动后先别急着上压测工具,用单个客户端连上去发一条完整包,确认能收到回显,再逐步加连接。端口、最大连接数、缓冲区大小这些参数集中在ServerConfig.cs,改完重新编译即可,不建议做成命令行参数,例子阶段保持简单。
4.2 压测时该看哪些指标
压测不是把连接数拉满就完事,要看几个关键点。第一是连接建立速率,如果 Accept 跟不上,说明backlog太小或 Accept 回调里有阻塞操作。第二是单连接吞吐,用固定大小的包持续发,看服务端 CPU 和延迟。第三是内存,重点看缓冲区池有没有被正确复用,如果 GC 频繁触发,多半是每次收发都在new byte[]。第四是异常断开,模拟客户端直接拔线,确认服务端能通过BytesTransferred == 0或SocketError正确清理连接,不留下僵尸对象。
提示:压测客户端和服务端不要跑在同一台机器上,否则端口耗尽和 CPU 争抢会污染结果。连接数从 500 起步,每次翻倍,观察延迟曲线的拐点。
4.3 参数怎么调
几个参数值得单独说。backlog默认 512,高并发建连场景调到 1024 以上。单次接收缓冲区 8192,包大就调大,但别超过 64K,否则大对象堆压力上来。_maxConnections按机器内存估,每个连接占用的内存主要是接收缓冲加待发队列,粗算每连接几十 KB,再留余量。NoDelay打开适合小包低延迟,如果业务是大块数据传输,关掉它让 Nagle 合并小包反而更省。这些没有标准答案,压测一遍看数据说话。
5. 避坑与排查:五个真实踩过的坑
5.1 只能接一个连接就卡住
现象:第一个客户端连上正常,第二个连不上,服务端没有任何报错。原因:OnAcceptCompleted里忘了重新调用StartAccept,或者只在成功分支里调了,失败分支直接返回。解决:把重新投递放在回调末尾,无论SocketError是什么都执行,保证 Accept 循环永不断。
5.2 高并发下数据错乱
现象:客户端收到的包内容串了,A 的消息里混进了 B 的片段。原因:多个线程同时对同一个 socket 调SendAsync,或者共享了同一个SocketAsyncEventArgs和缓冲区。解决:每个连接独立持有收发 args 和缓冲区,发送走队列串行化,绝不复用跨连接的 args。
5.3 内存持续上涨不回落
现象:压测跑一段时间后内存曲线只涨不跌,GC 回收后仍高位。原因:连接关闭时没有释放SocketAsyncEventArgs和缓冲区,或者_pending流没清空;也可能是待发队列无上限,慢客户端把队列撑爆。解决:Close里统一释放资源,队列加上限,超限断开连接。
5.4 半包导致解析异常
现象:业务层偶尔抛数组越界或反序列化失败。原因:ReceiveAsync一次收到的数据不保证是一个完整包,可能半个,也可能一个半。解决:所有解析必须基于累积缓冲,先判断长度前缀是否到齐,不够就等下次,够才切包,切完把剩余字节保留。
5.5 同步完成路径被漏掉
现象:低负载时一切正常,压测时偶发丢消息或连接挂起。原因:AcceptAsync/ReceiveAsync/SendAsync返回false时表示同步完成,不会触发Completed,代码只处理了异步分支。解决:每次调用后判断返回值,false就手动调一次回调,保持单一路径。
6. 二次开发:把例子改成能用的服务
拿到这份代码后,第一件该做的事不是加业务,而是把 IO 层和业务层的边界钉死。我的习惯是定义一个IMessageHandler接口,Connection只依赖这个接口,具体实现放外面,这样换协议、加鉴权、接消息队列都不用动 socket 代码。下面是一个改造骨架:
public interface IMessageHandler { void OnConnected(Connection conn); void OnMessage(Connection conn, ReadOnlyMemory<byte> packet); void OnClosed(Connection conn, string reason); } // 业务实现里做反序列化和分发,IO 层完全不知道业务存在 public class EchoHandler : IMessageHandler { public void OnMessage(Connection conn, ReadOnlyMemory<byte> packet) { // 原样回显,替换成你的业务逻辑 conn.Send(packet.ToArray()); } public void OnConnected(Connection conn) { } public void OnClosed(Connection conn, string reason) { } }改造时几个验证点要盯住。一是连接生命周期,OnConnected和OnClosed必须成对出现,用计数器核对,防止泄漏。二是背压,业务处理慢的时候,接收侧要不要暂停投递ReceiveAsync,这是防止内存被打爆的关键开关,例子阶段可以先不做,上生产必须补。三是心跳,长连接必须有超时检测,常见做法是记录最后一次收到数据的时间,后台定时扫描,超时就断开。四是优雅关闭,进程退出前先停止 Accept,再逐个关闭连接,给在途数据一点时间发完。
还有一个具体技巧:把BufferManager做成真正的池,按固定块大小分配,连接借块、归还块,避免大对象堆碎片。压测时对比改造前后的 GC 次数,差距会很明显。另外,日志不要写在 IO 回调里同步刷盘,改成无锁队列加后台线程消费,否则磁盘一慢,整个收发链路跟着卡。
从那以后我每次接手这类 socket 服务,都强制先跑一遍「单连接回显 → 500 连接压测 → 拔线重连」这三步,确认基础链路没问题再动业务代码,省下不少排查时间。这份例子不大,但把 IOCP 在 C# 里的关键路径都摆出来了,照着跑通、改一版自己的协议,比读十篇原理文章管用。希望帮到你。
本文还有配套的精品资源,点击获取