
你写了一个 TCP echo server,100 个并发连接跑得好好的。加到 1000 个,P99 延迟从 2.85ms 涨到 24.87ms。再往上加,直接连不上了。
这时候你可能会想:是不是 Go 的网络性能不行?
不是。是你还停在第一层。
Go 的网络编程有三层递进。第一层,用好标准库——net.Listen 一把梭,大多数场景够用。第二层,突破标准库——用 syscall.RawConn 去摸标准库没暴露的 socket 选项。第三层,绕过标准库——自定义协议、事件驱动框架,彻底掌控传输层。
大多数开发者停在第一层,因为第一层确实够用。但"够用"有边界,这篇文章帮你找到那条边界。
1. 第一层:用好标准库
net.Listen("tcp", ":8080") 帮你做了多少事?拆开看:socket 创建、端口绑定、开始监听——三步并一步。然后你在 for 循环里 Accept,每个连接开一个 goroutine,读写完了 Close。
这是 Go 网络编程的标准姿势,简洁、够用。
但"够用"有个范围。
我跑了一组实测 benchmark [实测 Go 1.26.2, macOS ARM64, 本地回环]:TCP echo server,128 字节消息,默认配置 vs 优化配置(开 TCP_NODELAY + 读写缓冲区 64KB)。完整测试代码见 evidence/code/tcp-benchmark。
| 并发 | 配置 | QPS | P99 延迟 |
|---|---|---|---|
| 100 | 默认 | 57,604 | 2.85ms |
| 100 | 优化 | 57,543 | 2.09ms |
| 1000 | 默认 | 53,706 | 24.87ms |
| 1000 | 优化 | 53,812 | 22.23ms |

QPS 几乎没差别。但 P99 延迟在 100 并发时优化后降了 27%(2.85ms → 2.09ms)。
默认配置下 Nagle 算法会攒小包再发——它在等凑够一个 MSS 或者等一个 ACK 回来。对于 128 字节的小消息,这个"等"就是多出来的延迟。SetNoDelay(true) 禁掉 Nagle 之后,小包直接发出去,P99 立刻下来。
|
|
这三行代码就是第一层能做的全部优化了。如果你的 P99 延迟要求不严(比如 > 10ms 也能接受),而且并发连接不超过几百个,那确实够了。
什么时候该看第二层?当你的 P99 延迟开始不可接受、或者并发连接数接近系统 fd 上限时。

2. 第二层:突破标准库
标准库的 net.TCPConn 提供了 SetNoDelay、SetReadBuffer、SetKeepAlive 这些方法。但 TCP/IP 协议栈有几十个 socket 选项,标准库只暴露了几个常用的。
比如 SO_REUSEPORT——让多个进程绑定同一个端口,内核做负载均衡。标准库不提供。再比如 TCP_KEEPIDLE——空闲多久后开始发 keepalive 探测,默认 7200 秒(2 小时),需要快速检测断连的场景完全不够。标准库也不提供。
Go 留了两个口子。
口子一:ListenConfig.Control
在 socket() 之后、bind() 之前,你可以插手设置 socket 选项:
|
|

开了 SO_REUSEPORT 之后,你可以起多个 listener 进程绑同一个端口。内核会把新连接分发到各个进程(Linux 4.5+ 下基本均匀)。在多核机器上,这比单进程 Accept 然后分发给 worker 效率更高,减少了跨核调度。
口子二:SyscallConn
连接建立之后,用 SyscallConn() 拿到底层 fd:
|
|
7200 秒和 30 秒的差别:如果对端挂了,默认配置下你要等超过 2 小时才能发现(7200 秒 idle + 探测间隔 × 探测次数)。游戏服务器或即时通讯场景下,这不可接受。
第二层的核心价值是控制粒度。你能调的旋钮从 3 个变成了几十个。大多数时候你不需要动它们,但当你需要的时候,标准库挡不住你。完整的 RawConn 使用示例见 evidence/code/rawconn-demo。
那什么时候该看第三层?当 goroutine-per-connection 模型的内存开销成为问题,或者你需要 TCP 之外的传输协议时。

3. 第三层:绕过标准库
第二层解决了"标准库没暴露的参数"问题。第三层解决的是"标准库的模型本身不适合"的问题。
Go 标准库的网络模型是 goroutine-per-connection:每个连接一个 goroutine。runtime 在底层用 epoll(Linux)或 kqueue(macOS)管理 I/O 事件,goroutine 等数据时被 park(挂起),不占用线程。写起来简单,效率也不差。
但它有成本。每个 goroutine 的栈初始约 2-8KB(会动态扩缩),加上调度器的元数据开销。1 万连接 = 1 万 goroutine,栈内存至少 20MB,实际因栈增长通常更高。到百万连接级别,光 goroutine 就可能吃掉数 GB 内存。

事件驱动替代方案
gnet、evio 这类框架走另一条路:一个 event loop 管理所有连接,数据到了触发回调,不需要每个连接一个 goroutine。一个最小的 event loop 回调长这样:
|
|
和标准库对比:没有 go handleConn(conn) 这一步,也就没有 goroutine-per-connection 的内存开销。在高连接数、低活跃度的场景下(比如物联网网关——10 万设备连着,大部分时间沉默),差异特别明显。
你真的需要 TCP 吗?
TCP 是一个全能协议——可靠传输、有序到达、流量控制、拥塞控制,全包了。但很多场景不需要全包。
问自己四个问题:需要可靠传输吗?需要有序到达吗?需要流量控制吗?需要拥塞控制吗?

| 场景 | 可靠? | 有序? | 拥塞控制? | 推荐 |
|---|---|---|---|---|
| 游戏状态同步 | ❌ 最新帧覆盖旧帧 | ❌ | ❌ 要的是快 | 原始 UDP |
| IoT 传感器上报 | ✅ 不能丢 | ❌ 到了就行 | ❌ 数据量小 | UDP + ACK |
| 实时音视频 | ❌ 丢帧可容忍 | ✅ 播放需有序 | ✅ 避免雪崩 | WebRTC(含 DTLS-SRTP) |
| 微服务 RPC | ✅ | ✅ | ✅ | TCP / gRPC |
如果你只需要"可靠但不需要有序和拥塞控制"——在 UDP 上实现序列号和 ACK 就够了。核心逻辑不到 50 行:
|
|
我跑了这个 demo 的完整版本(含 encodePacket/decodePacket 实现),3 条消息全部可靠送达,这是一个功能验证——证明最小协议可以工作。完整代码见 evidence/code/mini-reliable-udp。
关键判断:如果你需要拥塞控制,不要自己写。拥塞控制是 TCP/IP 最复杂的部分之一——Google 从 2012 年开始设计 QUIC,到正式成为 RFC 9000 花了近十年。直接用 quic-go,纯 Go 实现,已经 production-ready。
你在哪一层?
回到开头的问题:echo server 加到 1000 并发,P99 延迟从 2.85ms 涨到 24.87ms。
第一层先查:SetNoDelay 开了吗?一行代码,P99 降 27%。读写缓冲区调过吗?
第二层再看:需要 SO_REUSEPORT 做多进程负载均衡吗?keepalive 还是默认的 7200 秒吗?
第三层最后考虑:连接数到万级了吗?goroutine 内存是瓶颈吗?你真的需要 TCP 的全部属性吗?
大多数 Go 程序,第一层就够了。知道边界在哪,比停留在"够用"更重要。
本文聚焦 server 端网络优化。client 端(连接池、Dialer 配置、DNS 优化等)是另一个话题。
附录:实验代码和原始数据
本文 3 组实验的代码和原始数据已开源:
GitHub:zhiyulab-evidence/go-net-optimization
tcp-benchmark/— TCP echo server benchmark(默认 vs 优化配置,100/1000 并发)rawconn-demo/— syscall.RawConn 控制 socket 选项示例(SO_REUSEPORT、TCP_KEEPIDLE)mini-reliable-udp/— 最小可靠 UDP 协议 demo(序列号+ACK+超时重传)
每个子目录都有独立 go.mod,说明如何复现。