2.1 的结论是「Go 把 Reactor 做进了 runtime」。这个结论有一个直接推论:既然内核事件表在 runtime 里,那业务代码就可以放弃回调、写成最直白的同步代码——一个连接一个 goroutine,Read 阻塞就阻塞。
但这个推论马上会撞上一个疑问:每个连接一个 goroutine,一万个连接就是一万个 goroutine,这不算多吗? 站内专题 /posts/game/server/practice/ 里的方案文章通常只给一句「Go 的 goroutine 很轻,随便开」。本节不接受这句话,而是把它拆成可量的三块:栈内存、调度开销、以及真实的连接与往返数字。
本节要回答:2000 条并发连接在 goroutine-per-connection 模型下,建立要多久、往返延迟多少、吞吐多少? 结论是:本机实测 2000 条连接零失败建立,拨号 P50 约 15ms;单次往返延迟从 8 连接时的约 1.1ms 涨到 2000 连接时的约 1.3s——涨的不是协议栈开销,是排队;这台机器在这条 echo 路径上的吞吐上限约 4000–6700 往返/秒,并发拉到 2000 反而降到 1000 往返/秒左右。
2.2.1 一个连接一个 goroutine:成本到底在哪
先把服务端写出来。整个并发模型只有一句话:Accept 到一个连接,就 go 一个函数去伺候它,函数里用看起来阻塞的 Read / Write 写同步代码。
// snippet: internal/netio/echoserver.go(Accept 循环与默认处理器)
for {
c, err := s.ln.Accept()
if err != nil {
if s.closed.Load() || errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
s.accepted.Add(1)
if tc, ok := c.(*net.TCPConn); ok {
// 游戏服必开:Nagle 会把小于 MSS 的小包攒起来等 ACK,
// 与对端 delayed ACK 叠加就是一个 40ms 级别的额外往返。
_ = tc.SetNoDelay(true)
}
s.mu.Lock()
s.conns[c] = struct{}{}
s.mu.Unlock()
s.wg.Add(1)
go func(c net.Conn) {
defer s.wg.Done()
defer func() {
s.mu.Lock()
delete(s.conns, c)
s.mu.Unlock()
_ = c.Close()
}()
echoLoop(c)
}(c)
}
// echoLoop 读多少回多少。这里没有任何 select / 状态机 / 回调注册——
// Read 与 Write 都是「阻塞」调用,但 netpoller 会把阻塞的 goroutine
// 从 M 上摘下来,让 M 去跑别的 goroutine。
func echoLoop(c net.Conn) {
buf := make([]byte, 16*1024)
for {
n, err := c.Read(buf)
if n > 0 {
if _, werr := c.Write(buf[:n]); werr != nil {
return
}
}
if err != nil {
return
}
}
}
一个空闲连接到底占多少?三个数字:
- 栈内存:2KB 起步。
src/runtime/stack.go:78写着stackMin = 2048。栈在堆上分配、按需增长(copystack把整块搬走),并在 GC 时被shrinkstack缩回去。也就是说,一个只收发小包的连接,栈永远不会超过 2KB。 - goroutine 结构体本身:几十字节。
g结构体加上它挂的运行队列指针、栈边界、状态位,远小于栈本身。 - OS 线程:零。 一个 goroutine 不绑定任何线程,它只在
_Grunning的那一小段时间里占用一个 M。
第三条是关键。GOMAXPROCS 决定 P 的数量(本机是 10),P 决定同时能跑多少个 goroutine;2000 个连接里绝大多数处于 _Gwaiting(等 fd 就绪),它们既不占线程也不占 P。
这里有一个容易被忽略的细节:goroutine 阻塞在 fd 上时,它的 M 不会跟着一起睡。netpollblock 走的是 gopark,而 gopark 只是把 goroutine 从 P 上摘下来、把 P 交给下一个可运行的 goroutine。真正会睡的是没有活可干的 M——调度器在 findRunnable 里找不到任何可运行的 goroutine 时,才会调用 netpoll(delay) 把 M 自己挂到 epoll/kqueue 上。换句话说,线程是被「整个系统都没活干」这个条件挂起的,不是被某一个连接的 Read 挂起的。这正是同样 10 个线程能撑住 2000 条连接的机制。
所以 2.2 这个模型真正的成本不在「多少个 goroutine」,而在下面两件事:
- netpoller 每次唤醒要经过一次 goroutine 调度。 一次往返至少涉及 2 次
gopark+ 2 次goready,这些是有成本的。 - 同一条 fd 上的读写必须串行。
internal/poll.FD里有readLock/writeLock,同一 fd 上的并发Read会被挡住——这也是「一个连接一个 goroutine」在语义上比回调更安全的原因。
那这两件事加起来值多少钱?得量。
2.2.2 真起进程:2000 条并发连接的建立与往返
压测客户端分两段:先错峰拨号,再逐连接做若干轮往返。错峰不是调优,是 macOS 上的必需品——本机 kern.ipc.somaxconn 实测是 128,一次性发起 2000 个 connect,超出 accept 队列的 SYN 会被内核直接 RST。所以按 64 条一批、批间隔 15ms 拨号。
下面这段是 L2(真起进程、真建连接),go test 可以直接跑:
// file: internal/netio/loadtest/manyconns_test.go
// tier: L2
// verify: go test -v -run TestManyConnections -timeout 600s ./internal/netio/loadtest/
// Package loadtest 是第 2 章的 L2 测试:真起进程、真建连接。
package loadtest
import (
"sync/atomic"
"testing"
"time"
"aegis/internal/netio"
)
const totalConns = 2000
// TestManyConnections 起一个真实 TCP echo 服务端,错峰建立 2000 条并发连接,
// 全部保持打开,每条做 5 轮往返,报告连接建立耗时与 RTT 的 P50/P99。
//
// 为什么必须错峰连接:macOS 的 kern.ipc.somaxconn 在本机实测是 128,
// 一次性发起 2000 个 connect 时,超出 accept 队列的 SYN 会被内核 RST,
// 表现为随机的 connection reset by peer。
//
// 为什么还要量一组 8 连接的基线:2000 路同时发包的延迟里,
// 绝大部分是调度排队而不是协议栈开销。不给基线,读者会把这个数字
// 误当成「Go 的 TCP 有多慢」。
func TestManyConnections(t *testing.T) {
if testing.Short() {
t.Skip("短模式跳过 2000 连接压测")
}
srv, err := netio.NewEchoServer("127.0.0.1:0")
if err != nil {
t.Fatalf("监听失败: %v", err)
}
defer srv.Close()
go func() { _ = srv.Serve() }()
var liveAtPeak atomic.Int64
// 客户端 dial 返回只代表三次握手完成,服务端的 Accept 可能还差最后几条。
// 这里给 2 秒宽限,等 2000 条全部登记进连接表再采样。
onDialed := func() {
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && srv.Live() < totalConns {
time.Sleep(time.Millisecond)
}
liveAtPeak.Store(int64(srv.Live()))
}
t.Logf("ulimit -n=%s maxfilesperproc=%s somaxconn=%s ncpu=%s",
sysval(t, "sh", "-c", "ulimit -n"),
sysval(t, "sysctl", "-n", "kern.maxfilesperproc"),
sysval(t, "sysctl", "-n", "kern.ipc.somaxconn"),
ncpu(t))
base := netio.DialConfig{
Addr: srv.Addr(), Conns: totalConns, Batch: 64, BatchGap: 15 * time.Millisecond,
DialTimeout: 10 * time.Second, PingRounds: 5, NoDelay: true,
Payload: make([]byte, 32), AfterDial: onDialed,
}
// 基线:8 条连接、各 200 轮,几乎无并发争抢。
bl := netio.RunBench(netio.DialConfig{
Addr: srv.Addr(), Conns: 8, Batch: 8, DialTimeout: 10 * time.Second,
PingRounds: 200, NoDelay: true, Payload: make([]byte, 32),
})
t.Logf("基线(8 conns) %s", bl)
// 第一轮:2000 路同时发包。
herd := netio.RunBench(base)
t.Logf("惊群(2000) %s", herd)
t.Logf("服务端 accept=%d 拨号完成瞬间存活=%d", srv.Accepted(), liveAtPeak.Load())
// 第二轮:连接数与总轮数不变,只把发包起点按 2ms 一条往后推(共铺开 4 秒)。
spreadCfg := base
spreadCfg.PingStagger = 2 * time.Millisecond
spread := netio.RunBench(spreadCfg)
t.Logf("错峰(2000) %s", spread)
if herd.Failed != 0 || spread.Failed != 0 {
t.Fatalf("拨号失败:惊群 %d / 错峰 %d(典型原因:未错峰导致 SYN 被 RST)",
herd.Failed, spread.Failed)
}
if herd.RTTCount != totalConns*base.PingRounds {
t.Fatalf("往返样本数 %d != 期望 %d", herd.RTTCount, totalConns*base.PingRounds)
}
if got := liveAtPeak.Load(); got < totalConns {
t.Fatalf("拨号完成瞬间服务端存活连接 %d,期望 %d", got, totalConns)
}
}
本机一次真实运行的输出:
ulimit -n=1048576 maxfilesperproc=122880 somaxconn=128 ncpu=10
基线(8 conns) conns=8 failed=0 dial_total=7.332ms dial_p50=5.945ms dial_p99=6.807ms ping_window=385ms rtt_n=1600 rtt_p50=1.087ms rtt_p99=11.445ms rtt_max=18.403ms tput=4159 rt/s total=392ms
惊群(2000) conns=2000 failed=0 dial_total=1.856816s dial_p50=16.198ms dial_p99=637.268ms ping_window=9.924s rtt_n=10000 rtt_p50=1.339902s rtt_p99=4.179334s rtt_max=9.312899s tput=1008 rt/s total=11.782s
服务端 accept=2008 拨号完成瞬间存活=2000
错峰(2000) conns=2000 failed=0 dial_total=1.830765s dial_p50=14.914ms dial_p99=554.817ms ping_window=12.755s rtt_n=10000 rtt_p50=803.416ms rtt_p99=4.013179s rtt_max=5.693747s tput=784 rt/s total=14.587s
先看本机软限制:ulimit -n = 1048576,kern.maxfilesperproc = 122880。前者是当前 shell 的 fd 上限,后者是单进程的上限——两者都远大于 2000,所以这个实验里 fd 不是瓶颈。真正卡人的是 kern.ipc.somaxconn = 128 这个 accept 队列长度。
往返的测法是:客户端发 32 字节、io.ReadFull 读回 32 字节,两端都开了 TCP_NODELAY。这里刻意不做长度前缀、不做粘包处理——echo 场景下发多少回多少,字节数天然对齐;真正的消息分帧是第 3 章的事。这么做是为了让这一节的数字只反映「连接 + 调度」的成本,不掺进编解码。
再看数字:
- 2000 条连接零失败。 服务端 accept 了 2008 条(含前面基线那 8 条),拨号完成瞬间
Live()正好是 2000,全程没有任何 RST。 - 拨号 P50 约 15ms。 这不是握手的成本,而是错峰节奏的成本:64 条一批、间隔 15ms,平均每条要等大约一批的时间。真实 MMO 网关的登录洪峰也是这个形状——把瞬时并发压到队列容量以内,代价就是排队。
- RTT 从 1.087ms 涨到 1.34s,涨了三个数量级。 这是本节最容易被误读的数字,下一小节专门拆。
- 吞吐从 4159 往返/秒掉到 1008 往返/秒。 并发涨了 250 倍,吞吐反而掉了 4 倍。
必须交代测量环境:这台机器在测量期间的 1 分钟负载均值是 57–66,而它只有 10 个逻辑核。也就是说所有数字都是在超载状态下取得的。同一台机器上重复跑,基线吞吐在 4159–6685 往返/秒之间波动,2000 连接那一组在 982–1207 往返/秒之间波动。绝对数值不可移植,可移植的是它们之间的比例关系。
2.2.3 把数字读成容量:Little 定律与排队
为什么 2000 条连接会让单次往返从 1ms 变成 1.3 秒?答案不是「Go 慢」,而是排队。Little 定律(L = λW)说得很清楚:系统中的平均请求数 = 到达率 × 平均停留时间。反过来写就是:
在途请求数 = 吞吐 × 平均延迟
2000 = 1008 rt/s × 1.98s
测出来的 P50 是 1.34 秒,均值约 1.98 秒(受长尾拖累)——与 Little 定律吻合到小数点后一位。这说明 2000 个同时发出的请求,每个都要排在另外 2000 个后面等。延迟不是被协议栈吃掉的,是被队列吃掉的。
第二组数据印证了这个解释。第二轮把发包起点按 2ms 一条往后推、共铺开 4 秒:总轮数一样,吞吐也没变(784 对 1008 往返/秒,在噪声范围内),但 P50 从 1.34s 降到 803ms。同一时刻在途的请求少了,每个请求排的队就短了。这跟网络快慢毫无关系。
于是三条可以拿去做容量规划的结论:
| 结论 | 证据 | 对网关设计的含义 |
|---|---|---|
| 连接数本身几乎不花钱 | 2000 条 goroutine 的栈合计约 4MB | 别为「省连接」做连接池,该做的是控制并发请求数 |
| 延迟由在途请求数决定,不由连接数决定 | 错峰后 P50 减半、吞吐不变 | 限流要限每秒请求数,不是限连接数 |
| 并发拉高会先降吞吐再崩延迟 | 8 连接 4159 rt/s → 2000 连接 1008 rt/s | 网关必须给每个玩家设发送速率上限(见 15.3) |
第二行最反直觉,也最容易被忽略。一个挂机玩家开着一条连接,一秒不发一个包,对服务端几乎没有成本;而一个被外挂驱动、每秒发 500 个移动包的客户端,一条连接就能把整个网关的排队时间拉起来。这就是为什么 MMO 网关的限速单位是「包/秒」而不是「连接数」。
还要留意 P99 与 P50 的比值。基线那一组 P50 是 1.087ms、P99 是 11.445ms,差了约 10 倍;2000 连接那一组 P50 是 1.34s、P99 是 4.18s,只差约 3 倍。低负载时 P99/P50 的比值大,高负载时反而小——因为高负载下所有人都一样慢,长尾被队列本身抹平了。所以「P99 是 P50 的多少倍」这个指标只在负载没打满时才有诊断价值;一旦系统进入饱和区,它反而会给你一个虚假的安全感。真正该盯的是吞吐曲线什么时候开始拐弯。
本节小结
- goroutine-per-connection 的成本在调度,不在内存。 一个空闲连接的栈是 2KB(
stackMin = 2048,实测每实例约 2053 字节),2000 条合计约 4MB,可以忽略;真正的开销是每次 fd 就绪时的那次gopark/goready。 - 2000 条并发连接在本机零失败建立并保持。 拨号 P50 约 15ms 是错峰节奏的产物;
somaxconn = 128决定了必须错峰,ulimit -n = 1048576与maxfilesperproc = 122880说明 fd 不是瓶颈。 - 延迟是排队的结果,不是协议栈的结果。 Little 定律给出的 2000 / 1008 ≈ 1.98s 与实测均值吻合;错峰发包在不改变吞吐的前提下把 P50 砍掉四成,直接证伪了「Go 的网络慢」这个读法。
- 容量规划的单位是「请求/秒」,不是「连接数」。 这条会在 8.3 广播扇出、9.3 Tick 预算和 15.3 限速里反复出现。
最后留一个伏笔:本节只量了 Go。同样的 2000 条连接,如果换成 Java 的 Netty,或者换成 JDK 21 的虚拟线程,数字会怎么变?2.3 把三套模型放在同一张表里对照。
阅读导航:上一节:2.1 阻塞、非阻塞与 Reactor · 下一节:2.3 双栈对照:goroutine、Java 虚拟线程与 Netty EventLoop 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。