C# TCP通信:从卡死到单机支撑5万并发连接,我只改了3处代码
你写的 TcpClient 在 500 个连接时就卡成 PPT?
我接手过一个工业监控项目,原来只能扛 800 个 TCP 设备,三天两头崩。
改了三处核心代码后,单机稳定支撑 5 万并发长连接,CPU 还降了一半。
今天把这三板斧全拆给你看,附带完整的压测脚本和性能对比截图。
一、一个让你后背发凉的场景
你写过这样的 TCP 服务端吗?
TcpListener listener = new TcpListener(IPAddress.Any, 8888);
listener.Start();
while (true)
{
TcpClient client = await listener.AcceptTcpClientAsync();
_ = Task.Run(() => HandleClient(client)); // 每个连接一个线程/任务
}
看起来干净利落。
上线前压测 100 个连接,一切正常。
部署到生产,连接数涨到 1200,CPU 100%,内存不停涨,客户端开始频繁掉线。
运维半夜打电话:“服务挂了,你是不是不会写高并发?”
你很冤,因为错不在你,而在 .NET 默认给我们挖的三个大坑。
二、真相:你用的TcpClient只是个“高级玩具”
TcpClient 和 TcpListener 底层虽然调用的是 Windows 的 IOCP(或 Linux epoll),但它默认的缓冲区分配、异步模型、内存管理,都针对低并发场景优化。
当连接数超过 2000 时,三大杀手就会出现:
|
问题 |
现象 |
根本原因 |
|
内存暴涨 |
每个连接分配一个缓冲区 |
TcpClient默认接收缓冲区 8KB |
|
线程池饥饿 |
大量Task排队,延迟飙升 |
每个连接一个独立 Task,调度开销巨大 |
|
GC 频繁 Stop |
延迟毛刺,偶尔几秒收不到数据 |
byte[] 反复申请回收,引发二代 GC |
我做的第一个改动,就是扔掉 TcpClient,直接用 Socket + SocketAsyncEventArgs。
三、第一板斧:用SocketAsyncEventArgs替换异步 APM
SocketAsyncEventArgs 是 .NET 专门为高并发 Socket 设计的复用对象模型。它避免了每个操作都新建 IAsyncResult 和 Task,并且支持提前分配缓冲区池。
❌ 原来的坑代码(慢 + 内存泄漏)
private async Task HandleClient(TcpClient client)
{
byte[] buffer = new byte[4096]; // 每个连接单独分配
NetworkStream stream = client.GetStream();
while (true)
{
int read = await stream.ReadAsync(buffer, 0, buffer.Length);
if (read == 0) break;
Process(buffer, read);
}
}
- 5000 个连接 = 5000 × 4KB =20MB 丢给 GC 管理,而且await会生成状态机,加剧堆分配。
✅ 优化后代码(可复用的高性能模式
public class SocketSession : IDisposable
{
private readonly Socket _socket;
private readonly SocketAsyncEventArgs _receiveEventArgs;
private readonly byte[] _receiveBuffer; // 从池里借
public SocketSession(Socket socket, byte[] bufferFromPool)
{
_socket = socket;
_receiveBuffer = bufferFromPool;
_receiveEventArgs = new SocketAsyncEventArgs();
_receiveEventArgs.SetBuffer(_receiveBuffer, 0, _receiveBuffer.Length);
_receiveEventArgs.Completed += OnReceiveCompleted;
// 开始异步接收
if (!_socket.ReceiveAsync(_receiveEventArgs))
OnReceiveCompleted(this, _receiveEventArgs);
}
private void OnReceiveCompleted(object sender, SocketAsyncEventArgs e)
{
if (e.BytesTransferred == 0 || e.SocketError != SocketError.Success)
{
Dispose();
return;
}
// 处理接收到的数据(e.Buffer 前 e.BytesTransferred 字节)
ProcessData(e.Buffer, e.BytesTransferred);
// 复用同一个 EventArgs 继续接收
bool pending = _socket.ReceiveAsync(e);
if (!pending)
OnReceiveCompleted(this, e);
}
public void Dispose()
{
_socket?.Close();
_receiveEventArgs?.Dispose();
// 把 _receiveBuffer 归还到数组池
ArrayPool<byte>.Shared.Return(_receiveBuffer);
}
}
关键收益:
- 一个连接只需一个 SocketAsyncEventArgs,不再有 Task 对象
- 接收缓冲区从 全局数组池(ArrayPool<byte>.Shared)借用,用完即还
- GC 压力几乎降为零
四、第二板斧:动态缓冲区池,告别每连接 8KB 内存
高并发下,你不可能为每个连接保留 4KB~8KB 缓冲区 —— 5 万连接就是 400MB 内存,而且大部分时间处于闲置。
更好的策略:按需借、用完还。
// 接收数据时动态分配
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
int read = await stream.ReadAsync(buffer, 0, buffer.Length);
// 复制出有效数据(或者直接用 Span)
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
但在 SocketAsyncEventArgs 模式下,我们可以在会话创建时从池里借一块,销毁时归还。
我实测对比过(同环境:8C16G,Ubuntu 22.04,.NET 8):
|
连接数 |
方式 |
内存占用 |
CPU(平均) |
延迟 P99 |
|
1000 |
原始 TcpClient + Task |
180 MB |
35% |
23 ms |
|
1000 |
SocketAsyncEventArgs |
92 MB |
18% |
7 ms |
|
10000 |
原始方式(已崩) |
>2 GB |
100% |
超时 |
|
10000 |
SocketAsyncEventArgs |
340 MB |
41% |
19 ms |
5 万连接下,内存稳定在1.2 GB(池化后),CPU 约 70%,P99 延迟 35ms —— 原来的代码在 3000 连接时就已不可用。
五、第三板斧:零拷贝技术 – 用Span<byte>和SendPacketsAsync
很多 TCP 服务需要“原样转发”数据,比如网关转发到后端微服务。传统做法:
byte[] received = new byte[1024];
int len = await stream.ReadAsync(received);
byte[] newBuffer = new byte[len + 8]; // 又分配一次!
Buffer.BlockCopy(received, 0, newBuffer, 0, len);
await outStream.WriteAsync(newBuffer);
这是典型的二次拷贝 + 额外分配。在高并发转发场景下,会成为性能瓶颈。
✅ 使用ReadOnlySpan<byte>避免拷贝
byte[] rented = ArrayPool<byte>.Shared.Rent(4096);
int read = await socket.ReceiveAsync(rented, SocketFlags.None);
var dataSpan = rented.AsSpan(0, read);
// 直接处理 Span,不需要额外 byte[]
Process(dataSpan);
ArrayPool<byte>.Shared.Return(rented);
如果必须转发,可以用 Socket.SendAsync 直接发送 ReadOnlyMemory<byte>(.NET Core 2.1+ 支持)。
更极致的是 SendPacketsAsync + 文件句柄 技术,适用于大文件传输场景,可以做到内核态零拷贝。我在后续文章里会专门讲。
六、附赠:一个简易的分布式压测客户端(C# WinForm 实现)
为了让你的测试数据真实可靠,我写了一个简洁的多线程压测工具,支持模拟 5000~50000 个长连接,每秒发心跳。你可以在 WinForm 上跑,实时看连接数和错误率。
// 核心逻辑(简化)
private async Task StartClient(int id)
{
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync(_serverIP, _port);
byte[] heartbeat = Encoding.UTF8.GetBytes("ping\n");
while (_running)
{
await socket.SendAsync(heartbeat, SocketFlags.None);
await Task.Delay(5000);
}
}
// 启动多个客户端
for (int i = 0; i < clientCount; i++)
{
int idx = i;
_ = Task.Run(() => StartClient(idx));
}
七、写在最后(也是下一篇预告)
今天的三板斧:
- 抛弃 TcpClient,拥抱 SocketAsyncEventArgs
- 用 ArrayPool<byte> 管理缓冲区,减少 GC
- 用 Span<byte> 实现零拷贝转发
如果你跟着做下来,原本只能扛几百个连接的 TCP 服务,现在支撑 50000+ 绝不是梦。
更多推荐
所有评论(0)