ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#高性能TCP服务器:SocketAsyncEventArgs与完成端口实战

C#高性能TCP服务器:SocketAsyncEventArgs与完成端口实战 简介完成端口IOCP是Windows下实现高并发网络服务的关键技术面向服务端开发者的这套C#示例正好演示其在语言层面的落地方式适合需要处理大量长连接与高吞吐量通信的人群。包内围绕SocketAsyncEventArgs完成了通讯层封装提供服务端日志查看、在线Socket列表、文件上传下载、远程文件流以及专门的吞吐量协议可直接用于压力测试与性能评估。服务端以C#编写引入log4net记录日志在回环网络下可支撑65535个长连接命令交互吞吐量可达250MB/S并进一步标称能支持65536个连接、400M网络吞吐。资源包共300个文件体积约3.35MB主要包含C#工程源码、Delphi相关源码、动态库、界面图形与配置文件其中.pas/.dfm/.dpr等Delphi文件可帮助习惯Pascal语法的读者对照阅读。已有1163人浏览学习适合进阶开发者深入理解IOCP异步套接字、连接管理、文件传输与性能压测的完整落地方法从目录结构还能快速定位服务端、客户端、日志与协议模块便于按需抽取复用。1. 为什么C#高性能SOCKET绕不开完成端口一台16C32G的Windows服务器想同时顶住5万条TCP长连接最朴素的做法是每个连接开一个线程结果还没到2万连接线程栈就把内存撑爆CPU全花在线程切换上。真正能扛大容量的是Windows的完成端口IOCP几十个工作线程排队取完成事件把几千几万次异步I/O消化掉。C#里的完成端口落地形态就是SocketAsyncEventArgs它把accept、receive、send封装成可复用的异步操作配合对象池和缓冲区池单机支撑几万连接不是玄学。下面这套例子会把这个方案拆开从原理讲到参数设置再讲并发压测和踩坑点适合正在写高并发IM、网关或设备采集服务的人。2. 完成端口在C#落地SocketAsyncEventArgs的原理与资源池设计在Windows上直接调CreateIoCompletionPort的教科书写法已经很少见了。.NET替我们做了这层封装但不关心内部调度逻辑的人往往会在连接数上来之后翻车。这一章先把完成端口背后的模型说清楚再看写C#服务器时需要准备哪些资源池。2.1 为什么“一个连接一个线程”会在16C32G上翻车很多人做socket编程的第一版都是同步阻塞Accept一个连接就new一个Thread循环Receive。这样在几十个连接时很舒服但连接数一过万问题就是几何级数冒出来。第一个问题是线程栈内存。Windows上每个线程默认保留1MB地址空间5万个线程就是约50GB虚拟内存。虽然物理内存不会立刻全部用满但线程数本身就会触发系统资源耗尽。第二个问题是上下文切换。CPU只有32个核但活跃线程数可能是几万调度器需要不停切换每次切换都要保存和恢复寄存器、遍历等待队列最后真正处理数据的CPU时间所剩无几。有人会说我用ThreadPool不就不会开那么多线程了情况好一点但只要你仍在回调里使用NetworkStream.Read这种阻塞方法线程池里的线程就被一个连接占住。此时线程池发现没有空闲线程又会不停注入新线程最终还是回到线程膨胀的结局。所以C#做高并发连接必须走异步I/O让少数工作线程等“完成事件”而不是去等“数据到达”。2.2 完成端口一个处理海量I/O的内核黑匣子完成端口IOCP是Windows异步I/O的核心机制。它本质上是一个内核对象负责把“异步I/O已完成”的通知按先进先出的顺序放进一个队列。工作线程不需要一个连接配一个而是启动少量线程反复从队列里取完成包处理。哪个连接的数据先到达就先处理哪个CPU自然被占满而线程数始终保持很低。.NET里的SocketAsyncEventArgs在Windows上就映射到了这层机制。当你调用ReceiveAsync时底层Socket会被关联到一个完成端口数据到达后内核把完成包投递给IO线程池然后触发你的Completed事件。换句话说完成端口是黑匣子SocketAsyncEventArgs是它的C#门面。在Linux上.NET Core会自动换成epoll模型代码可以不改但本文讨论的是Windows下的完成端口所以后续参数和坑都默认Windows环境。2.3 三个必须自己设计的池SAEA池、Buffer池、发送队列池完成端口把I/O调度解决了但C#层有两个“重量级”不能频繁创建SocketAsyncEventArgs对象和接收缓冲区。SocketAsyncEventArgs内部封装了OVERLAPPED结构每次都new会有分配和GC压力byte[]频繁分配则会把托管堆打到LOH引发频繁的Full GC。因此高性能服务器必须自己做池化。第一个是SAEA池。它保存空闲的SocketAsyncEventArgs对象接收时弹出一个关闭时塞回。第二个是Buffer池。它预分配一块大的字节数组切成等长的小块每个接收SAEA绑定其中一块连接关闭时释放。第三个是发送队列池严格来说它解决的是发送风暴问题当业务产生大量写数据时不能一次性塞进Socket的发送缓冲区而要按连接排队发送避免某个连接把工作线程拖死。Buffer池的实现并不复杂核心逻辑是先分配一块大数组用并发队列维护空闲块下标。下面是一个简化版本public class BufferManager { private byte[] _buffer; private readonly int _bufferSize; private readonly ConcurrentQueueint _freeIndexes; public BufferManager(int totalBytes, int bufferSize) { _buffer new byte[totalBytes]; _bufferSize bufferSize; _freeIndexes new ConcurrentQueueint(); } public bool TrySetBuffer(SocketAsyncEventArgs args) { int index; if (_freeIndexes.TryDequeue(out index)) { args.SetBuffer(_buffer, index, _bufferSize); return true; } return false; } public void FreeBuffer(SocketAsyncEventArgs args) { if (args.Offset 0) { _freeIndexes.Enqueue(args.Offset); args.SetBuffer(null, 0, 0); } } }逻辑说明先分配一块大字节数组空闲块下标放在ConcurrentQueue中。调用TrySetBuffer时从队列取一个下标调用SocketAsyncEventArgs.SetBuffer把这个SAEA的缓冲区指向大数组中的固定切片。连接关闭时FreeBuffer把下标还回队列。这样做的好处是接收字节始终写在预分配的大数组里不再每个连接new一个byte[]GC压力被压到最低。参数说明totalBytes建议按“预计最大连接数×接收缓冲区大小×2”来分配多留一倍余量给发送和碎片。bufferSize常用4096或8192小于85000字节就不会进入大对象堆。这个池在设计时不需要考虑线程安全ConcurrentQueue本身就是并发安全的。3. 把完成端口跑起来最小C#服务器代码与并发参数设置这一章给出一个可以直接拉起来跑的TCP回显服务器。它会在收到数据后把原样发回代码覆盖接受连接、接收数据、发送数据、关闭清理四个核心动作。为了不让代码发散我把发送也做了池化但发送缓冲区临时复制一份生产环境可以进一步优化。3.1 用ConcurrentStack搭SAEA池创建一次后面只Pop/PushSocketAsyncEventArgs的Completed事件不能重复订阅否则一个完成包会被处理多次。所以正确的做法是在准备阶段创建SAEA并订阅一次事件之后只做Pop和Push不再碰事件绑定。下面这段代码在服务器构造函数中完成两个池的初始化private readonly ConcurrentStackSocketAsyncEventArgs _receivePool new(); private readonly ConcurrentStackSocketAsyncEventArgs _sendPool new(); public AsyncTcpServer(int maxConnections, int bufferSize, int backlog) { _maxConnections maxConnections; _bufferSize bufferSize; _backlog backlog; _bufferManager new BufferManager(maxConnections * bufferSize * 2, bufferSize); for (int i 0; i maxConnections; i) { var saea new SocketAsyncEventArgs(); saea.Completed OnIoCompleted; _receivePool.Push(saea); } for (int i 0; i maxConnections / 4 1; i) { var saea new SocketAsyncEventArgs(); saea.Completed OnIoCompleted; _sendPool.Push(saea); } }逻辑说明接收池数量等于最大连接数因为每个活跃连接至少需要一个接收SAEA发送池按最大连接数的四分之一预分配实际并发发送量不会达到连接数那么大。所有SAEA都只订阅了一次OnIoCompleted后续Pop和Push都不会改变事件绑定。参数说明接收池太大浪费内存太小会限制连接数如果预算允许建议直接把接收池设为maxConnections。发送池数量可以根据业务调整如果业务是“下行消息远多于上行”发送池可以再加大。如果最大连接数特别大比如10万发送池2万到3万是常见值。3.2 启动监听与Accept用计数器控制最大连接完成端口服务器的Accept也走SocketAsyncEventArgs这样不会有一个专属线程卡在Accept上。这里用Interlocked计数器控制连接数而不是Semaphore因为回调线程里一个WaitOne都可能把IO完成线程堵死。private Socket _listenSocket; private SocketAsyncEventArgs _acceptSAEA; private long _connectionCount; public void Start(int listenPort) { _listenSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _listenSocket.Bind(new IPEndPoint(IPAddress.Any, listenPort)); _listenSocket.Listen(_backlog); _acceptSAEA new SocketAsyncEventArgs(); _acceptSAEA.Completed OnAcceptCompleted; StartAccept(); } private void StartAccept() { _acceptSAEA.AcceptSocket null; bool willRaiseEvent _listenSocket.AcceptAsync(_acceptSAEA); if (!willRaiseEvent) OnAcceptCompleted(_listenSocket, _acceptSAEA); } private void OnAcceptCompleted(object sender, SocketAsyncEventArgs e) { if (e.SocketError SocketError.Success) { Socket clientSocket e.AcceptSocket; if (Interlocked.Increment(ref _connectionCount) _maxConnections) { Interlocked.Decrement(ref _connectionCount); clientSocket.Close(); } else if (!_receivePool.TryPop(out var receiveSAEA)) { Interlocked.Decrement(ref _connectionCount); clientSocket.Close(); } else if (!_bufferManager.TrySetBuffer(receiveSAEA)) { _receivePool.Push(receiveSAEA); Interlocked.Decrement(ref _connectionCount); clientSocket.Close(); } else { receiveSAEA.UserToken clientSocket; if (!clientSocket.ReceiveAsync(receiveSAEA)) ProcessReceive(receiveSAEA); } } StartAccept(); }逻辑说明AcceptAsync的返回结果需要区分两种情况如果返回false说明Accept已经同步完成直接走完成逻辑如果返回true说明操作是异步的等Completed事件触发。_connectionCount用Interlocked.Increment原子增加超出限制就直接关闭新连接避免继续分配资源。参数说明_backlog是系统内核accept队列长度建议设置成1024或更快。如果流量很大1024不够时可以调成2048但改完还要检查Windows的配置文件不是所有版本都能无限扩大。3.3 接收、回显、发送完成端口回调里的核心处理收到数据后的处理逻辑都在OnIoCompleted里分派。回显模式下把收到的数据复制到独立数组再从发送池取SAEA发出。注意这里为什么要复制接收SAEA的缓冲区马上会被下一次ReceiveAsync使用如果直接把这个缓冲区交给发送发送还没完成缓冲区内容可能就被覆盖了。private void OnIoCompleted(object sender, SocketAsyncEventArgs e) { switch (e.LastOperation) { case SocketAsyncOperation.Receive: ProcessReceive(e); break; case SocketAsyncOperation.Send: ProcessSend(e); break; } } private void ProcessReceive(SocketAsyncEventArgs e) { Socket clientSocket (Socket)e.UserToken; if (e.SocketError ! SocketError.Success || e.BytesTransferred 0) { CloseAndRecycle(e); return; } byte[] sendData new byte[e.BytesTransferred]; Buffer.BlockCopy(e.Buffer, e.Offset, sendData, 0, e.BytesTransferred); if (!_sendPool.TryPop(out var sendSAEA)) { CloseAndRecycle(e); return; } sendSAEA.UserToken clientSocket; sendSAEA.SetBuffer(sendData, 0, sendData.Length); bool willRaise clientSocket.SendAsync(sendSAEA); if (!willRaise) ProcessSend(sendSAEA); if (!clientSocket.ReceiveAsync(e)) ProcessReceive(e); } private void ProcessSend(SocketAsyncEventArgs e) { if (e.SocketError ! SocketError.Success) { CloseAndRecycle(e); return; } e.SetBuffer(null, 0, 0); e.UserToken null; _sendPool.Push(e); } private void CloseAndRecycle(SocketAsyncEventArgs e) { if (e.UserToken is Socket clientSocket) { try { clientSocket.Shutdown(SocketShutdown.Both); } catch { } clientSocket.Close(); } e.UserToken null; if (e.LastOperation SocketAsyncOperation.Receive) { _bufferManager.FreeBuffer(e); _receivePool.Push(e); } else { e.SetBuffer(null, 0, 0); _sendPool.Push(e); } Interlocked.Decrement(ref _connectionCount); }逻辑说明ProcessReceive里最需要注意的是“接收完成之后再发起下一次接收”。这里先完成回显发送再调用ReceiveAsync。如果需要实现更复杂的协议应该先做粘包拆包再决定是否继续接收。ProcessSend在发送结束后把SAEA的缓冲区引用清空并归还池避免临时byte[]被SAEA长期引用。参数说明这里临时分配byte[]只做演示值高频下会产生大量小对象。生产环境可以把发送也放进BufferManager统一分配发送块再把待发送数据从接收缓冲区拷进发送块。到那时候发送池里的SAEA同样需要预先绑定发送块。3.4 并发参数怎么设16C32G推荐值和内存估算下面是一组我在类似规格机器上常用的起步参数适合长连接IM这类业务。最大连接数、缓冲区大小和Backlog需要一起调整只改一个会出问题。参数16C32G推荐值设置理由最大连接数50000按每连接8KB接收缓冲区估算约400MB缓冲加上Session、Socket对象总内存1.5GB左右接收缓冲区大小8192字节大小不会让byte[]进入LOH8KB也能覆盖大多数短消息Accept Backlog1024能应付突发连接太大反而可能拖慢三次握手发送SAEA池12500取maxConnections的四分之一下行频率高时加大线程池ThreadPool.SetMinThreads(128, 128)避免突发连接时线程注入慢内存估算公式最大连接数 × 接收缓冲区大小 × 2。为什么乘2因为BufferManager maxConnections × bufferSize × 2一半给接收一半给发送和碎片。按50000 × 8192 × 2 819MB纯缓冲区不到1GB加上Socket对象本身16G内存完全扛得住。如果你真要跑十万连接建议把缓冲区降到4096或者换64G内存的机器否则连接数会先被物理内存卡住。4. 用高并发压测验证“大容量”从1000连接到5万连接的测量代码能跑通不代表并发能扛住。这一章用最简单的方法验证服务器的真实容量从1000连接开始逐步加到5万观察服务器的CPU、内存和连接数变化。4.1 写一个最小压测客户端先看1000连接压测客户端不要用多线程同步阻塞那一套否则客户端自己会先崩。我一般用一个简单的异步TCP客户端每个连接反复发送几个字节并回读统计成功数和总耗时。static async Task Main(string[] args) { string ip args[0]; int port int.Parse(args[1]); int clients int.Parse(args[2]); var tasks new ListTask(clients); int success 0; long echoBytes 0; var sw Stopwatch.StartNew(); for (int i 0; i clients; i) { tasks.Add(Task.Run(async () { using var client new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await client.ConnectAsync(ip, port); byte[] ping Encoding.ASCII.GetBytes(PING); byte[] buf new byte[1024]; for (int j 0; j 10; j) { client.Send(ping); int r client.Receive(buf); Interlocked.Add(ref echoBytes, r); } Interlocked.Increment(ref success); })); } await Task.WhenAll(tasks); sw.Stop(); Console.WriteLine($成功:{success} 耗时:{sw.ElapsedMilliseconds}ms 平均:{sw.ElapsedMilliseconds / (double)success:F2}ms/连接); }逻辑说明这段代码在本地机器上跑时connect、send、receive都是阻塞调用但每个连接放在Task里仍然能模拟出并发连接的效果。10次Send/Receive是为了观察稳定后才算成功结束。返回的平均耗时包含了建立连接和10次来回的总时间。参数说明clients参数代表并发连接数第一次建议1000第二次10000第三次50000。不要一上来就50000否则问题发生你可能分不清是客户端还是服务器。另外压测最好用一台独立机器避免客户端和服务器互相抢CPU。4.2 关键指标怎么读CPU、内存、连接数压测过程中打开任务管理器远远不够还要看三个数CPU占用率、内存占用、ESTABLISHED连接数。Windows下可以用以下命令查看当前TCP连接数netstat -an | findstr ESTABLISHED | find /c TCP如果连接数稳定在压测值附近CPU占用保持在60%-80%之间并夹着小幅波动说明服务器在正常处理。如果CPU已经100%但连接数还在增长说明线程调度出了问题需要回看是否在回调里做了阻塞操作。内存方面应该看到服务器进程内存缓慢上升后趋于平稳如果持续线性上涨基本可以确定SAEA或缓冲区没有回池这就是下一章的泄漏问题。还可以用perfmon加“Thread Count”和“Working Set”。正常的完成端口服务器线程数应该稳定在几十到两百之间而不是几千。线程数几千基本可以宣告代码有阻塞或线程注入失控。4.3 从1万到5万你会看到的瓶颈和应对当连接数超过1万时第一个瓶颈往往是客户端的动态端口范围。Windows默认可用源端口大约只有一万六千个短连接测完后端口进入TIME_WAIT不能立刻复用。所以50,000连接压测最好用长连接每个连接只跑一次Send/Receive或者在多台压测机上分散发起。服务器那边如果连接数到1万后Accept就开始变慢先看Backlog。如果拒绝了新连接计数器会直接Close客户端会看到ConnectionRefused。此时可以调大_backlog同时注意Windows的注册表键TcpNumConnections是否限制了系统级连接数量。内存方面每连接8KB缓冲区只能估算缓冲部分实际上Socket本身、SAEA、Session对象都会占内存。16C32G撑到5万长连接是常见水平之后如果要继续加需要把bufferSize降到4096并且检查是否有内存碎片。性能压测不是一次跑一个数就结束应该记录连接数、CPU、内存在不同时刻的曲线判断出真正的拐点在哪里。5. 避坑完成端口并发服务器最常见的5个翻车点这一章的每一条都是血泪经验现象、原因、解决一条路走完。代码能跑通但并发上来就崩的人大多数逃不开下面几个坑。5.1 坑一IO回调里一阻塞CPU和线程数同时翻车现象连接数爬到几千后CPU占用直接冲到100%线程数从几十涨到上千但吞吐量反而下降。 原因在OnIoCompleted回调里用了Semaphore.WaitOne、Thread.Sleep或者NetworkStream.Read这类阻塞调用。完成端口的工作线程被占住系统要不断注入新线程补位引发抖动。 解决回调里边只能做无阻塞操作。业务逻辑如果耗时可以用Channel或ConcurrentQueue先入队丢给独立业务线程去处理。并发限制也要用Interlocked计数器不能用Semaphore。5.2 坑二SAEA被阴魂不散内存和句柄缓慢泄漏现象服务器运行半天后内存和句柄数持续上涨最终报出“参数不正确”或OutOfMemoryException。 原因最常见的是SAEA归还路径不完整。比如CloseAndRecycle里只处理了接收SAEA发送SAEA的UserToken没清空结果它被误判为接收SAEA再次入池同一块接收缓冲区被两个连接共用数据错乱。 解决给SAEA的分发加一个显式标记在Pop出来后根据LastOperation决定它的角色在Push回池之前先清空UserToken和SetBuffer引用。还可以用Dump工具查看哪些SAEA没有回到池中。5.3 坑三粘包/半包把业务报文打乱现象客户端分两次发送“HELLO”和“WORLD”服务端一次收到了“HELLOWORLD”或者反过来只收到了“HELL”。 原因TCP是流协议完成端口每次ReceiveAsync返回的字节数不保证对应一个完整消息。BufferManager里的8KB只是给ReceiveAsync用的临时缓冲区不是业务消息缓存。 解决在ProcessReceive里不能直接解析业务必须维护一个每个连接独立的读缓冲先把字节追加到ReadBuffer再按协议头解析长度字段。长度不够就等下一次Receive多余字节留在ReadBuffer下一条继续用。5.4 坑四远程主机强迫关闭现有连接异常刷屏现象客户端快速断开时服务端一堆SocketException说“远程主机强迫关闭了一个现有的连接”。 原因客户端可能刚连接完就关闭或者发送数据的同时已经半关闭服务端的SendAsync和ReceiveAsync会以ConnectionReset或ConnectionAborted完成。 解决这些错误码不代表服务器故障。在ProcessReceive和ProcessSend里统一判断SocketError.Success其余非零错误码都执行CloseAndRecycle然后打印错误或记日志。不要因为一个客户端关闭就去重启整个服务器。5.5 坑五压测时客户端先撑不住了现象服务器端CPU还很健康但压测程序抛“地址已在使用中”或ConnectAsync超时。 原因Windows客户端默认动态端口范围只有16384个短连接断开的端口进入TIME_WAIT后不能立即复用。压测程序中的每次重连都会消耗一个源端口。 解决压测改用长连接每个连接做完流程后保持不要反复连断。如果一定要模拟短连接需要调整注册表里的MaxUserPort和TcpTimedWaitDelay但生产环境要谨慎修改后会影响所有socket。6. 进阶把回显服务器改成高并发IM网关的4个改造点回显只是验证完成端口没白用真正做高并发IM网关时至少还有四个地方要动。第一消息协议。回显直接把收到的字节发回去但IM要能识别“包”。我一般会在包头放2字节消息长度加1字节消息类型收到数据先放进读缓冲长度不够就继续等够了一帧一帧切出来。第二Session对象。UserToken不要直接存Socket要存一个Session里面放用户ID、心跳时间、读出缓冲和发送队列。这样关闭连接时能判断身份也能把未发送的数据处理干净。第三心跳与断线重连。服务器定期扫描Session的最后活跃时间超时就踢掉。客户端重连要加随机退避防止5万客户端同时重连把服务器冲垮。第四发送队列隔离。业务线程要发消息时不能直接调SendAsync而是往对应Session的发送队列里PushIO完成线程在回调后取下一段发送。否则某个用户网速慢发送缓冲排满可能把完成端口线程堵住整个服务器都受影响。我早年做IM网关就是直接在回调里调业务存储一个慢查询把完成端口线程池拖崩过一次。后来想通了完成端口只负责传输业务逻辑必须拆出去。这个习惯帮我少翻了很多车希望帮到你。本文还有配套的精品资源点击获取
返回列表