Go netpoller 与 goroutine 模型:为什么一连接一协程可行

"一连接一协程"在 C 或 Java 里是禁忌,Go 里却敢这么写。原因不在"协程更轻"这句口号,而在于 Go 把 epoll(Linux 的事件多路复用)藏在了 fd 背后:所有网络 fd 都是非阻塞的,读写遇到 EAGAIN 就挂起协程、把线程让出去,fd 就绪后再唤醒协程。开发者写的是同步阻塞代码,运行时干的是事件驱动。

先算清代价:线程模型为什么不行

模型十万连接的代价编程复杂度
一连接一线程(thread per connection)十万线程 × 8 MB 默认栈 = 800 GB 虚拟地址空间,切换要进出内核态,不可行低
事件循环 + 回调(epoll + callback)内存最省,但状态机拆散了业务逻辑高
协程 + netpoller(Go 的做法)协程栈 2 KB 起,二十万协程约 0.4~1 GB低,同步写法
协程 + 真阻塞 IO 系统调用阻塞调用会拖住线程,仍需海量线程低但不可行

关键差别只有一个:等待数据就绪这件事,是由协程自己"阻塞"承担,还是由线程承担。Go 让协程承担,线程就始终在干活。

内核侧:epoll 就绪链表

多路复用的核心机制是 select/poll 每次都要把全量 fd 拷进内核并线性扫描,复杂度 O(n);epoll 让内核维护一张就绪链表(ready list),只返回真正就绪的 fd,复杂度 O(就绪数)。Go 运行时在 Linux 上的网络轮询器(netpoller)就是 epoll 的一层封装:

netpollopen(fd)  → epoll_ctl(EPOLL_CTL_ADD),事件掩码 EPOLLIN | EPOLLOUT | EPOLLRDHUP | EPOLLET
netpoll(delay)   → epoll_wait(epfd, events, delay),返回就绪的 pollDesc 列表
netpollBreak()   → 往一个 eventfd 写入,强制打断阻塞中的 epoll_wait

三个要点:fd 以**边沿触发(edge-triggered,EPOLLET)**注册,所以协程被唤醒后必须把数据读干净;netpollBreak 用 eventfd 唤醒,是为了在"有新的定时器/后台任务"时立刻让 epoll_wait 返回,而不是傻等到超时;每个线程最多只有一个 epoll_wait 在跑,谁空闲谁去等。

Go 侧:一次 Read 的完整路径

func handle(conn net.Conn) {
    defer conn.Close()
    buf := make([]byte, 4096)
    for {
        _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
        n, err := conn.Read(buf)   // 数据没到 → EAGAIN → 挂起当前协程
        if err != nil {
            return                 // 超时或对端关闭
        }
        if _, err = conn.Write(buf[:n]); err != nil {
            return
        }
    }
}

这条 Read 内部发生了这些事:

  1. fd 在创建时就被置为 O_NONBLOCK,所以 read 系统调用不会阻塞线程;
  2. 内核缓冲区没有数据时,read 返回 EAGAIN,运行时调用 netpollblock 把当前 G 标记为等待该 fd 的读事件,然后 gopark 挂起它;
  3. 挂起的 G 不在任何运行队列里,P(逻辑处理器)继续去跑别的协程,这就是十万连接只需要几个线程的原因;
  4. 数据到达,epoll_wait 返回该 fd 对应的 pollDesc,运行时 netpollready → goready 把 G 放进运行队列;
  5. 被唤醒的 G 重试 read,这次拿到数据后按同步代码继续往下走。超时则靠运行时定时器把 G 唤醒,并让 read 返回 i/o timeout。

没有轮询、没有空转:协程只在"确实有数据"时被唤醒,CPU 花在真正的收发上。后台监控线程 sysmon 每 10 ms 调一次 netpoll(0) 兜底,防止 epoll_wait 长时间不返回导致就绪事件没人处理。

唯一会让线程膨胀的东西

调用类型是否走 netpoller后果
网络读写(net.Conn、TCP、Unix socket)是协程挂起,线程不受影响
数据库、Redis 等基于网络客户端的调用是同上(第 04 章"读写分离"就靠这个)
本地文件读写、syscall.Read/Write否线程真的进内核等待
CGO、time.Sleep(长时长)部分长阻塞会触发创建新线程

一旦某个协程在不经过 netpoller 的阻塞调用上停住,运行时会把这个 M 与该 P 分离(handoff),再创建一个新线程来顶替。所以纯网络服务几十个线程就够,而混入了同步文件 IO 或 CGO 的服务,threads 会涨到几百上千——这是排查"线程数异常"的第一线索。

一连接两协程的真实内存

第 04 章采用读、写各一个协程的分离模型,所以十万连接对应约 二十万个协程:

  • 每个协程的初始栈 2 KB(运行时 _StackMin),按需翻倍增长到 4 KB、8 KB、16 KB,64 位平台上最大 1 GB;栈不够用时运行时会分配更大的栈并拷贝过去;
  • 二十万协程 × 2 KB = 400 MB 起步,读写循环里带缓冲、有局部变量时典型落在 0.8~1.6 GB;
  • 协程栈分配在堆上,对 GC 可见,所以"协程多"会直接表现为 GC 扫描压力,而不是隐形开销;
  • GC 期间会做栈收缩(shrinkstack),空闲协程的栈能被收回去,这也是"长连接闲时占内存、忙时反而稳定"的原因之一。

观测手段

GODEBUG=schedtrace=1000 ./im-server
# SCHED 1000ms: gomaxprocs=16 idleprocs=3 threads=24 spinningthreads=0 needspinning=0 idlethreads=19 runqueue=2 [1 0 0 ...] gc=3

curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2' | grep -c 'net.(\*conn).Read'
go tool pprof -top -inuse_space http://127.0.0.1:6060/debug/pprof/heap
go tool trace ./trace.out     # 看 Syscall blocking profile 与协程阻塞分布

schedtrace 里最该看两个字段:threads 远大于 gomaxprocs 说明有非 netpoller 的阻塞调用;runqueue 长期非 0 说明 CPU 是瓶颈。协程总数则用 runtime.NumGoroutine() 或 pprof 看,它的增长曲线应当与在线连接数同步——只涨不跌就是泄漏(详见第 17 章)。

常见坑

现象原因处理
threads 涨到几百读循环里做了同步文件 IO 或 CGO 调用改为异步或投递到固定大小的 worker 池
协程数只涨不跌退出路径漏了 return/close,或心跳没设读超时统一 defer conn.Close() + ReadDeadline
空闲连接仍占大量内存每连接缓冲过大、帧不设上限裁缓冲、SetReadLimit,用 pprof 定位
CPU 空转 100%有人在循环里 Read 后立刻重试或自旋检查是否有绕过阻塞的忙等逻辑
GOMAXPROCS 设得远大于核数误以为能提升并发保持等于 CPU 核数,并发能力来自 netpoller 而非线程数
自己实现 epoll 网络层重复造轮子,还容易漏掉边沿触发语义直接用 net 包,它已经把 netpoll 接好了

小结:Go 把 epoll 就绪链表与 fd 的非阻塞语义封装在 net 包内部,协程只负责"同步地读",遇到 EAGAIN 由运行时挂起、由 epoll 事件唤醒,线程因此始终在跑别的协程;代价是每个协程 2 KB 起的栈(二十万协程约 0.4~1 GB,且对 GC 可见),唯一的雷区是混入不走 netpoller 的阻塞调用——它会让线程数膨胀。

笔记加载中…