做 C# Socket 开发这么多年,我手头上最常被同事讨要的一份代码,就是一套既支持 TCP 又支持 UDP,服务器和客户端都能直接落地,还能扛住高并发场景的通信源码。市面上很多教程给的是单连接聊天室或者阻塞式收发,写起来简单,放到生产环境一压测就露馅。这篇文章不吹框架,直接讲我在维护这套 C# 高并发高性能 Socket 源代码时的核心设计思路:从线程模型、缓冲区管理、SocketAsyncEventArgs 的使用,到 TCP 粘包处理、UDP 收包并发、以及压测时踩过的那些坑,尽量一次讲透。
这套代码适合几类人:做即时通讯服务端的,写 ERP 库存网关的,用 C# 写上位机采集设备数据的,以及准备从“阻塞式 Socket + 多线程”转型到异步高并发模型,但还没想明白从哪下手的人。我会把项目骨架、每个模块为什么这么写、哪里最容易出错,按我实际开发时的顺序串起来。跟着这篇文章走完,你至少能对自己的 socket 框架有一个完整判断,而不是继续在网上拼碎片代码。
1. 先想清楚再写代码:高性能 Socket 到底在解决什么问题
1.1 为什么不能直接用 TcpClient 和同步收发
很多新手喜欢用 TcpClient,因为封装度够高,几行代码就能连上。但 TcpClient 内部的高层封装,到了高并发场景反而成了限制。你可以打开 TcpClient 源码看看,它本质上是包了一层 Socket,而真正决定性能的是你如何驱动那个底层 Socket。
最大的问题出在“阻塞式同步收发”上。一个线程在那里Receive()死等,如果有 1 万个客户端连接,你就得准备 1 万个线程。操作系统不是不能开这么多线程,但线程上下文切换会吃掉大量 CPU,而且每个线程默认栈空间 1MB,1 万连接光栈内存就接近 10GB。这不是性能问题,是资源爆炸问题。
高并发下正确方向是异步 I/O。异步收发不需要“一个连接占一个线程”,而是把 I/O 完成事件交给操作系统,完成后回调到线程池继续处理。C# 里最底层、最稳的这个机制就是 SocketAsyncEventArgs,它在 Windows 上映射到 IOCP,在 Linux 上映射到 epoll。我写的这套源码里,TCP 服务器端、TCP 客户端、UDP 服务器端,核心收发逻辑全是基于 SocketAsyncEventArgs 实现的。
1.2 SocketAsyncEventArgs 和 async/await 怎么选
现在写 C# 网络代码,很多人第一反应是await socket.ReceiveAsync(buffer, ...),这个写法本身没问题,.NET 5 之后性能也不错。但如果你需要兼容 .NET Framework 4.8 或者老项目,或者面对几万连接这种量级,直接用 SocketAsyncEventArgs 仍然是更稳妥的选择。
SocketAsyncEventArgs 的核心优势是“复用”。它允许你把操作要用的缓冲区和 Socket 状态打包成一个对象,反复投递给操作系统,尽量避免在每次收发时都分配新对象。异步回调触发时,系统会调用Completed事件。这里有一个很多初学者必踩的坑:如果AcceptAsync或ReceiveAsync返回true,说明操作被挂起,等完成事件;如果返回false,说明操作已经同步完成,此时事件不会另外触发,你必须自己立刻处理回调逻辑。
这也意味着 Complected 事件处理函数里面,必须假设“当前回调可能在任意线程”,所有共享状态要加锁或做成无锁队列。我见过不少代码,异步回调里直接访问同一个 List ,结果偶发崩溃,查半天都找不到原因,其实就是多线程并发写。
1.3 TCP 和 UDP 在高并发维度上完全是两回事
TCP 是面向连接的字节流协议。它可靠、有序、但默认有 Nagle 算法和 ACK 延迟等机制,吞吐并不总能跑满。高并发 TCP 要解决的问题,主要是连接管理、粘包半包、慢启动、断开检测。你的服务器每接受一个连接,都要为它单独维护一个会话对象,这个会话里有缓冲区、协议解码器、心跳状态、发送队列。连接数一上来,会话管理本身就是一门学问。
UDP 是无连接的数据报协议。它保留消息边界,本身就天然解决粘包问题,但不可靠、可能乱序。UDP 的高并发维度和 TCP 完全不同:你不需要为每个客户端维护复杂状态,但你需要处理“一个 socket 上有无数来源的包”这种情况,需要快速地把每个包分发到对应的业务逻辑里。还要面对全链路丢包、接收缓冲区溢出、Windows 上莫名奇妙的 ConnectionReset 错误。这不是比 TCP 简单,是“简单的地方不用管,复杂的地方特别隐蔽”。
所以,不要指望一套代码同时解决 TCP 和 UDP 的一切问题。TCP 代码解决“连接与流”,UDP 代码解决“数据报与分发”。下面我按模块拆开讲。
2. 工程结构与线程模型:这套源码是怎么组织的
2.1 建议的项目目录结构
很多团队写 socket 通信,喜欢把所有代码堆在一个文件里。开始 500 行还好,连接类型一多、协议一复杂,后面根本没法维护。我现在的工程结构是这样:
NetEngine/ ├─ BufferManager.cs # 共享缓冲区管理 ├─ SocketAsyncEventArgsPool.cs # SAEA 对象池 ├─ Tcp/ │ ├─ TcpServer.cs # TCP 服务器端 │ ├─ TcpSession.cs # 单连接会话 │ └─ TcpClient.cs # TCP 客户端 ├─ Udp/ │ ├─ UdpServer.cs # UDP 服务器端 │ └─ UdpChannel.cs # UDP 客户端封装 └─ Protocol/ ├─ LengthPrefixDecoder.cs # 长度前缀拆包器 ├─ Packet.cs # 消息包 └─ PacketDispatcher.cs # 消息分发这个分层思路是:IO 层只负责把字节搬进缓冲区,协议层负责把字节解析成消息,业务层只处理消息。如果你有一个业务逻辑要跟 Socket 收发混在一起,说明分层出了问题。我在做 ERP 库存网关时,曾经把库存预占逻辑直接写在 ReceiveCompleted 回调里,结果 IO 线程被数据库查询堵死,后面所有连接的读取都延迟了,这就是架构级的错误。
2.2 Accept 循环和连接会话要分离
TCP 服务器最忌讳的写法是“每来一个连接,就线程池丢一个 Task 去 while 循环接收”。这种写法等于把阻塞模型换了一层皮。高并发服务器的正确结构应该是:一个监听 Socket 负责 Accept,每 accept 出一个新 Socket,立刻封装成独立的 TcpSession 对象,然后由这个 Session 自行投递 ReceiveAsync 接收请求。
Accept 本身不需要多线程。大多数场景维护一个 pending 的 AcceptAsync 就够用,但我在源码里默认开了 4 个并发 AcceptAsync,主要是防止 accept 突发瞬间处理不过来。每个 AcceptAsync 必须用独立的 SocketAsyncEventArgs,因为同一个实例同时只能服务一个异步操作。Accept 完成后,立刻再次调用 AcceptAsync,形成 Accept 循环。
这里还要注意,第一次调用AcceptAsync时如果返回 false,也是同步完成,要马上处理。很多网上代码没判断返回值,只在 Completed 事件里处理,结果偶发丢失连接。我遇到过一次线上服务“部分客户端连不上”的诡异问题,最后排查下来就是同步完成场景没处理。
2.3 业务消息处理不能卡住 IO 线程
IO 完成回调里的代码执行时间,直接决定这个连接的“下一个包什么时候能收到”。如果回调里做数据库操作、调用第三方 HTTP、处理复杂 JSON,网络吞吐会被这种 CPU/IO 混合任务拖垮。
正确做法是让 IO 回调只做“搬运”:把收到的字节写入协议解码器,解析出消息,把消息投递到一个有界队列里,然后立刻回到ReceiveAsync。业务侧由专门的 Consumer 线程从队列取消息处理。C# 里可以用Channel<T>,这是一个线程安全、支持背压的队列。
背压这个点特别重要。如果业务处理不过来,队列会无限增长,最后内存爆掉。有界 Channel 写满时TryWrite会返回 false,此时你应该选择丢弃或断开连接。很多 IM 场景愿意丢弃非关键消息,而 ERP 库存场景宁可断连重推也不丢数据。这是我做 IM 和 ERP 网关时最核心的取舍,没有统一答案,但架构必须支持这种取舍。
3. TCP 服务器端与客户端实现细节
3.1 TCP 服务器:Accept 与 Session 核心代码
下面是一段简化但可运行的服务器端核心框架。监听 Socket 初始化时,先设置 ReuseAddress,避免服务重启时 “Address already in use” 的尴尬。
public sealed class TcpServer { private readonly Socket _listenSocket; private const int AcceptConcurrency = 4; public TcpServer(int port, int backlog = 1024) { _listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listenSocket.Bind(new IPEndPoint(IPAddress.Any, port)); _listenSocket.Listen(backlog); } public void Start() { for (int i = 0; i < AcceptConcurrency; i++) { var acceptArgs = new SocketAsyncEventArgs(); acceptArgs.Completed += AcceptCompleted; AcceptAsync(acceptArgs); } } private void AcceptAsync(SocketAsyncEventArgs args) { args.AcceptSocket = null; bool pending = _listenSocket.AcceptAsync(args); if (!pending) { AcceptCompleted(this, args); } } private void AcceptCompleted(object? sender, SocketAsyncEventArgs e) { if (e.SocketError == SocketError.Success && e.AcceptSocket != null) { var session = new TcpSession(e.AcceptSocket); session.Start(); } else { // 这里要记录 SocketError,不要只是吞掉 } AcceptAsync(e); } }这块代码里有几个细节经常被忽略。一是backlog,也就是 listen 队列长度,一般压测场景 1024 够用,如果压力特别猛,要结合操作系统参数一起调。二是 Accept 失败后不能直接继续盲目 Accept,如果因为 fd 耗尽或者瞬时错误,至少延迟几十毫秒再恢复,否则会出现疯狂空转。三是 AcceptSocket 拿到之后,单独立刻设置参数,比如NoDelay = true、KeepAlive 等,再封装成 Session。
每个 Session 的核心接收代码如下。注意接收缓冲区是每个连接一份,大小为 8KB 到 16KB 在多数业务场景覆盖了大量短消息。大消息靠协议层累积,而不是靠调大这块缓冲区。
public sealed class TcpSession { private readonly Socket _socket; private readonly byte[] _receiveBuffer; private readonly SocketAsyncEventArgs _receiveArgs; private readonly LengthPrefixDecoder _decoder; public TcpSession(Socket socket) { _socket = socket; _socket.NoDelay = true; _socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); _receiveBuffer = new byte[8192]; _receiveArgs = new SocketAsyncEventArgs(); _receiveArgs.SetBuffer(_receiveBuffer, 0, _receiveBuffer.Length); _receiveArgs.Completed += ReceiveCompleted; } public void Start() { ReceiveAsync(); } private void ReceiveAsync() { bool pending = _socket.ReceiveAsync(_receiveArgs); if (!pending) { ReceiveCompleted(this, _receiveArgs); } } private void ReceiveCompleted(object? sender, SocketAsyncEventArgs e) { if (e.SocketError != SocketError.Success || e.BytesTransferred == 0) { Dispose(); return; } _decoder.Feed(e.Buffer, e.Offset, e.BytesTransferred); foreach (var packet in _decoder.TryDecodePackets()) { PacketDispatcher.Enqueue(this, packet); } ReceiveAsync(); } }当BytesTransferred == 0,说明对端正常关闭,一定要把会话从全局连接字典里移除,并释放 Socket 和 SAEA。如果忘了释放,每断开一个连接就泄漏一个对象,长期运行 GC 涨到爆。
3.2 粘包半包处理:长度前缀是最稳的方案
TCP 是字节流,不是消息流。对方一帧消息可能是 1 个字节也可能跨多个 TCP 段,接收方必须自己“划界”。我在源码里统一使用“4 字节 header + body”的长度前缀方案:
[4字节 int32 大端 body长度] [body]这个方案的好处是协议简单,解析快,也不需要转义。解码器的核心逻辑是:先累积 4 字节的头部,从头部里读出 body 长度,再继续累积 body,直到凑够一个完整消息。一旦一个消息解析完成,把剩余的字节当作下一个消息的开始继续解析。
这里有个很容易搞错的地方:头部和 body 都可能跨ReceiveAsync的多次回调到达。所以解码器内部必须维护状态,不能每次收到数据都当成一个完整消息。我见过很多新手写if (count < 4) return;然后直接丢弃数据,这是不对的。正确的做法是把剩余字节暂存起来,等待后续数据填满。
如果你传输的是二进制协议,大端还是小端也要提前规划。.NET 默认小端,而很多硬件设备、嵌入式端用的是大端。我自己做上位机采集时,和 C 端设备联调,最常见的问题就是字节序对不上。建议在协议文档里明确标注字节序,并在解码器里统一用BinaryPrimitives.ReadInt32BigEndian这种语义明确的 API。
3.3 TCP 客户端:连接池、超时与重连
TCP 客户端表面简单,就是 Connect、Send、Receive,但高并发下客户端同样不能乱来。最常见的问题是“每次发一条消息,就 new 一个 Socket”。如果业务 QPS 是每秒一万,那就意味着每秒有一万次 TCP 握手和四次挥手,服务器还没处理业务,先被 SYN 包打懵了。
正确做法是维护连接池。按目标服务器地址,维护一组 TcpSession 连接,空闲时复用,不用时放回池子,定期心跳。连接池的核心参数有三个:最小连接数、最大连接数、空闲回收时间。最小连接数保证突发流量下有存量连接可用;最大连接数避免资源耗尽;空闲回收时间避免大量半死不活的长连接占着内存。
连接超时和重连也要单独设计。下面的伪代码方式可以表达重点:
private async ValueTask<TcpSession> ConnectWithRetryAsync(IPEndPoint remote, int retryCount) { var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.NoDelay = true; socket.SendBufferSize = 64 * 1024; socket.ReceiveBufferSize = 64 * 1024; for (int i = 0; i <= retryCount; i++) { try { await socket.ConnectAsync(remote, CancellationToken.None); return new TcpSession(socket); } catch (SocketException) { if (i == retryCount) { socket.Dispose(); throw; } await Task.Delay(500 * (i + 1)); } } throw new InvalidOperationException("unreachable"); }重连退避很重要。不要失败后立刻无限重连,那会导致“连接风暴”,尤其服务器刚重启时,几千个客户端同时疯狂重连,很容易把服务打崩。我现在的默认方案是:第一次延迟 500ms,随后指数递增到最大 15 秒,并加随机抖动,让重连请求均匀散开。
4. UDP 服务器端与客户端实现细节
4.1 UDP 服务器的异步接收循环
UDP 服务器不需要 Accept,只需要创建一个 Socket,Bind 到一个端口,然后持续调用 ReceiveFromAsync。听起来比 TCP 简单,但它有个“单接收循环性能瓶颈”:如果你只维持一个 ReceiveFromAsync 在飞行,处理一个包期间新到的包只能等内核缓冲区排队。极端情况下内核缓冲区溢出,UDP 直接丢包。
所以在 UDP 高并发场景,我的服务器端默认维持多个接收通道。每个通道一个独立的 SocketAsyncEventArgs,一个独立缓冲区,一个独立的“当前投递中”状态。收到包后,复制到接收队列就立刻再次投递 ReceiveFromAsync。接收通道数量一般取 CPU 核数,最常见的是 4 到 8 个。
简化版核心代码:
public sealed class UdpServer { private readonly Socket _udpSocket; private readonly int _port; private readonly List<SocketAsyncEventArgs> _receiveArgsList = new(); public UdpServer(int port) { _port = port; _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); _udpSocket.Bind(new IPEndPoint(IPAddress.Any, port)); } public void Start(int receiveChannelCount) { for (int i = 0; i < receiveChannelCount; i++) { var args = new SocketAsyncEventArgs(); args.SetBuffer(new byte[65507], 0, 65507); args.RemoteEndPoint = new IPEndPoint(IPAddress.Any, 0); args.Completed += ReceiveCompleted; _receiveArgsList.Add(args); ReceiveFromAsync(args); } } private void ReceiveFromAsync(SocketAsyncEventArgs args) { bool pending = _udpSocket.ReceiveFromAsync(args); if (!pending) { ReceiveCompleted(this, args); } } private void ReceiveCompleted(object? sender, SocketAsyncEventArgs e) { if (e.SocketError != SocketError.Success || e.BytesTransferred == 0) { e.RemoteEndPoint = new IPEndPoint(IPAddress.Any, 0); ReceiveFromAsync(e); return; } OnUdpPacketReceived(e.RemoteEndPoint!, e.Buffer, e.Offset, e.BytesTransferred); e.RemoteEndPoint = new IPEndPoint(IPAddress.Any, 0); ReceiveFromAsync(e); } }UDP 数据报最大长度是 65507 字节,所以缓冲区直接按上限申请也没问题。但注意,如此大的 UDP 包几乎不可能在不分片的情况下穿过公网,所以生产环境更多是处理 1400 字节以内的小包。ReceiveFromAsync 返回的 BytesTransferred 表示当前数据报的实际长度,不要把它当成缓冲区已填充的连续字节流。
OnUdpPacketReceived 里不要做重逻辑。我的源码在这里只做一件事:通过 RemoteEndPoint 找到对应的业务处理器,把数据包投递到 Channel ,然后马上返回。如果收到包后要花 10ms 去解析复杂结构,丢包率会不可控。
4.2 UDP 客户端:发送并发与缓冲区
UDP 客户端和 TCP 客户端不同,通常不需要连接池。因为 UDP 本身无连接,一个 Socket 可以往任意地址发送,而且 SendToAsync 是线程安全的,多个线程同时调用不会导致数据错乱。问题在于发送方也会遇到内核缓冲区紧张:如果发送速度超过对端接收速度,或者网络拥堵,发送缓冲区满了之后,SendTo 会返回错误。
高并发 UDP 客户端的关键,是要控制发送速率和内存占用。不要因为 UDP 不收 ACK,就一口气把大量数据丢进 Socket。很多 IM 消息推送服务用 UDP 做秒级定位消息,一但瞬间涌入,客户端在网络特差时会有大量 UDP 包在发送缓冲区排队,内存压力非常大。我的做法是:应用层维护一个有界发送队列,队列满时直接丢弃最新不重要的包,或者对重要包降级为 TCP 通道补发。
UDP 客户端的发送代码用起来很直白:
public async Task SendAsync(IPEndPoint remote, ReadOnlyMemory<byte> payload) { await _socket.SendToAsync(payload, SocketFlags.None, remote); }如果你要兼容 .NET Framework,则要回到 SocketAsyncEventArgs 那套写法。但无论哪种方式,发送之前都建议检查payload.Length,UDP 超过 65507 会直接报错,超过链路 MTU 则可能分片,性能大幅下降。本地回环测试可以发大包,公网场景尽量控制在 1472 字节以内,也就是以太网 MTU 1500 减去 IP 头 20 字节和 UDP 头 8 字节。
4.3 丢包、乱序和 Windows 上的 ConnectionReset
UDP 最大的特点是“没有保证”。做 UDP 服务器,你必须接受两件事:第一,包可能丢;第二,到达顺序可能不同。尤其是多通道接收的情况下,CPU 多线程并行处理,如果后续业务依赖顺序,就得在包体里加序号,用接收端排序。
我的做法是:在业务包头部带一个 4 字节的自增序列号,接收端收到后不直接处理,而是先放进滑动窗口排序窗口。窗口大小按业务容忍度设计,比如容忍 100 个包乱序,超过就丢弃整段或者请求重发。这个逻辑虽然增加内存,但它让 UDP 层具备从“裸数据报”升级为“可靠消息”的能力。如果你只是做简单传感器数据,可能不需要排序,直接解析即可。
还有一个 Windows 特有的坑,我必须单独讲。当你用 UDP Socket 向一个未启动服务的端口发送数据,该主机会回一个 ICMP Port Unreachable。Windows 在收到这个 ICMP 后,会让你下一次ReceiveFromAsync抛出一个 SocketException,错误码是 10054 / ConnectionReset。这在 Linux 上通常不会这么直接暴露。处理办法是创建 UDP Socket 后执行一次IOControl:
_socket.IOControl(-1744830452, new byte[] { 0 }, null);这个数值对应的就是 Windows 的SIO_UDP_CONNRESET,作用是让系统禁止把 UDP 的 ConnectionReset 错误传给你的应用。在 Windows 下做 UDP 收包时,这道设置我建议从 Socket 初始化后立刻调用。否则一个健康服务会收到莫名其妙的异常,基至导致服务器停止接收。
5. 让性能再上一个台阶:对象池、Buffer 管理与内核参数
5.1 内存与对象分配是隐形杀手
高并发 Socket 性能和 GC 压力强相关。典型的反面写法是每次 Receive 都 new 一个 byte[],每次收到包都 new 一个 SocketAsyncEventArgs。连接数少时感觉不到,连接数上万时,每秒几千次分配会让 GC 频繁触发,造成明显的卡顿。
SocketAsyncEventArgs 最好做成对象池。一个连接从池子里借 SAEA,断开连接时归还。缓冲区也不建议每个连接都 new 独立大数组,更优的做法是提前从ArrayPool<byte>.Shared租一段,用完归还。下面是我在 UDP 服务器里替换“new byte[]”的方式:
byte[] rented = ArrayPool<byte>.Shared.Rent(e.BytesTransferred); Buffer.BlockCopy(e.Buffer!, e.Offset, rented, 0, e.BytesTransferred); // 业务消费完这个包之后,务必归还 ArrayPool<byte>.Shared.Return(rented);这里要注意一个细节:从 ArrayPool 租到的数组可能比实际请求的大,使用长度必须用e.BytesTransferred,而不是rented.Length。归还后不能再被业务侧持有。如果你的业务需要异步长时间持有这个包,就得复制到自己的缓冲区或者等处理完再归还。
GC 不是恶魔,但高并发下“无谓分配”是恶魔。我见过一些人为了追求“高性能”把 SocketAsyncEventArgs 池写得特别复杂,实际上池子本身如果管理不当反而引入锁竞争。目前我的建议是:预创建 100 到 200 个 SAEA 进池子,高峰期动态补充,空闲时裁剪,不要一上来就搞几万个对象。
5.2 Socket 参数优化明细
参数这东西,不能直接抄网上,要理解每个参数在业务里起什么作用。下面是我测试中比较常用的一套配置,括号里是原因:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| NoDelay | true | 关闭 Nagle 算法,适合请求/响应频繁的场景;大文件流传输时可以再评估 |
| KeepAlive | true | 用于清理长时间无数据连接,但应用层心跳才是主角 |
| ReceiveBufferSize | 32KB~128KB | 不是越大越好,内核有自动调优机制 |
| SendBufferSize | 32KB~128KB | 过大反而导致延迟 |
| Listen backlog | 1024 | 高并发压测时结合系统 somaxconn 调整 |
| ReuseAddress | true | 服务重启时避免 TIME_WAIT 导致绑定失败 |
NoDelay 这个要单独说。默认情况下 TCP 会启用 Nagle 算法,把小的数据包攒在一起发送,节省小包开销,但对即时交互是灾难。比如发一条 20 字节的实时指令,如果被 Nagle 等 200ms,用户感官极差。做 IM、游戏、上位机指令下发,基本都是 NoDelay=true。但如果你在传输大文件,NoDelay=true 可能导致大量小包突刺,也不代表最快。所以要从实际业务判断。
5.3 .NET 环境配置和线程池参数
代码层面优化完之后,环境参数也要跟上。压测时最容易踩的坑是 .NET 线程池慢启动。异步 Socket 完成回调最终要在线程池线程上执行,线程池会根据负载逐步增加线程,压测刚开始时可能只有几个线程,导致现象是一开始性能很低,跑几十秒才上去。这不是程序写得不行,是线程池还没热。
我习惯在服务启动时显式预热线程池:
ThreadPool.SetMinThreads(64, 64);不要设置成几千,否则线程上下文切换会成为新的瓶颈。64 或者 128 对多数场景足够。关键是它会告诉线程池:别吝啬,立刻给我足够的执行线程,不要等队列堆满才加线程。
Linux 部署时还要注意两个系统参数:文件描述符限制和somaxconn。ulimit -n如果只有 1024,就算程序写再好,开到第 1025 个连接也会直接报 “Too many open files”。生产环境我一般调到 100 万。/proc/sys/net/core/somaxconn限制 listen 队列最大值,如果你的 backlog 设置超过它,最终会被它截断。这些不是 C# 代码问题,但高并发排查时经常遇到。
6. 高频问题排查与压测实录
6.1 本地 10 万连接压测怎么做
压测高并发 Socket 服务器,很容易走入“客户端成了瓶颈”的误区。本地起几个 Task 连几千个连接,发现服务端 CPU 没跑满,就不敢继续加,其实往往是客户端先扛不住了。我的建议是不要用一个进程模拟几万客户端,尽量用多台机器或多个进程,每个进程只模拟 5000 到 10000 个连接。
最简单的压测方式是写一个客户端压力工具,每个连接建立一个 TcpSession,循环发送固定大小的请求。你要统计几个指标:
- 连接数峰值
- 每秒处理消息数(QPS)
- P50、P95、P99 延迟
- 服务端 CPU、内存
- GC 频率和单次 GC 暂停
在 Linux 上可以用dotnet-counters观察内存和线程池数据,用perfview或者dotnet-trace抓热点。如果延迟 P99 突然上不去,优先怀疑三件事:线程池线程不够、锁竞争、GC 分配过多。在这三种原因里,锁竞争最常见,比如每个连接一个锁、全局消息队列加锁、连接字典加锁,都能把并发拖垮。
6.2 错误速查表
| 现象 | 原因 | 处理方法 |
|---|---|---|
| Accept 报 Too many open files | Linux 文件描述符上限不够 | 调大 ulimit -n |
| 服务重启 Bind 报 Address already in use | 大量 TIME_WAIT 连接 | 绑定前设置 ReuseAddress |
| 客户端连上后偶尔收不到首个数据包 | AcceptAsync 同步完成分支没处理 | 判断 AcceptAsync 返回值,同步完成直接回调 |
| TCP 消息解析出乱码 | 粘包半包没处理 | 使用长度前缀拆包器 |
| UDP 收包时抛 10054 | Windows 的 UDP ConnectionReset | 初始化 Socket 后执行 IOControl(-1744830452...) |
| ReceiveCompleted 里出现 ObjectDisposedException | 连接被主动释放后,异步完成事件仍然回调 | 用 int 状态位控制在 Dispose 后忽略回调 |
| 高并发下 GC 频繁 | 每包 new byte[] / new SAEA | 改用 ArrayPool 和对象池 |
| 客户端大量连接建立失败 | 客户端频繁 new Socket 导致握手风暴 | 接入连接池和退避重连 |
| 压测性能先低后高 | .NET 线程池慢启动 | 启动时 ThreadPool.SetMinThreads |
6.3 几个我后来才改掉的坏习惯
第一个坏习惯是在async void事件处理函数里直接await。这个方法表面能跑,但一旦内部抛出异常,会导致整个进程崩溃,而且很难追踪。所有 SocketAsyncEventArgs 的 Completed 事件,我后来都改成“同步逻辑 + 明确调用异步方法”结构,禁止把业务 await 放在事件处理链里。
第二个坏习惯是连接关闭时只调 Socket.Close。Close 默认会立即返回,但底层可能还有未发送的数据,或者对端还在读取。社区里常见的做法是Socket.Shutdown(SocketShutdown.Both)之后再 Close,但 Shutdown 本身也可能阻塞。我现在的策略是:先停止接受新读写,把发送队列里的剩余数据尽量 Flush,再关闭。到了高并发场景,“如何优雅退场”跟“如何高效进场”同样重要。
第三个坏习惯是过度优化。为了省一两次new,很多人把缓冲区和 SAEA 复用得过于极端,导致调试时根本无法定位数据污染。我后来把代码分成两档:默认档用 SocketAsyncEventArgs + ArrayPool,够稳妥;极限档才上无锁队列和内存池。做 Socket 高并发,先保证正确性和可排查性,再谈压榨性能。这个顺序不能反。
按我这套架构从零搭建,TCP 服务器端、TCP 客户端、UDP 服务器端、UDP 客户端四块代码是可以解耦的。你如果不做 UDP,完全不需要为了“完整性”硬塞一个收到 10054 就崩的模块进去。根据业务需要选型,比照抄一份庞大源码更重要。如果要拿这套思路改造成私有协议,建议先把长度前缀解码器和会话生命周期管理写好,这两块稳了,上层业务基本不会出大问题。