你写的 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));
}

七、写在最后(也是下一篇预告)

今天的三板斧:

  1. 抛弃 TcpClient,拥抱 SocketAsyncEventArgs
  2. 用 ArrayPool<byte> 管理缓冲区,减少 GC
  3. 用 Span<byte> 实现零拷贝转发

如果你跟着做下来,原本只能扛几百个连接的 TCP 服务,现在支撑 50000+ 绝不是梦。

更多推荐