ARTICLE DETAIL

资讯详情

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

C#高并发Socket实战:SAEA模型、缓冲区池与TCP/UDP通信框架

C#高并发Socket实战:SAEA模型、缓冲区池与TCP/UDP通信框架 做C#上位机和网络服务的老哥应该有同感项目刚验收那会儿设备还没全上线一切正常等到现场一两百台扫码枪、PLC、视觉相机同时往上涌服务器端CPU直接拉满消息延迟从几十毫秒飙到好几秒甚至直接卡死。我之前做过一个对接多类型设备的上位机平台设备端最终稳定在几千到上万连接最开始用的还是经典的TcpListener加同步接收一个客户端分配一个线程结果连接数到三百左右就开始明显卡顿。后来推倒重写用SocketAsyncEventArgs配合缓冲区池实现了TCP/UDP两套服务器端和客户端才算把这个问题彻底解决。这篇就围绕这套高并发高性能socket源码把服务器端、客户端、UDP通信、压测调优和踩过的坑一次说透。1. 先说结论C#写高并发Socket核心就三件事很多人一提到高并发第一个想到的是换语言、上C、搞DPDK但实际上在工控和物联网场景里C#用对了模型跑到几万连接是没问题的。真正决定上限的不是语言而是三件事异步IO模型、缓冲区管理、以及协议处理方式。三件事只要有一件做得糙连接数一上来立刻露馅。1.1 为什么同步阻塞模型在工业现场扛不住同步阻塞模型的典型写法是一个客户端一个线程收到数据后逐条处理。单看逻辑没有任何问题但问题是线程本身很贵。每个线程默认栈大小1MB1000个线程光栈就占1GB虚拟内存线程切换需要上下文切换CPU时间片被白白消耗再加上锁竞争很多代码在处理共享数据时用lock一把梭并发一高基本全在等待。更隐蔽的是IO等待。同步Receive一旦调用线程会一直阻塞在那儿哪怕这个连接只有一秒钟一条心跳线程也得干等着。也就是说大部分线程资源都消耗在“等数据”上而不是“处理数据”上。工业现场的设备连接特点是数量多、频率低、包大小差异大这种场景恰恰是同步模型的死穴。1.2 SAEA、async/await、Begin/End三套异步API怎么选C#里做Socket异步有三条路老牌的Begin/End模式、Task-based的async/await、以及SocketAsyncEventArgs简称SAEA。Begin/End模式属于老古董异步回调里拿IAsyncResult再调EndXxx收尾代码绕异常处理麻烦新项目基本不考虑。async/await是大部分人最先想到的方案优点是代码写起来跟同步一样.NET 5之后Socket也提供了返回ValueTask的ReceiveAsync/SendAsync重载不分配Task对象。实际使用中在连接数几千这个量级async/await完全够用写起来又舒服维护成本低。SAEA是高性能场景下的正解。它的核心思想是事件参数对象复用一个SocketAsyncEventArgs实例可以反复用来发起接收、发送、Accept操作配合底层IOCP或epoll把每次IO调用时创建状态对象的开销省掉。代价是代码结构复杂需要自己管理对象池和回调逻辑。我的建议分两档如果你的目标是单机稳定支撑五千到一万连接用async/await足够省心如果追求极致吞吐、或者连接数要往两三万以上冲那就得上SAEA。这套源码里我用的是SAEA因为上位机业务里经常要同时挂视觉、PLC、扫码设备还有自定义传感器连接类型杂统一用SAEA模式可控性更高。1.3 “高并发”的数字概念多少连接才算高并发先对齐一下数字。几十个连接任何模型都没问题几百到几千连接要用异步模型并且注意锁和内存分配上万连接必须SAEA或ValueTask方案并且消息处理链路里不能有明显的阻塞点十万级连接单台.NET服务器的极限需要做IO线程分离、连接分片管理还得考虑网卡多队列之类的东西。对于大多数C#上位机和工控项目目标区间是一万到两万连接。这套源码就是按这个目标设计的。2. TCP服务器端主流程拆解Accept、接收、拆包、发送服务器端是整个通信框架里最重要的部分连接建立、数据接收、协议解析、数据发送四个环节每个环节都有讲究。我把代码按流程拆开讲。2.1 Listen的backlog参数和AcceptAsync的正确用法很多人写TCP服务器第一步就埋了坑。Listen()方法的参数是backlog它表示内核中等待应用层Accept的连接队列长度。如果同时涌入的连接数超过这个值多出来的连接会被内核直接拒绝客户端表现就是“连接被重置”或“连接超时”。工业现场最容易出现连接风暴断电后所有设备同时重连瞬间几百上千个连接涌过来。backlog如果只是默认值或者随便填个10、20基本必炸。我一般直接开1024甚至更大。var listenSocket new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listenSocket.Bind(new IPEndPoint(IPAddress.Any, 9000)); listenSocket.Listen(1024);Accept环节用SAEA的话核心是维护一个Accept池。因为AcceptAsync会复用SAEA对象同一时刻一个SAEA只能发一次AcceptAsync所以要准备多个Accept SAEA轮换使用尤其是突发大量连接时处理不过来会导致内核accept队列堆积。private void StartAccept() { var acceptSaea _acceptSaeaPool.Rent(); acceptSaea.Completed OnAcceptCompleted; bool pending listenSocket.AcceptAsync(acceptSaea); if (!pending) { ProcessAccept(acceptSaea); } } private void OnAcceptCompleted(object sender, SocketAsyncEventArgs e) { ProcessAccept(e); } private void ProcessAccept(SocketAsyncEventArgs e) { if (e.SocketError SocketError.Success) { var clientSocket e.AcceptSocket; // 为新连接创建接收会话然后立刻把下一个Accept挂起 StartReceive(clientSocket); } e.AcceptSocket null; StartAccept(); // 继续接受新连接 }注意在ProcessAccept里要尽快做完新连接的初始化工作不要在这里做耗时操作。连接风暴来的时候Accept回调处理得越慢积压越严重。2.2 缓冲区池用ArrayPool统一管理接收内存接收缓冲区怎么分配直接决定内存效率和GC压力。早期做法是每个连接new一个byte[]连接一多大量堆内存被占用再频繁断线重连GC就得频繁回收。更合理方案是从ArrayPool 租借缓冲区用的时候Rent归还的时候Return。ArrayPool内部有分桶机制会根据请求大小返回合适大小的数组避免反复分配和回收。byte[] buffer ArrayPoolbyte.Shared.Rent(8192); try { // 用buffer接收数据 } finally { ArrayPoolbyte.Shared.Return(buffer); }每个连接需要独立的接收缓冲区SAEA通过SetBuffer来指定缓冲区的位置和大小。我用的模式是每个连接一个接收会话会话里包含一个SAEA、一个Socket引用、一个从ArrayPool租来的缓冲区。需要特别留意的是ArrayPool的数组在Return之前里面数据是脏的如果对安全性要求高Return前考虑ClearArray选项。另外Rent的数组长度不一定是申请的长度所以代码里要严格使用SetBuffer时指定的offset和count不能想当然用buffer.Length。2.3 一个可落地的二进制协议长度头加PayloadTCP是流协议没有消息边界。设备发过来的数据在接收缓冲区里可能是半包、也可能是多个包粘在一起。解决这个问题只能靠应用层协议。工控场景里最常用也最稳妥的是“4字节长度头Payload”的格式。// 包结构: [4字节小端长度][Payload] private int ParsePacket(byte[] buffer, int offset, int count, out byte[] payload) { if (count 4) { payload null; return 0; // 头部还没收齐 } int bodyLength BitConverter.ToInt32(buffer, offset); if (bodyLength 0 || bodyLength MaxPacketSize) { throw new InvalidDataException(非法包长度); } if (count 4 bodyLength) { payload null; return 0; // 包体没收齐 } payload new byte[bodyLength]; Buffer.BlockCopy(buffer, offset 4, payload, 0, bodyLength); return 4 bodyLength; // 完整包消耗的字节数 }数据接收和协议解析的衔接是难点。设备发过来的数据不一定会落在同一段缓冲区里所以要维护一个“残余数据”的概念收到新数据后先解析出完整包剩余的数据留到下次再拼。我这里的做法是每个接收会话维护一个内部缓存区可以用MemoryStream也可以自己管理字节数组收到数据先写入缓存然后循环尝试解析解析出多少包就处理多少缓存里剩下的部分继续保留等下一波数据到达后再拼。这个设计听起来简单但实际踩坑很多缓冲区不够用、拷贝频繁、解析时没考虑粘包导致数据错位。我的经验是缓存区直接开得比单包最大长度大一些比如8KB避免频繁扩容解析时一定要用“可解析的字节数”来判断不够就跳出循环。2.4 发送队列与背压别把数据疯狂往网络层塞服务器端向客户端发数据的场景很多人会忽略一个问题如果对端接收慢TCP窗口会变小你的SendAsync调用会一直挂起发送队列一堆待发数据内存越积越多最终OOM。背压控制是必须做的。我的方案是每个连接一个发送队列队列超过阈值就分流处理能丢弃的丢弃比如实时性要求高的状态包不能丢弃的就断开连接让上层重连。private readonly ConcurrentQueuebyte[] _sendQueue new(); private int _sending; private const int MaxSendQueueSize 1024; public bool Send(byte[] data) { if (_sendQueue.Count MaxSendQueueSize) { // 发送队列溢出要么丢包要么断开 Close(); return false; } _sendQueue.Enqueue(data); if (Interlocked.CompareExchange(ref _sending, 1, 0) 0) { SendFromQueue(); } return true; } private void SendFromQueue() { if (!_sendQueue.TryDequeue(out byte[] data)) { Interlocked.Exchange(ref _sending, 0); return; } var saea _sendSaeaPool.Rent(); saea.SetBuffer(data, 0, data.Length); saea.Completed OnSendCompleted; if (!clientSocket.SendAsync(saea)) { ProcessSend(saea); } }注意同一时间一个socket只能有一个SendAsync在飞行所以需要sending这个标记位来控制。发送完成回调里继续从队列取数据直到队列为空。这个队列机制在设备端批量上报数据时特别有用。没有它只要有一个慢设备服务器内存就会被这个设备的发送队列拖垮甚至影响其他所有连接。3. TCP客户端与连接管理设备端、采集端的高并发细节服务器端搞定了客户端同样有高并发问题。上位机要同时采集几百上千个设备如果对每个设备new一个TcpClient再同步收发很容易触发本地端口耗尽还会被半开连接坑到。3.1 客户端连接数多了之后本地端口耗尽怎么办TCP客户端每发起一个连接系统就分配一个本地端口。Windows默认的动态端口范围通常在49152到65535之间也就是最多一万五千多个并发连接。如果再加上TIME_WAIT状态的连接占用端口实际可用的还要少。要突破这个限制有几个方向一是把TIME_WAIT状态的连接快速回收Windows上调整注册表或调用SetSocketOption设置ReuseAddress二是尽量用长连接避免频繁建连三是如果单机确实需要超过一万个出站连接可以考虑多个本地IP绑定也就是一个客户端对应多个源IP。对于上位机采集场景我的建议是不要一台机器采集所有设备按业务分片每台采集5000个以内连接最稳。3.2 心跳与断线重连链路监测到底怎么做才可靠TCP本身没有强制的保活机制默认的KeepAlive时间单位是小时级根本等不起。所以应用层心跳是必须的。常规心跳设计是双向的客户端定时发心跳包服务器端如果在超时时间内没收到任何数据不只是心跳任何业务数据都算就判定连接已死主动关闭释放资源。反过来客户端判断服务器端是否可用可以通过发送心跳后有没有响应来确认。心跳间隔的选取有讲究。太密浪费带宽和电量太疏导致故障发现慢。通用设备我一般设10到30秒现场对故障感知要求高的设5秒。超时倍数一般是3倍心跳间隔。断线重连也不是闭着眼重连就行。工业现场断电恢复后是几百台设备同时重连如果每台设备都按同一个重连策略会对服务器造成二次冲击。所以我常在客户端里做随机退避第一次重连等1秒第二次2秒第三次4秒最大30秒加一点随机抖动。private async Task ReconnectLoop() { int attempt 0; while (!_cancellation.IsCancellationRequested) { try { using var client new TcpClient(); var connectTask client.ConnectAsync(_host, _port); if (await Task.WhenAny(connectTask, Task.Delay(TimeSpan.FromSeconds(5))) ! connectTask) { throw new TimeoutException(连接超时); } await connectTask; attempt 0; await HandleClient(client); } catch { int delay Math.Min(30, 1 Math.Min(attempt, 5)) Random.Shared.Next(0, 3); attempt; await Task.Delay(TimeSpan.FromSeconds(delay)); } } }这里注意TcpClient.ConnectAsync默认没有超时机制必须配合Task.WhenAny做手动超时否则一个不可达的IP能让你卡几十秒。3.3 连接池设计短连接和长连接该怎么取舍上位机和设备之间一般用长连接因为设备要持续上报数据建连三次握手的开销受不了。但有些场景比如定时从服务器拉取配置、登录鉴权就需要短连接。短连接多的时候TCP握手带来的延迟和TIME_WAIT状态堆积会拖垮性能。这时候连接池就派上用场了。核心思路是维护一批预建立的连接调用方需要时从池里借用完归还。池有最大数量限制超过限制就等待或者新建临时连接。连接池实现的难点在“借”和“还”两个操作的并发控制。我用SemaphoreSlim来控制并发数配合ConcurrentBag存储空闲连接逻辑比较简单且不容易出错。public class TcpConnectionPool { private readonly ConcurrentBagTcpClient _clients new(); private readonly SemaphoreSlim _semaphore; private readonly string _host; private readonly int _port; public TcpConnectionPool(int maxSize, string host, int port) { _semaphore new SemaphoreSlim(maxSize, maxSize); _host host; _port port; } public async TaskTcpClient GetAsync() { await _semaphore.WaitAsync(); if (_clients.TryTake(out var client)) { return client; } var newClient new TcpClient(); await newClient.ConnectAsync(_host, _port); return newClient; } public void Return(TcpClient client) { _clients.Add(client); _semaphore.Release(); } }4. UDP服务器与客户端和TCP完全不同的玩法UDP经常被误解成“简单版TCP”实际不是。UDP没有连接、没有重传、没有流控但正因为如此它的延迟更低、开销更小某些场景下吞吐更高。高并发UDP通信的设计思路和TCP完全不一样。4.1 UDP接收循环ReceiveFromAsync和数据报缓冲管理UDP和TCP最大的不同是消息边界由内核保留一次接收调用完整拿回一个数据报。所以不需要拆包逻辑但也意味着你必须提供足够大的缓冲区来容纳一个完整的UDP包否则多余部分会被内核截断丢弃。UDP最大数据报载荷是65507字节。但如果每次接收都开65KB的缓冲区内存会比较浪费。我的做法是根据业务包大小分类处理大部分设备上报包不超过2KB分配4KB缓冲区少数传文件的场景单独开64KB缓冲区。接收循环用SAEA或者ValueTask都行。UDP场景下我习惯用基于ValueTask的ReceiveFromAsync代码更清晰。private async Task ReceiveLoop(Socket udpSocket, CancellationToken ct) { byte[] buffer new byte[4096]; var remote new IPEndPoint(IPAddress.Any, 0); while (!ct.IsCancellationRequested) { var result await udpSocket.ReceiveFromAsync(buffer, SocketFlags.None, remote, ct); int received result.ReceivedBytes; var sourceEp (IPEndPoint)result.RemoteEndPoint; // 完整处理一个UDP数据报 ProcessDatagram(buffer.AsSpan(0, received), sourceEp); } }UDP服务器端没有“连接”的概念同一个socket要服务所有远端地址。因此逻辑代码里要明确记录每个数据报从哪个地址来发响应时也要指定目标地址不然回包就发错地方了。4.2 丢包、乱序、缓冲区溢出与应对策略UDP是尽力而为的协议丢包和乱序是常态。设计上必须搞清楚一件事你的业务能容忍丢包吗抄表类、状态类上报最新数据覆盖旧数据就行丢一个包无所谓的不需要任何可靠性机制。但是控制指令、文件传输丢一个包就是事故。应对方案有几种一是应用层加ACK和重传包的序号加确认号超时重传二是用FEC前向纠错牺牲带宽换可靠性三是直接干脆用TCP或者带可靠性的UDP封装比如RUDP思路。我可以负责任地说对绝大多数C#上位机场景如果业务要求可靠传输直接用TCP别在UDP上反复造轮子。UDP的应用场景就是能容忍丢包、追求低延迟和高吞吐的地方。还有一个隐蔽问题是接收缓冲区溢出。UDP的接收缓冲区在内核里默认大小有限如果应用层处理速度跟不上数据到达速度内核缓冲区满了就会丢包。调整Socket.ReceiveBufferSize可以缓解但本质上还是要保证应用层消费速度。4.3 UDP的“连接”语义Connect方法解决收发包乱绑问题虽然UDP无连接但Socket类提供了Connect方法。对UDP调用Connect后这个socket就成了“半连接”状态只能和指定的远端通信Send可以直接调用Receive也只会接收来自该远端的数据报。这么做的好处有三点一是内核会过滤掉来自其他地址的数据包省去了应用层校验源地址的逻辑二是在Windows上已连接的UDP socket性能更好三是配合异步读写代码更像TCP好维护。var udpClient new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); // 本地绑定端口通常是固定端口便于服务器识别 udpClient.Bind(new IPEndPoint(IPAddress.Any, 0)); udpClient.Connect(new IPEndPoint(serverIp, serverPort)); udpClient.Send(data); // 自动发往Connect时指定的地址但这里有个陷阱一旦调用了Connect这个socket就跟单一目标绑定了。如果UDP客户端需要和多个服务器通信就得每个服务器一个socket或者干脆不Connect每次SendTo指定地址。5. 压测与调优用数据说话吹再多架构设计不如跑一轮压测看数据。这套源码本地压测的结果以及调优过程中的发现我列一下。5.1 本地压测方案与真实数据测试环境是8核16G Windows 11.NET 8服务器端和压测客户端跑在同一台机器。我分别测了几种场景。场景一1万个TCP连接同时建立并保持在线客户端不发数据只看服务器端连接管理开销。结果是CPU占用不到5%内存约300MB主要是每个连接对象和缓冲区的固定开销连接稳定不丢失。场景二5000个连接每个连接每秒发一条128字节的消息服务器端接收后立刻转发给一个处理队列不回复。结果是服务器端CPU约15%消息处理延迟在毫秒级全程GC没有明显暂停。场景三同样的5000连接服务器端每条消息都要回复且回复内容也是128字节。结果CPU涨到30%左右延迟略有上升但在可接受范围内。这里就能明显看出服务器端回包这个操作对吞吐的影响比收包大一截因为每次发送都要走一次完整的事件循环和队列管理。5.2 几个关键参数对吞吐量的影响调试过程中有几个参数对性能影响很大值得单独拿出来说。第一个是Socket.SendBufferSize和ReceiveBufferSize。默认值对低延迟小包场景来说偏大反而容易增加内存占用对高吞吐大包场景偏小会限制TCP窗口。我一般建连后根据业务类型调整小包高频场景设16KB到32KB大包文件传输场景设256KB以上。第二个是TCP_NODELAY。Nagle算法会合并小包降低网络开销但对延迟敏感的控制指令就是灾难。工业现场我几乎总是禁用NagleclientSocket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true);第三是listen backlog上面提过直接决定瞬时连接风暴时能不能扛住。第四个容易被忽略的是Environment.ProcessorCount和线程池设置。SAEA底层走IOCP完成端口会启动线程处理回调线程数默认是CPU核数的倍数。如果你的回调里用了async/await并切换到线程池线程注意不要让线程池饥饿。在超高频场景下回调里尽量避免异步操作处理完就立刻返回。5.3 Windows和Linux下的行为差异这套代码在我的项目里Windows和Linux服务器都跑过。Windows上用IOCP内核完成端口在处理海量并发连接时表现很稳定Linux上用epoll.NET运行时做了适配但有个明显区别Linux下每个socket关联的缓冲区管理在极端高并发下更容易出现处理延迟抖动。另外一个实战体会是Linux服务器上如果遇到大量TIME_WAIT连接调整/etc/sysctl.conf里的net.ipv4.tcp_fin_timeout和net.ipv4.tcp_tw_reuse是有用的但别指望默认配置能撑住上万的短连接压力。尽量用长连接对Linux服务器更友好。还有一个跨平台细节不同操作系统对UDP接收缓冲区上限限制不同。Windows上可以通过注册表或API调大Linux上需要root权限修改net.core.rmem_max。如果UDP业务量很大建议部署前先确认服务器系统参数别等现场丢包了才排查。6. 实战踩坑笔记这些坑我都是真金白银试出来的最后这部分没有条理化的理论全是问题复盘。我踩过、也看同事踩过写出来算是给后来人省点时间。6.1 在Completed回调里做耗时操作服务器立刻打回原形SAEA的Completed回调运行在IOCP线程上这个线程极其宝贵。有人图省事在回调里直接解析协议、更新数据库、调用第三方接口结果一个回调阻塞几毫秒整个完成端口线程池被拖住所有连接都跟着卡顿。正确做法是回调里只做最轻量的操作把数据包塞进并发队列立刻返回。后面的协议解析和业务处理交给独立的工作线程或线程池去做。这是整个架构里最核心的一条原则。我习惯是回调收到字节数后把这段数据复制到接收会话的内部缓存完成解析。解析出的业务消息放进Channelbyte[]或者ConcurrentQueuebyte[]由后台消费者处理。这样IO层和业务层完全解耦IO层永远保持“维护连接、收发字节”这个单一职责。6.2 异常处理不当连接悄悄消失Socket编程里最经典的坑是假设数据总是完整的到达。实际上客户端可能半路断电、网线被剪、路由器重启这些情况TCP可能感知不到或者感知得很慢。如果代码里没有完善的超时断开机制这些死连接会永远占用服务器资源。所以每个接收会话都必须配一个“最后活跃时间”后台定时扫描超时就强制关掉。这个和心跳是配合使用的缺一不可。异常处理还有一个容易踩的坑SocketException异常码种类很多ConnectionReset、OperationAborted、Shutdown……不同操作系统返回的异常码不一样。处理时不能简单catch Exception就完事要区分哪些是正常的连接关闭比如对端主动Close哪些是异常中断需要告警的。6.3 日志、GC、锁三个内耗大户第一是日志。在接收循环里直接Console.WriteLine或者写日志文件性能直接掉一个量级。尤其是高并发下每个消息打一条日志IO就会成为瓶颈。日志要异步写入且要有采样频率限制不能每条都记。第二是GC。高并发场景每多分配一个对象GC的压力就多一分。常见的分配来源是解析出的byte[]、字符串拼接、装箱拆箱、LINQ闭包。用ArrayPool复用缓冲区用Span直接解析数据避免额外拷贝用TryFormat代替字符串拼接都能实实在在降低GC频率。第三是锁。高并发下锁竞争是隐形的杀手。我见过一个项目所有连接的接收数据都往一个List里塞用lock保护连接数一多CPU就全烧在锁上。解决方式是用无锁结构或者细粒度锁。ConcurrentQueue和Channel在大多数场景下性能足够而且不被锁阻塞。还有一个小技巧如果多个连接发送的目标是同一个下游服务可以在下游服务侧做聚合而不是在每个连接上单独建发送队列。聚合发送能显著减少系统调用次数这个在对接硬件设备多的项目里收益非常明显。最后说一个经验不管方案设计得多完美上线前一定要做全链路压测而且压测要包含异常场景。设备突然全部断线重连、某台设备疯狂发垃圾包、网络抖动导致大量重传这些情况模拟一遍比写一百行注释都有用。这套框架经历过的最大一次事故就是验收现场所有设备同时断电又同时上电如果没有提前做过连接风暴压测那次上线大概率要翻车。
返回列表