news 2026/11/2 11:31:07

C# Socket高并发服务端:IOCP完成端口与万级连接实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# Socket高并发服务端:IOCP完成端口与万级连接实践

简介:这是一套面向C#网络编程开发者的高性能大容量Socket并发实践资料,围绕完成端口(IOCP)与SocketAsyncEventArgs封装展开,适合需要研究长连接服务端、压力测试和吞吐量优化的中高级读者。压缩包共300个文件,以pas、dfm、dpr等工程文件与cs源码、dll、res资源为主,并含配置、文档及工程文件,整体约3.35MB。已有1166人学习。服务端实现日志查看、Socket列表、上传、下载、远程文件流与吞吐量协议,可直接用于测试SocketAsyncEventArgs的性能和压力,最大支持65535个长连接,本机测试命令交互速度可达250MB/s,并采用log4net记录日志,便于理解连接管理、异步收发与吞吐量统计,也可复用其封装思路。

1. 从一台被连接数压垮的测试机说起:C# Socket 高并发为什么绕不开完成端口

前阵子帮人看一个 C# Socket 写的采集服务端,两千多路长连接,8 核机器,CPU 常年 85% 以上,进程里线程数一千八。代码本身不复杂,每个 Accept 起来的连接配一个线程阻塞读。这种写法几十路时看不出毛病,上千路就开始交学费:线程栈吃内存、上下文切换吃掉大半 CPU、线程调度抖动让延迟毛刺压不住。在 Windows 上把 C# Socket 做到真正的高并发、大容量,绕不开完成端口(IOCP)——收发请求全部交给内核,内核把完成事件排进队列,应用侧只用固定几个工作线程去取结果,连接数和线程数彻底解耦。这份示例就是一套不依赖第三方网络库的纯 C# 完成端口服务端骨架,从监听接入、IOCP 队列消费、缓冲区池到粘包拆包都写全了,适合想啃 Windows 网络底层、或者想拿一个能撑万级连接的底座改成自己业务服务端的人。

2. 工程骨架与跑通路径:从 clone 到压出第一组连接数

拿到这类示例,第一件事不是读代码,是把它跑起来,看它到底能在你的机器上撑住多少连接。这套工程的层次切得比较干净:原生 API 的 P/Invoke 声明单独一层,网络运行时一层,协议解析一层,业务分发一层。按这个顺序读,比从Program.cs一行行往下捋快得多。跑通过程中有两个点容易被忽略,一是 Release 编译,Debug 下缓冲区池的边界检查和高频日志会把压测数据带偏;二是防火墙,本地压测建议直接用回环地址,省得先跟入站规则纠缠。

2.1 目录结构与各工程职责

示例按三个工程拆分,服务端、压测客户端、公共协议库。这种拆法不是为了好看,是为了让你改协议的时候只动一处,服务端和压测端不会因为结构体对不齐而互相甩锅。

IocpDemo/ ├── IocpDemo.sln ├── src/ │ ├── IocpDemo.Server/ # 服务端主工程 │ │ ├── Program.cs # 入口:解析参数、装配、启动 │ │ ├── appsettings.json # 端口、线程数、缓冲区等运行参数 │ │ ├── Native/ │ │ │ ├── Kernel32.cs # CreateIoCompletionPort / GetQueuedCompletionStatus │ │ │ └── Winsock.cs # WSARecv / WSASend / AcceptEx / WSAIoctl │ │ ├── Net/ │ │ │ ├── IocpServer.cs # 监听、接入、工作线程调度 │ │ │ ├── IoContext.cs # OVERLAPPED + 会话 + 缓冲区 的绑定体 │ │ │ ├── Session.cs # 单连接状态机 │ │ │ ├── SessionManager.cs # 连接表与淘汰 │ │ │ └── BufferPool.cs # 定长缓冲区池 │ │ └── Protocol/ │ │ ├── PacketParser.cs # 长度前缀拆包 │ │ └── MessageDispatcher.cs # 按 cmd 分发到处理器 │ ├── IocpDemo.Client/ # 压测/联调客户端 │ └── IocpDemo.Common/ # 包头结构、序列化、常量 └── README.md

Native目录是纯声明,没有业务逻辑,出问题基本只在签名和SetLastError上;Net目录是这套示例的核心,所有和内核打交道的地方都在这里;Protocol目录完全不知道 IOCP 的存在,只吃byte[]吐消息对象。改业务的时候只看最后一层就够了,前面两层可以当黑匣子。我一般会先确认Native里的DllImport都带了SetLastError = true,漏了这个,后面所有错误码排查都会变成猜谜。

2.2 编译、启动与压测工具的准备

工程是标准的 SDK 风格 csproj,目标框架指向 Windows 桌面运行时即可,因为 IOCP 是 Windows 专有,跨平台移植这一步先别想。编译务必用 Release,然后在命令行把服务端拉起来,端口和最大连接数都从参数走,方便压测时反复调。

# 编译整个解决方案,Release 模式 dotnet build IocpDemo.sln -c Release # 启动服务端:监听 9000,最大连接 20000,工作线程数交给默认策略 dotnet run --project src/IocpDemo.Server -c Release -- --port 9000 --max-conn 20000 # 另开一个窗口跑压测端:5000 连接,每连接每秒 10 个心跳包 dotnet run --project src/IocpDemo.Client -c Release -- --host 127.0.0.1 --port 9000 --conn 5000 --qps 10

--max-conn不只是打印用的数字,它决定了SessionManager里连接表的预分配容量和是否启用淘汰,设置得比实际需要小,压测到阈值时会看到新连接被立刻关闭,很容易误判成服务端崩了。压测端建议从 500 开始阶梯往上加,每档跑满三分钟再看数据,直接上五千连接会把客户端自己的端口和句柄也顶到上限,你看到的现象就不再是服务端的问题了。想验证上限,客户端最好跑在另一台机器上,或者至少分多个进程发起。

2.3 运行时参数:线程数、缓冲区与超时

配置文件里那几个数字决定了这套框架的表现上限,改之前得知道它们各自影响什么。

参数典型值作用与影响
WorkerThreadCountCPU 核数,0 表示交给内核IOCP 并发线程数,大于核数会加剧上下文切换
RecvBufferSize8 KB单次WSARecv的缓冲区大小,决定单包上限
MaxPacketSize1 MB拆包层允许的最大包体,超过直接断连
SendQueueHighWater512单连接待发送队列上限,超过触发背压或踢连接
IdleTimeoutSeconds90无任何数据往来的连接回收时间

WorkerThreadCount是最容易被乱调的一个。完成端口的并发线程数指的是同一时刻允许有多少线程从队列里取完成包,设成 0 时内核默认按逻辑核数处理,这个默认值在绝大多数场景下就是最优解。有人怕处理不过来,手动设成 64 甚至 128,结果 CPU 全耗在线程切换上,吞吐反而掉一半。IdleTimeoutSeconds也别设得太激进,移动网络下客户端切后台几十秒不发包很常见,设成 15 秒会导致大量莫名掉线,然后被当成"服务端不稳"。

3. 完成端口核心链路:一个连接从 AcceptEx 到回收的完整过程

这套示例的主线只有一条:所有 IO 操作都先投递给内核,投递完立刻返回,等内核干完活把结果塞进完成端口队列,工作线程取出来继续下一步。理解这条主线之后,代码里那些看起来散落的Post、GCHandle、IoContext就都能串起来了。下面按连接的生命周期顺序拆,先是接入,再是收发,然后是队列消费,最后是缓冲区和回收。

3.1 监听与 AcceptEx:把接入也交给内核

普通Accept是阻塞的,一个线程等一个连接,这在高并发接入时是第一个瓶颈。AcceptEx的好处是把"等新连接"也变成一个异步 IO 请求,投递出去就不用管了,有新连接进来内核直接发完成通知。

// 预投递 N 个 AcceptEx,每个都绑定一个独立的接入上下文 private void PostAccept(int count) { for (int i = 0; i < count; i++) { var ctx = _acceptPool.Rent(); // 从池里取一个接入上下文 ctx.Session = _sessionManager.CreatePending(); // 预创建会话壳子 ctx.Operation = IoOperation.Accept; // 接入缓冲区要能放下两个 sockaddr,这是 AcceptEx 的硬性要求 IntPtr addrBuf = ctx.AddressBuffer; // 已固定的非托管内存 // 关键:把 OVERLAPPED 指针和完成键一起交给内核 bool ok = Winsock.AcceptEx( _listenSocket, // 监听 socket ctx.Session.SocketHandle, // 即将承载新连接的 socket ctx.BufferPtr, // 接收首批数据的缓冲区 0, // 首包不预收数据 AddrLen, AddrLen, // 本地/远端地址长度 out uint bytes, ctx.OverlappedPtr); // 指向固定住的 OVERLAPPED // 返回 false 且错误码是 ERROR_IO_PENDING 才是正常路径 if (!ok && Marshal.GetLastWin32Error() != ErrorIoPending) { ctx.Session.Close(); _acceptPool.Return(ctx); } } }

这段代码里最反直觉的是"失败才是正常"。AcceptEx投递成功后立刻返回false,错误码997(ERROR_IO_PENDING),意思是请求已入队、结果稍后通过完成端口通知。如果你把false当成失败去关连接,那服务端会一个连接都接不进来,这是新手最常翻的车。AddrLen一般取sockaddr_in加 16,微软文档明确要求缓冲区至少比地址结构大 16 字节。另外,预投递的数量就是同时能"排队等待接入"的连接数,投 10 个意味着第 11 个并发连接要等前面某个完成才被接受,所以这个值通常按预期峰值 QPS 来定,几十到几百都合理。

拿到完成通知后,还要用GetAcceptExSockaddrs把地址缓冲区解析出来,才能知道对端 IP 和端口。这一步很多人直接跳过,结果日志里全是0.0.0.0,线上排查连接来源时两眼一抹黑。示例里是在 Accept 完成分支里解析完立刻写进会话对象,同时把会话从"待定"移进正式连接表。

3.2 WSARecv 与 WSASend:OVERLAPPED 必须钉在内存里

收发环节的核心约束只有一条:传给内核的OVERLAPPED结构体地址,在整个 IO 完成之前不能变、不能被回收。托管对象在 GC 堆上会被移动,所以必须用GCHandle固定,或者干脆申请非托管内存。示例采用的是前者,把OVERLAPPED放在上下文结构体的第一个字段,这样结构体指针就是OVERLAPPED指针。

[StructLayout(LayoutKind.Sequential)] internal struct OVERLAPPED { public IntPtr Internal; public IntPtr InternalHigh; public int Offset; public int OffsetHigh; public IntPtr hEvent; } // IO 上下文:第一个字段必须是 OVERLAPPED,保证指针可以互转 internal sealed class IoContext { public OVERLAPPED Overlapped; // 必须在首位 public IoOperation Operation; // 读 / 写 / 接入 public Session Session; // 归属连接 public GCHandle SelfHandle; // 固定自身的句柄 public IntPtr BufferPtr; // 本次 IO 使用的缓冲区首地址 public int BufferLength; public int BytesTransferred; }

投递一次接收的代码如下,重点看flags和句柄固定这两处。

public bool PostRecv(IoContext ctx) { ctx.Operation = IoOperation.Recv; // 固定自身,防止 GC 移动;完成回调里再 Free ctx.SelfHandle = GCHandle.Alloc(ctx, GCHandleType.Pinned); var buf = new WSABUF { len = (uint)ctx.BufferLength, buf = ctx.BufferPtr }; uint flags = 0; uint received; int rc = Winsock.WSARecv( ctx.Session.SocketHandle, ref buf, 1, // 缓冲区个数,示例固定为 1 out received, ref flags, // 必须传引用,内核可能回写 ctx.SelfHandle.AddrOfPinnedObject(), IntPtr.Zero); // 不用完成例程,走 IOCP if (rc == SocketError.SocketError) { int err = Marshal.GetLastWin32Error(); if (err != ErrorIoPending) // 997 之外才是真错误 { ctx.SelfHandle.Free(); return false; } } return true; }

WSABUF的len是这次愿意接收的最大字节数,不是缓冲区总容量,实际收到多少由完成时的bytesTransferred告诉你。flags一定要传引用,传值在某些 P/Invoke 写法下会导致内核写回时踩到栈上的临时变量,表现为偶发的诡异崩溃。发送侧同理,WSASend的OVERLAPPED同样要固定,但发送还有一个额外纪律:同一个 socket 同一时刻只允许一个WSASend在飞,这一点放在第 5 章的血泪经验里细说。

3.3 工作线程与 GetQueuedCompletionStatus 消费循环

完成端口本身不干活,它只负责排队,真正干活的是那几个工作线程。线程数和内核并发度的关系前面说过,这里看循环怎么写才不浪费。

private void WorkerLoop() { while (!_stopping) { // 超时给 1 秒,方便线程感知退出信号 bool ok = Kernel32.GetQueuedCompletionStatus( _iocpHandle, out uint bytes, out IntPtr completionKey, out IntPtr overlappedPtr, timeout: 1000); // overlappedPtr 为空说明是超时,不是 IO 完成 if (overlappedPtr == IntPtr.Zero) continue; // 从固定句柄还原托管对象 var handle = GCHandle.FromIntPtr(overlappedPtr); var ctx = (IoContext)handle.Target; if (!ok) { int err = Marshal.GetLastWin32Error(); // 这里可能是对端重置或本地 socket 已关闭,统一走关闭流程 OnIoFailed(ctx, err); continue; } switch (ctx.Operation) { case IoOperation.Accept: OnAcceptCompleted(ctx, bytes); break; case IoOperation.Recv: OnRecvCompleted(ctx, bytes); break; case IoOperation.Send: OnSendCompleted(ctx, bytes); break; } } }

completionKey是投递时绑定的标识,示例里用的是会话 ID 或服务类型枚举,用来区分"这个完成包属于哪个对象",比从overlapped反查更省事。GetQueuedCompletionStatus返回false但overlapped非空时,表示某个 IO 失败了,必须处理,不能continue掉,否则那个会话会永远卡在"IO 进行中"的状态,连接不断、资源不释放,跑几小时就能看到句柄数缓慢爬升。超时返回的false加空指针才是可以忽略的分支,这两者一定要分清楚。

还有一个容易忽略的细节:OnAcceptCompleted里处理完必须立刻补投一个AcceptEx,否则预投递池会越来越小,最终没有等待中的接入请求,表现就是压测到某个连接数之后新连接一律连不上,而老连接一切正常。

3.4 缓冲区池:别让 GC 替你的高并发买单

每收一次数据就new byte[8192],在几百 QPS 下无所谓,到几十万 QPS 就是把 GC 焊死在 Gen0。示例里用了一个定长块池,按固定大小切块,借出和归还可追踪。

public sealed class BufferPool { private readonly ConcurrentBag<IntPtr> _free = new(); private readonly int _blockSize; public BufferPool(int blockSize, int preAlloc) { _blockSize = blockSize; // 预分配,用非托管内存,彻底绕开 GC for (int i = 0; i < preAlloc; i++) _free.Add(Marshal.AllocHGlobal(blockSize)); } public IntPtr Rent() => _free.TryTake(out var p) ? p : Marshal.AllocHGlobal(_blockSize); public void Return(IntPtr p) { if (p == IntPtr.Zero) return; _free.Add(p); // 生产环境要加容量上限,否则峰值后会一直占着 } }

用非托管内存的好处是地址天然固定,省掉fixed语句和GCHandle;代价是忘还就是纯粹的内存泄漏,GC 一点忙都帮不上,只能靠计数器发现。示例在会话关闭路径里统一归还缓冲区,压测时可以盯一下"借出数 - 归还数"这个指标,跑完一轮如果差值持续增长,基本就是某条错误分支提前return了没走finally。缓冲区块大小建议和RecvBufferSize对齐,并且做成 2 的幂,方便后面需要扩容大包时按倍数拼接。

4. 协议与业务分层:把粘包处理和消息分发从 IO 里剥出来

IO 层跑通之后,真正决定这个底座好不好用的是协议层。TCP 是字节流,你收到的可能是半个包、一个半包、或者三个包粘在一起,这跟网络质量无关,是协议本身的性质。示例把拆包单独放进PacketParser,不掺任何 socket 代码,好处是这块逻辑可以脱离网络环境用单元测试反复捶,不用起服务端也能验证边界。

4.1 长度前缀协议与拆包状态机

示例用的是最经典的长度前缀:4 字节包体长度加 2 字节命令字,再跟包体。简单、好调试、抓包工具直接能看。拆包器的职责是把任意长度的字节流切成完整包,剩下的半包留到下次。

public sealed class PacketParser { private byte[] _pending = new byte[8192]; private int _pendingLen; // 返回本次能解析出的完整包,剩余字节留在内部缓冲 public List<ReadOnlyMemory<byte>> Feed(ReadOnlySpan<byte> chunk) { EnsureCapacity(_pendingLen + chunk.Length); chunk.CopyTo(_pending.AsSpan(_pendingLen)); _pendingLen += chunk.Length; var packets = new List<ReadOnlyMemory<byte>>(); int offset = 0; while (_pendingLen - offset >= HeaderSize) { int bodyLen = BitConverter.ToInt32(_pending, offset); // 上限校验,防止恶意长度把内存打爆 if (bodyLen < 0 || bodyLen > MaxPacketSize) throw new ProtocolException($"非法包长 {bodyLen}"); int total = HeaderSize + bodyLen; if (_pendingLen - offset < total) break; // 半包,等下次 packets.Add(new ReadOnlyMemory<byte>(_pending, offset, total)); offset += total; } // 把没消费完的字节搬到头部,避免数组无限增长 if (offset > 0) { Buffer.BlockCopy(_pending, offset, _pending, 0, _pendingLen - offset); _pendingLen -= offset; } return packets; } }

HeaderSize是 6,MaxPacketSize必须设硬上限,这是安全边界不是性能参数。没有这个校验,任何一个客户端发一个0x7FFFFFFF的长度头,服务端就会开始疯狂扩容缓冲区,几个连接就能把内存吃光。Buffer.BlockCopy那一步是常见的性能热点,包小、包多的时候会频繁搬内存,优化方向是改成环形缓冲区或者链表式分片,示例保持简单是为了好读,上生产前这块值得替换。异常处理策略上,示例选择直接抛ProtocolException让上层断连,而不是尝试跳过坏数据重新同步——在长度前缀协议里,一旦长度字段不可信,后面所有字节的边界都是猜的。

4.2 会话对象与消息分发

Session是每条连接的状态容器,它同时被 IO 层和业务层引用,所以要特别注意线程安全。示例的做法是:会话上的所有 IO 状态只在工作线程里改,业务处理如果要做耗时操作,必须投递到独立的业务线程池,不能堵在 IOCP 工作线程上。

public void OnPacket(Session session, ReadOnlyMemory<byte> packet) { // 包体前 2 字节是命令字 ushort cmd = BitConverter.ToUInt16(packet.Span.Slice(4, 2)); var body = packet.Slice(6); if (!_handlers.TryGetValue(cmd, out var handler)) { _logger.LogWarning("未知命令 {Cmd},来自 {Remote}", cmd, session.RemoteEndPoint); return; } // 慢业务切出去,别占着 IOCP 线程 if (handler.IsSlow) { _businessPool.Queue(() => SafeInvoke(handler, session, body)); return; } SafeInvoke(handler, session, body); }

_handlers在启动时一次性注册完,运行期只读,用普通Dictionary就够,不用上ConcurrentDictionary付额外开销。IsSlow这个标记是按处理器声明的,凡是涉及数据库、磁盘、外部 HTTP 的都标成慢业务,走独立队列。这里有个连带问题:切到业务线程后,同一个会话可能同时有多个包在处理,如果处理器内部有状态(比如登录中的半成品会话),就必须自己加锁或者用Channel串行化。示例的做法是每个会话配一个单消费者队列,保证同一连接的包按序处理,代价是多一点内存,换来的确定性很值。

4.3 心跳、超时与连接的优雅回收

长连接服务端如果没有心跳和超时,迟早会因为半开连接把连接表撑满。示例用一个低频扫描线程,每 10 秒扫一遍会话表,找出超过IdleTimeoutSeconds没收到任何数据的连接。

回收路径必须走完整:先从会话表移除、再closesocket、再归还缓冲区、最后释放GCHandle。顺序错了会踩到野指针。closesocket会让该 socket 上所有未完成的 IO 立刻以失败完成,这些完成包还会从完成端口出来,所以上下文不能马上释放,要等引用计数归零。示例里给每个IoContext加了一个简单的引用计数:投递时 +1,完成回调处理完 -1,减到 0 才真正Free句柄和归还内存。少了这一步,压测时关闭大量连接会看到偶发的AccessViolationException,而且 dump 出来栈是乱的,极难定位。

另外,主动关闭时最好先shutdown(SD_SEND)再读干净对端剩余数据,最后才closesocket。直接closesocket在大量短连接场景下会产生一堆 RST,客户端看到的是"连接被重置",日志里刷屏,还容易被误判成服务端崩溃。

5. 避坑与排查:那些只在几千连接后才露头的问题

本地跑通一百个连接,一切正常;压到三千,问题开始一个一个冒出来。这套示例的价值一半在正常路径,另一半在这些只有高并发才暴露的坑。下面五条是我自己照着改、也看着别人反复踩过的,按"现象 → 原因 → 解决"整理,能省掉不少半夜看 dump 的时间。

5.1 五个高频翻车现场

第一种:连接数一上去 CPU 就满,吞吐却不涨。现象是压测端 QPS 上不去,服务端 CPU 90% 以上,但perfmon里线程数远大于核数,上下文切换每秒几十万次。原因多半是WorkerThreadCount被手动调大了,或者每个完成包都Task.Run扔给线程池,等于把 IOCP 的调度优势又还给操作系统。解决办法是把并发线程数设回 0 或等于核数,工作线程一次只处理一个完成包,处理完立刻回去取下一个,不要在循环里做任何阻塞调用。

第二种:跑几小时内存持续上涨,连接数并没有增加。现象是托管堆不大,但进程私有内存一路爬。原因通常是GCHandle.Alloc之后某条错误分支提前return,没走到Free;或者BufferPool.Rent之后异常路径没归还。解决办法是给借还两侧都加计数,暴露成可观测指标,一旦差值持续为正就说明有泄漏点,再配合Marshal.AllocHGlobal的总量统计定位到具体分支。

第三种:偶发进程直接退出,事件日志只有一条无参异常。现象是没有托管异常栈,进程说没就没。原因基本是OVERLAPPED或缓冲区地址在 IO 未完成时失效——托管数组被 GC 移动、GCHandle提前释放、stackalloc的栈内存出了作用域。解决办法是坚持"谁投递谁固定、完成回调里才释放",绝不在投递函数返回时就Free,也不要把局部变量的地址交给内核。

第四种:大包场景下客户端收到的数据错乱。现象是小包一切正常,包体超过几 KB 后偶发内容错位、前后半段来自不同消息。原因是多个线程同时对同一个 socket 调WSASend,重叠发送的完成顺序不可控,内核按完成顺序拼字节流就乱了。解决办法是每连接维护一个发送队列,任何时刻只允许一个WSASend在飞,完成回调里再从队列取下一段继续发,队列长度超过阈值就触发背压。

第五种:AcceptEx投递了几十个,压测到某点后新连接全部超时。现象是已有连接收发正常,新的connect一直挂着直到超时。原因是每次 Accept 完成后没有立即补投,预投递池被消耗干净。解决办法是把"完成即补投"写进OnAcceptCompleted的第一行,并且把当前在飞数量做成计数器告警,低于阈值就主动补。

5.2 出错时先看什么:错误码、计数器与日志

GetLastError拿到的码必须原样打出来,不要只打一句"操作失败"。997是正常的异步待完成,64是对端重置,10054类似,995是 IO 被中止(通常因为closesocket),1236是本地连接被中止。这几个码区分清楚,能直接分辨"是客户端拔网线"还是"服务端自己关的"。

计数器比日志更早发现问题。至少要有四个:当前连接数、正在飞的 IO 数、缓冲区借出减归还的差值、每秒完成包数量。前两个能看出是否卡死,第三个看泄漏,第四个看吞吐是否随连接数线性增长。日志方面,高频路径上不要打 Info,用采样或者只在状态变化时打,否则压测时日志本身就成了瓶颈,测出来的数据没有参考价值。

5.3 用最小用例复现,而不是靠猜

遇到偶发问题,第一反应不该是加日志重跑,而是构造能稳定复现的最小用例。上面第四种发送错乱,用 20 个连接、每连接并发发 1 MB 随机数据,跑十秒基本必现;第三种野指针,用一个循环不停建连断连,几分钟就能触发。能复现之后,再往里加Debug.Assert或者在完成回调里校验OVERLAPPED指针是否还在登记表中,定位速度比看日志快一个数量级。这套示例的压测客户端就是为这个准备的,参数化连接数、包大小、发送并发度,改几个数字就能造出对应场景。

6. 二次开发:把这套骨架改成能上生产的服务端

拿示例直接上生产,最大的差距不在性能,在可运维性。我的改造顺序通常是三步:先把 IO 层和业务层之间的接口抽象出来,再把可观测性补上,最后才是性能调优。

第一步抽象接口。把Session上的Send(byte[])收敛成一个ITransport接口,业务层只依赖它,不直接碰 socket 句柄。这样以后把传输层换成 TLS 或者SocketAsyncEventArgs实现,业务代码一行不用改。示例里业务处理器签名是(Session, ReadOnlyMemory<byte>),我一般改成(IConnectionContext, ReadOnlyMemory<byte>),让协议层和网络层彻底解耦。

第二步补可观测性。最低配是三样:一个/metrics输出前面说的那四个计数器,一个结构化日志把连接 ID 打进每条记录,一个定时打印的汇总行。有了连接 ID,一个客户端报问题时你能直接把它的全部日志捞出来,不用在几万行里翻 IP。这一步做完,很多"偶发"问题会变成"可解释"。

第三步才是调优。我一般按这个顺序试:

调优项做法观测指标
缓冲区大小8 KB 起步,按实际包体分布调到 90 分位每秒完成包数 / CPU
发送队列策略高水位触发背压,超时未降就断连慢客户端数量
业务线程池慢业务独立线程池,按队列长度动态扩容业务队列积压深度
拆包内存拷贝环形缓冲替代BlockCopyGC 次数 / 单包 CPU

改造中最容易忽略的是优雅停机。示例里按 Ctrl+C 会直接退,生产上要先把监听关掉、停投递新 Accept、等在飞 IO 完成或者超时强杀、最后释放所有非托管内存。少了这段,重启时会有大量连接处于半死不活状态,客户端要等自己超时才发现。

我自己的习惯是每次动 IO 层代码之前,先跑一遍"20 连接并发 1 MB"和"3000 连接空转十分钟"两个固定用例,跑完对比前后计数器和内存曲线,对不上就不合。从那以后我每次改这套底座的投递路径,都强制走一遍这两个用例再加一次完整关闭重启,宁可多花二十分钟,也不想再体验一次半夜被内存曲线叫起来的感觉。希望这些能帮到你,少踩两个坑。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 11:29:26

分布式文件系统原理与选型实战指南

简介&#xff1a;本资源是面向系统开发与分布式存储学习者的 FastDHT 分布式哈希表实现源码包&#xff0c;聚焦快速文件系统底层构建&#xff0c;适用于 Linux 环境下的 C 语言开发者、存储系统初学者及云计算/大数据方向实践者。包内共86个文件&#xff0c;以34个C源文件和33个…

作者头像 李华
网站建设 2026/11/2 11:27:50

OpenCV人脸识别实战:光照遮挡姿态鲁棒检测方案

简介&#xff1a;本资源是一份面向Python初学者与计算机视觉入门者的OpenCV人脸识别实战项目&#xff0c;聚焦人脸检测与明星身份识别场景&#xff0c;帮助读者掌握Haar级联分类器、图像预处理、LBPH特征提取及简单分类模型构建等核心技能。压缩包共120个文件&#xff0c;含110…

作者头像 李华
网站建设 2026/11/2 11:25:01

Burp Suite 伪造 IP 插件实战:原理、配置与避坑指南

简介&#xff1a;burpFakeIP-master.zip 面向从事 Web 安全测试与渗透测试的安全研究员&#xff0c;提供一款基于 Burp Suite 的 IP 伪装插件&#xff0c;用于在发起请求时模拟不同源 IP&#xff0c;从而隐藏真实身份、验证基于 IP 的访问控制策略或模拟多地区用户访问。资源包…

作者头像 李华
网站建设 2026/11/2 11:24:48

PCIe DMA驱动开发:从描述符环到中断处理的核心机制

简介&#xff1a;这份资源面向嵌入式Linux驱动开发者与FPGA硬件工程师&#xff0c;聚焦Linux系统下FPGA通过PCIe接口与主机高速通信的设备驱动实现&#xff0c;并集成DMA机制以降低CPU干预、提升大数据量传输效率。压缩包共11个文件&#xff0c;约27KB&#xff0c;包含C源码与头…

作者头像 李华
网站建设 2026/11/2 11:23:32

七代酷睿Win7核显驱动安装全攻略:从INF修改到DISM注入

简介&#xff1a;这份资源面向需要在Intel第七代CPU&#xff08;如i5-7500、i7-7700&#xff09;平台上继续使用Windows 7的用户&#xff0c;重点解决HD Graphics 630等集成显卡在Win7下缺乏原生支持、难以正常识别与驱动的兼容性问题。压缩包共660个文件&#xff0c;约354.69M…

作者头像 李华
网站建设 2026/11/2 11:23:12

C++实现四则运算器(无括号)

前言 「无括号四则运算器」要求解析只包含加、减、乘、除、整数和空格的表达式&#xff0c;按先乘除后加减、同级从左到右的规则求值。它比带括号的版本少了一层「括号递归」&#xff0c;但优先级、一元负号、多位数、除零这些边界一个都不少&#xff0c;是练习递归下降&#x…

作者头像 李华