首页 / 资讯中心 / 文章详情

C# 大容量 Socket 并发:IOCP 完成端口与 SAEA 对象池实践

C# 大容量 Socket 并发:IOCP 完成端口与 SAEA 对象池实践 ★ FEATURED ARTICLE
简介这是一份面向中高级 C# 网络编程开发者的 IOCP 完成端口实战示例围绕 SocketAsyncEventArgs 封装、服务端日志查看、Socket 列表、上传下载、远程文件流与吞吐量协议展开可用于评估高并发长连接场景下的性能与压力表现。压缩包共 300 个文件约 3.35MB包含 20 个 cs 源码、大量 pas/dfm/res 窗体与资源文件以及 dll、cpp、cfg、bmp 等配套内容便于对照服务端实现与测试模块。已有 1166 人学习下载。示例支持约 65535 个长连接在 127.0.0.1 环境下命令交互速度可达 250MB/s并借助 log4net 记录运行日志读者可从中梳理完成端口通讯封装、连接管理、文件传输与吞吐量压测思路适合作为高并发 Socket 服务端的学习与验证参考。1. C# 大容量 Socket 并发为什么绕不开完成端口从一次连接数压测翻车说起某开发者把“每连接一线程”的 echo 服务压到 8k 连接时CPU 没跑满P99 延迟却从 12ms 抖到 300ms换成 C#高性能大容量SOCKET并发完成端口IOCP的骨架后同样机器连接数翻了几倍延迟曲线才稳住。完成端口不是玄学它把 socket 读写交给内核完成队列应用层只处理“已完成”的事件避免大量线程阻塞在 recv/send 上。这份示例工程给的是可运行的 C# 服务端骨架监听、Accept、收包、发包、会话管理、对象池和基础压测入口。适合做长连接网关、采集服务、消息推送中台或者被线程池和 GC 拖垮的团队对照。它不是业务框架协议解析、鉴权、持久化要自己补直接抄收发循环不看连接生命周期后面大概率返工。2. 完成端口在 C# 里的落地模型SocketAsyncEventArgs 对象池与事件循环IOCP 在 C# 里最怕两件事把完成端口当线程池用以及把SocketAsyncEventArgs当一次性对象用。前者会让回调线程被业务阻塞后者会让高频收发变成分配风暴。比较稳的落地方式是每个连接挂一个常驻的接收上下文Accept 用小对象池发送队列按需分配业务处理从 IO 回调里挪走。2.1 为什么不是每个连接开线程IOCP 的线程模型拆解每连接一线程的模型在连接数少、业务简单时很直观一个线程阻塞在Receive收到数据就处理。但连接数上到几千、几万线程栈、上下文切换和锁竞争会一起上来。更麻烦的是很多线程大部分时间都在等数据CPU 利用率看着不高延迟却被调度拖出锯齿。完成端口的思路反过来socket 绑定到内核的完成端口读写操作投递后立即返回内核完成 IO 后把事件排进队列再由少量线程取出来处理。C# 里一般不直接 P/Invoke 完成端口而是用SocketAsyncEventArgs下称 SAEA。在 Windows 上Socket.ReceiveAsync、SendAsync、AcceptAsync底层会走 IOCP在 Linux 等平台上运行时换成 epoll 一类机制行为有差异调参不能直接照搬。SAEA 的好处是把缓冲区、偏移量、完成回调、错误码打包在一个可复用对象里避免每次收发都新建状态对象。代价是生命周期要自己管同一个 SAEA 不能同时投递两次接收回调没结束前不能归还池缓冲区也不能在发送排队时被下一次接收覆盖。线程模型上我一般让 IO 回调只做三件事校验状态、把完整包投递到业务队列、继续投递下一次接收。业务里的数据库写入、RPC 调用、复杂 JSON 解析放到独立的Channel消费循环里。回调里一旦阻塞线程池会被慢慢抽干表现就是连接还在但新包延迟越来越高最后看起来像“完成端口不工作了”。这不是玄学是线程池饥饿。提示SAEA 的完成回调运行在线程池线程上不要在里面做同步等待也不要把Task.Result这类调用带进去。2.2 最小可运行的 IOCP 服务端骨架监听、Accept、Receive下面这段是监听和 Accept 的核心骨架。省略了业务协议和日志保留连接进入后的关键动作。Accept 池只放少量 SAEAAccept 循环也不建议按 CPU 核数开满常见做法是 12 个循环把新连接尽快接进来再交给会话对象。using System; using System.Net; using System.Net.Sockets; using System.Threading; public sealed class IocpServer { private readonly Socket _listener; private readonly SocketAsyncEventArgsPool _acceptPool; private readonly SessionManager _sessions; public IocpServer(IPEndPoint endPoint, int backlog 1024) { _listener new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); _listener.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listener.Bind(endPoint); _listener.Listen(backlog); // Accept 用的 SAEA 做小池回调只负责把连接交给 Session _acceptPool new SocketAsyncEventArgsPool(64, e e.Completed OnAcceptCompleted); _sessions new SessionManager(); } public void Start() { // 不建议按 CPU 核数开满 Accept 循环12 个通常够用 for (int i 0; i 2; i) { StartAccept(); } } private void StartAccept() { var saea _acceptPool.Rent(); saea.AcceptSocket null; // AcceptAsync 可能同步完成必须处理返回值 if (!_listener.AcceptAsync(saea)) { OnAcceptCompleted(null, saea); } } private void OnAcceptCompleted(object? sender, SocketAsyncEventArgs e) { if (e.SocketError SocketError.Success) { var client e.AcceptSocket!; client.NoDelay true; var session new Session(client, _sessions); _sessions.Add(session); session.StartReceive(); } else { // 这里只记录错误不要抛异常打断 Accept 循环 // 常见错误包括连接被对端重置、监听 socket 已关闭 } e.AcceptSocket null; _acceptPool.Return(e); // 示例为了短直接递归投递高频同步完成时改成循环投递避免递归过深 StartAccept(); } }这段代码的关键参数不多但每个都容易踩坑。backlog是已完成三次握手、还没被 Accept 取走的队列长度不是最大连接数设得太小压测时会出现连接被拒。ReuseAddress主要缓解服务重启时端口占用不是提高并发的手段。NoDelay true关闭 Nagle适合小包低延迟场景但如果业务是批量大包收益不明显。AcceptAsync的返回值必须判断返回false表示已经同步完成直接走完成逻辑否则会漏连接。OnAcceptCompleted里不要抛异常一个未处理异常就可能让 Accept 循环断掉。接收侧每连接一个 SAEA比全局池更清晰。下面是最小接收循环Feed是协议解析器入口完整包在解析器里投递到业务队列不在 IO 回调里做重活。public sealed class Session { private readonly Socket _socket; private readonly SocketAsyncEventArgs _receiveArgs; private readonly byte[] _receiveBuffer; private readonly LengthPrefixParser _parser; private int _closed; public Session(Socket socket, SessionManager manager) { _socket socket; _receiveBuffer ArrayPoolbyte.Shared.Rent(8 * 1024); _receiveArgs new SocketAsyncEventArgs(); _receiveArgs.SetBuffer(_receiveBuffer, 0, _receiveBuffer.Length); _receiveArgs.Completed OnReceiveCompleted; _parser new LengthPrefixParser(manager, this); } public void StartReceive() PostReceive(); private void PostReceive() { if (Volatile.Read(ref _closed) ! 0) { return; } // 同一个 SAEA 同一时刻只能有一个 Receive 在飞 if (!_socket.ReceiveAsync(_receiveArgs)) { OnReceiveCompleted(null, _receiveArgs); } } private void OnReceiveCompleted(object? sender, SocketAsyncEventArgs e) { if (e.SocketError ! SocketError.Success || e.BytesTransferred 0) { Close(); return; } // 只做协议拆包和投递复杂业务放到 Channel 消费 _parser.Feed(new ReadOnlySpanbyte(e.Buffer!, e.Offset, e.BytesTransferred)); PostReceive(); } private void Close() { if (Interlocked.Exchange(ref _closed, 1) ! 0) { return; } try { _socket.Shutdown(SocketShutdown.Both); } catch { } _socket.Close(); ArrayPoolbyte.Shared.Return(_receiveBuffer); _receiveArgs.Dispose(); } }接收缓冲从 8KB 起步比较稳。太小会增加系统调用次数太大则在大连接数下把内存吃光10 万连接每连接多 8KB就是 800MB 的差距。e.BytesTransferred 0通常表示对端关闭要按关闭处理。ReceiveAsync返回false时同样要直接走完成逻辑。Close用Interlocked.Exchange保证只执行一次否则多个回调同时关闭会重复归还缓冲区。2.3 SAEA 池与缓冲区池把每次收发的高频分配压下去Accept 用的 SAEA 适合做小池因为 Accept 频率相对低但对象生命周期短。接收用的 SAEA 我一般直接挂在Session上不做全局池每连接一个关闭时释放生命周期跟连接一致少一类“归还后被另一个连接复用”的 bug。发送用的 SAEA 也可以每连接一个只允许一个SendAsync在飞。下面这个池只用于 Accept 这类短生命周期场景池满后直接丢弃防止无限膨胀。using System; using System.Collections.Concurrent; using System.Net.Sockets; public sealed class SocketAsyncEventArgsPool { private readonly ConcurrentBagSocketAsyncEventArgs _bag new(); private readonly int _max; public SocketAsyncEventArgsPool(int max, ActionSocketAsyncEventArgs? init null) { _max max; for (int i 0; i max; i) { var e new SocketAsyncEventArgs(); init?.Invoke(e); _bag.Add(e); } } public SocketAsyncEventArgs Rent() { if (_bag.TryTake(out var e)) { return e; } throw new InvalidOperationException(SAEA 池耗尽检查 Accept 是否泄漏); } public void Return(SocketAsyncEventArgs e) { if (_bag.Count _max) { return; } // 归还前清掉连接相关状态避免下一个 Accept 复用脏数据 e.AcceptSocket null; e.RemoteEndPoint null; _bag.Add(e); } }池的max不是越大越好。Accept 池 64 个足够应对多数突发连接关键看OnAcceptCompleted里有没有及时归还。归还前必须清AcceptSocket和RemoteEndPoint否则下一次 Accept 可能带着旧状态。Completed事件只在初始化时挂一次复用的时候不要重复挂否则一个完成会触发多次回调。缓冲区用ArrayPoolbyte.Shared可以降低 GC但要注意归还前确认没有在飞的发送或解析引用如果业务包需要跨线程排队最好把包体拷贝到独立缓冲别把ArrayPool的数组长期留在队列里。3. 大容量连接管理会话表、发送队列与粘包边界连接一多问题就从“能不能收”变成“怎么管”。会话表要能快速增删发送队列要有上限协议解析要能处理半包和粘包心跳要能发现僵尸连接。这几件事没做好IOCP 骨架跑通压测第一分钟就会暴露内存涨、延迟抖、关闭不掉。3.1 会话对象怎么设计连接 ID、状态机与超时字段会话对象不要塞业务大对象先保持轻量。下面这些字段是我一般会保留的最小集合业务状态可以放到独立字典或会话扩展里避免一个连接对象几百字节十万连接就是几十 MB 的额外开销。字段类型作用注意Idlong会话标识用Interlocked.Increment生成不要拿Socket.Handle当业务 IDSocketSocket底层连接关闭后置空避免回调继续用ReceiveArgsSocketAsyncEventArgs收包上下文每连接一个同一时刻只有一个在飞SendQueueQueueSendItem待发数据必须有字节上限不能无界PendingByteslong待发字节数背压依据用Interlocked更新Sendingint是否有发送在飞0/1 状态避免并发SendAsyncLastActiveUtclong最后活跃时间心跳扫描用收发包时更新Closedint关闭标志所有入口先检查保证只关闭一次会话表用ConcurrentDictionarylong, Session就够新增和移除都是 O(1)。不要在关闭时锁住整个表做遍历心跳扫描可以每 10 秒取一次快照再逐个检查LastActiveUtc。状态机保持简单Connected - Closing - Closed。Socket.Connected不能用来判断连接是否可用它只反映上一次 IO 操作的状态经常出现“显示已连接实际早断了”的情况。判断连接是否活着靠应用层心跳和最近活跃时间更可靠。3.2 发送队列与背压SendAsync 不是随便调发送比接收更容易出问题。接收是被动等数据发送是主动往内核写对端读得慢数据就会堆在用户态队列里。无界队列是延迟雪崩的起点内存涨、GC 频繁、发送锁竞争最后 P99 直接失控。比较稳的做法是每连接一个发送队列只允许一个SendAsync在飞队列按字节数设水位超过水位就拒绝或断开慢客户端。private readonly object _sendLock new(); private readonly QueueSendItem _sendQueue new(); private long _pendingBytes; private int _sending; private const int MaxPendingBytes 256 * 1024; // 单连接发送水位按业务调整 public bool TrySend(ReadOnlySpanbyte payload) { var buffer ArrayPoolbyte.Shared.Rent(payload.Length); payload.CopyTo(buffer); var item new SendItem(buffer, payload.Length); lock (_sendLock) { if (Volatile.Read(ref _closed) ! 0) { ArrayPoolbyte.Shared.Return(buffer); return false; } // 背压超过水位直接拒绝让上层决定断开或降速 if (_pendingBytes payload.Length MaxPendingBytes) { ArrayPoolbyte.Shared.Return(buffer); Interlocked.Increment(ref Metrics.RejectedByBackpressure); return false; } _sendQueue.Enqueue(item); _pendingBytes payload.Length; if (_sending 1) { return true; } _sending 1; } // 不要在锁内调用 SendAsync可能同步完成导致重入 PostSend(); return true; } private void PostSend() { SendItem item; lock (_sendLock) { if (_sendQueue.Count 0) { _sending 0; return; } item _sendQueue.Peek(); } _sendArgs.SetBuffer(item.Buffer, item.Offset, item.Length - item.Offset); if (!_socket.SendAsync(_sendArgs)) { OnSendCompleted(null, _sendArgs); } } private void OnSendCompleted(object? sender, SocketAsyncEventArgs e) { if (e.SocketError ! SocketError.Success) { Close(); return; } lock (_sendLock) { var item _sendQueue.Peek(); item.Offset e.BytesTransferred; _pendingBytes - e.BytesTransferred; if (item.Offset item.Length) { _sendQueue.Dequeue(); ArrayPoolbyte.Shared.Return(item.Buffer); } } PostSend(); }这里的关键不是代码本身而是三个约束。第一TrySend先复制到池化缓冲避免调用方修改原数组导致发出去的数据错位。第二_pendingBytes超过MaxPendingBytes就拒绝单连接 256KB 是起点大文件或视频场景要单独评估。第三只允许一个SendAsync在飞完成回调里处理“部分发送”BytesTransferred可能小于剩余长度必须推进Offset再继续发。锁内只改队列状态锁外投递发送避免同步完成时重入死锁。全局也要有水位所有连接PendingBytes加起来超过机器内存的一定比例就要拒绝新包或断开最慢的连接。3.3 粘包与心跳长度前缀协议与空闲检测TCP 是流一次Receive可能拿到半个包也可能拿到三个半包。示例工程用长度前缀4 字节大端长度 body。解析器要能累积半包、循环取完整包并且限制单包最大长度防止恶意长度把内存打爆。public sealed class LengthPrefixParser { private const int MaxBody 1024 * 1024; // 单包上限按业务改 private readonly GrowableBuffer _buffer new(); public void Feed(ReadOnlySpanbyte data) { _buffer.Write(data); while (true) { if (_buffer.Length 4) { return; } var bodyLen BinaryPrimitives.ReadInt32BigEndian(_buffer.FirstSpan); if (bodyLen 0 || bodyLen MaxBody) { throw new InvalidDataException(非法包长); } if (_buffer.Length 4 bodyLen) { return; } _buffer.Advance(4); var body _buffer.FirstSpan.Slice(0, bodyLen); // 拷贝或投递到业务 Channel不要在 IO 回调里同步处理 _onMessage(body.ToArray()); _buffer.Advance(bodyLen); } } }MaxBody必须设1MB 只是起点。长度字段用大端跨平台排查时不容易扯皮。_onMessage最好投递到Channelbyte[]由固定数量的消费者处理如果直接在回调里做数据库操作连接数一上来线程池就会被拖死。心跳用PeriodicTimer每 10 秒扫一次会话快照LastActiveUtc超过 90 秒没更新就关闭。不要每个连接开一个 Timer十万连接就是十万个定时器。TCP 自带的 KeepAlive 默认时间太长不能替代应用层心跳但应用层心跳也要走发送队列慢客户端心跳包同样会积压所以超时判断前先看PendingBytes避免把“发送队列堵了”误判成“连接死了”。4. 参数与配置监听 backlog、缓冲区大小、NoDelay 和 KeepAlive 怎么定IOCP 示例能不能扛住连接数代码结构只占一半另一半在参数。参数没有万能值但有一套起步基线先保证不因为 backlog 太小被拒再控制每连接内存最后用指标验证。下面这些值适合做第一轮压测不是最终答案。4.1 监听与连接参数backlog、双栈、NoDelay监听 socket 的参数不多但每个都影响连接建立的成功率。下面这段是常见的双栈监听写法DualMode打开后 IPv4 和 IPv6 都能进来如果只服务 IPv4直接用AddressFamily.InterNetwork更简单。var listener new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp); listener.DualMode true; listener.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Bind(new IPEndPoint(IPAddress.IPv6Any, 9000)); listener.Listen(4096);backlog设 4096 是压测起点线上要结合 Accept 速度和突发连接量。它不是最大连接数连接被 Accept 取走后就不占这个队列。NoDelay在连接建立后设置小包低延迟场景开批量大包场景可以关掉做合并发送。Linger不要随手设 0设 0 会在关闭时发 RST可能丢未发完的数据默认值通常更安全。ReceiveBufferSize和SendBufferSize是内核缓冲建议值不是应用层队列大小设太大反而占内存。参数建议起点说明backlog10244096太小会拒绝连接Accept 要及时NoDelay小包场景true关闭 Nagle降低小包延迟ReceiveBufferSize默认或 16KB内核接收缓冲不是应用缓冲SendBufferSize默认或 16KB本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站