简介:完成端口(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 ConcurrentQueue<int> _freeIndexes; public BufferManager(int totalBytes, int bufferSize) { _buffer = new byte[totalBytes]; _bufferSize = bufferSize; _freeIndexes = new ConcurrentQueue<int>(); } 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/Push
SocketAsyncEventArgs的Completed事件不能重复订阅,否则一个完成包会被处理多次。所以正确的做法是:在准备阶段创建SAEA并订阅一次事件,之后只做Pop和Push,不再碰事件绑定。下面这段代码在服务器构造函数中完成两个池的初始化:
private readonly ConcurrentStack<SocketAsyncEventArgs> _receivePool = new(); private readonly ConcurrentStack<SocketAsyncEventArgs> _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[]进入LOH,8KB也能覆盖大多数短消息 |
| Accept Backlog | 1024 | 能应付突发连接,太大反而可能拖慢三次握手 |
| 发送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 List<Task>(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的发送队列里Push,IO完成线程在回调后取下一段发送。否则某个用户网速慢,发送缓冲排满,可能把完成端口线程堵住,整个服务器都受影响。
我早年做IM网关,就是直接在回调里调业务存储,一个慢查询把完成端口线程池拖崩过一次。后来想通了:完成端口只负责传输,业务逻辑必须拆出去。这个习惯帮我少翻了很多车,希望帮到你。
本文还有配套的精品资源,点击获取