跳转到正文
zeno's blog
返回

Go 运行时(三):netpoller 如何用 epoll 与 gopark 跑异步 I/O

专题: Go 运行时

Table of contents

Open Table of contents

TL;DR

Go 运行时在创建网络 socket 时将其设为非阻塞,当 Read/Write 返回 EAGAIN 时,通过 gopark 挂起 goroutine(不占 OS 线程),将 fd 注册到 epoll/kqueue(边缘触发),就绪后通过调度器的 findRunnablesysmon 唤醒 goroutine 重试——用户写的是同步代码,运行时跑的是异步 I/O。


1. 解决什么问题

没有 netpoller 会怎样

Go 的并发模型允许轻松创建数十万 goroutine。如果每个 goroutine 执行网络 read() 时直接阻塞 OS 线程(M),10,000 个并发连接就需要 10,000 个 OS 线程——每个线程 1-8MB 栈内存,加上内核调度开销,系统直接崩溃。

netpoller 的解法

将传统 I/O 多路复用(epoll/kqueue/IOCP)封装进运行时,对上层暴露同步 API:

n, err := conn.Read(buf)  // 看起来阻塞了 goroutine

实际执行路径:

  1. socket 在创建时已被设为非阻塞模式syscall.SetNonblock(fd, true)
  2. syscall.Read 返回 EAGAIN(数据未就绪)
  3. goroutine 被 gopark 挂起(状态 _Gwaiting),OS 线程被释放去执行其他 goroutine
  4. fd 已注册在 epoll 实例上,内核跟踪就绪状态
  5. 调度器在 findRunnablesysmon 中调用 epoll_wait,发现 fd 就绪
  6. goroutine 被 goready 唤醒,重试 syscall.Read——这次数据已到达

本质:用户写同步代码(可读性好),运行时跑异步 I/O(性能好)。事件循环的复杂性被完全封装在 runtime 中。


2. 三层抽象架构

用户代码
  net.Conn / net.TCPConn           ← 网络连接抽象
    └── net.netFD                  ← 网络元数据(family, sotype, addr)
        └── internal/poll.FD       ← I/O 操作 + 非阻塞重试循环
            └── runtime.pollDesc   ← goroutine 挂起/唤醒 + 截止时间管理
                └── epoll/kqueue   ← OS 级 I/O 多路复用

net.netFD

位于 net/fd_posix.go,持有网络元数据,所有 I/O 委托给 poll.FD

type netFD struct {
    pfd         poll.FD     // 实际的 I/O 包装器
    family      int         // AF_INET, AF_INET6
    sotype      int         // SOCK_STREAM, SOCK_DGRAM
    isConnected bool
    net         string      // "tcp", "udp"
    laddr       Addr
    raddr       Addr
}

internal/poll.FD

位于 internal/poll/fd_unix.go,核心是非阻塞重试循环

type FD struct {
    fdmu        fdMutex     // 串行化 Read/Write
    Sysfd       int         // OS 文件描述符
    pd          pollDesc    // netpoller 集成
    csema       uint32      // close 信号量
    isBlocking  uint32      // 是否阻塞模式
    IsStream    bool        // 流式 vs 数据报
    isFile      bool        // 文件 vs 网络 socket
}

FD.Read 的核心逻辑:

func (fd *FD) Read(p []byte) (int, error) {
    if err := fd.readLock(); err != nil { return 0, err }
    defer fd.readUnlock()
    for {
        n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p)
        if err == syscall.EAGAIN && fd.pd.pollable() {
            // 数据未就绪 → 挂起 goroutine,等 netpoller 唤醒
            if err = fd.pd.waitRead(fd.isFile); err == nil {
                continue  // 被唤醒后重试 syscall
            }
        }
        return n, err
    }
}

runtime.pollDesc — 核心数据结构

位于 runtime/netpoll.go,每个被 netpoller 管理的 fd 对应一个 pollDesc

type pollDesc struct {
    link  *pollDesc          // pollCache 空闲链表指针
    fd    uintptr            // 关联的文件描述符

    // --- 无锁状态机 ---
    fdseq      atomic.Uintptr  // 序列号,防止 fd 复用导致的虚假唤醒
    atomicInfo atomic.Uint32   // 打包的状态位(closing/expired/eventErr/fdseq)

    rg atomic.Uintptr  // 读信号量:pdNil → pdWait → G指针 → pdReady
    wg atomic.Uintptr  // 写信号量:同上

    // --- 截止时间管理 ---
    lock mutex
    rd   int64    // 读截止时间(nanotime,-1 表示已过期,0 表示无截止时间)
    wd   int64    // 写截止时间
    rt   timer    // 读截止时间定时器(内联,Go 1.14 起)
    wt   timer    // 写截止时间定时器
    rseq uintptr  // 读定时器序列号,防止过期回调误触发
    wseq uintptr  // 写定时器序列号
}

关键设计

字段作用
rg/wg无锁二元信号量。状态流转:pdNil(0) → pdWait(2) → *g(goroutine 指针) → pdReady(1)
fdseq每次 fd 复用时递增,epoll 事件携带旧 fdseq 的会被静默丢弃
atomicInfopollClosing/pollEventErr/pollExpiredReadDeadline/pollExpiredWriteDeadline/pollFDSeq(20bit) 打包为一个 uint32netpollcheckerr 无需加锁即可检查
rt/wt内联 timer,不走堆分配(Go 1.14 重构,减少 lock contention)
rseq/wseq重设截止时间时递增,旧定时器回调发现 seq 不匹配则静默退出

pollCache — 对象池

type pollCache struct {
    lock  mutex
    first *pollDesc  // 空闲链表头
}

分配策略:


3. 完整 I/O 生命周期

conn.Read(buf) 为例,从用户代码到内核再回来:

用户: conn.Read(buf)

  ├─ 1. net.TCPConn.Read → netFD.Read → poll.FD.Read
  │     syscall.Read(fd, buf) → EAGAIN(非阻塞 socket,数据未到)

  ├─ 2. fd.pd.waitRead → runtime_pollWait(pd, 'r')
  │     poll_runtime_pollWait 检查错误(closing/timeout),无错则进入 netpollblock

  ├─ 3. netpollblock(pd, 'r', false)
  │     CAS: rg = pdNil → pdWait(标记为等待状态)
  │     gopark(netpollblockcommit, ..., waitReasonIOWait)
  │       ├─ netpollblockcommit: CAS rg = pdWait → G指针,增加 netpollWaiters
  │       └─ goroutine 进入 _Gwaiting 状态,M 被释放去跑其他 G

  ├─ 4. [内核] epoll 监控 fd,数据到达时标记就绪

  ├─ 5. 调度器调用 netpoll()
  │     epoll_wait() 返回就绪事件
  │     → netpollready → netpollunblock: CAS rg = G指针 → pdReady,提取 G
  │     → 返回 gList,调度器 injectglist 将 G 放入运行队列

  ├─ 6. G 被调度执行,gopark 返回
  │     netpollblock 返回 true(old == pdReady)
  │     poll_runtime_pollWait 返回 pollNoError

  └─ 7. FD.Read 的 for 循环 continue
        syscall.Read(fd, buf) → 成功读取数据 → 返回给用户

4. 平台后端实现

统一接口

每个平台必须实现:

func netpollinit()                                // 一次性初始化
func netpollopen(fd uintptr, pd *pollDesc) int32  // 注册 fd
func netpollclose(fd uintptr) int32               // 注销 fd
func netpoll(delta int64) (gList, int32)          // 轮询事件
func netpollBreak()                               // 唤醒阻塞的 netpoll
func netpollIsPollDescriptor(fd uintptr) bool     // 内部 fd 判断

平台对比

平台后端源码触发模式特点
Linuxepollruntime/netpoll_epoll.go边缘触发EPOLLET注册时 EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET,一次 epoll_wait 最多返回 128 事件
macOS/BSDkqueueruntime/netpoll_kqueue.go边缘触发EV_CLEAR每个 fd 注册两个 filter(EVFILT_READ + EVFILT_WRITE),fd 关闭时自动注销
WindowsIOCPruntime/netpoll_windows.go完成通知(completion-based)与 readiness-based 模型不同,通知的是 I/O 完成而非就绪
Solaris/AIX/dev/poll, pollset各自实现水平触发需要在每次 pollWait 前调用 netpollarm 重新注册

为什么用边缘触发

边缘触发(ET)只在状态变化时通知(数据从无到有),不在状态持续时重复通知(缓冲区有未读数据)。Go 的重试循环(读到 EAGAIN 才挂起)保证了正确性。好处:

fdseq 防虚假唤醒

连接关闭后 OS 可能立即复用同一个 fd 号给新连接。如果旧连接的 epoll 事件还在队列中,会错误唤醒新连接的 goroutine。

解法:epoll_event.data 存的是 pollDesc 指针 | fdseq。事件到达时比较 fdseq,不匹配则静默丢弃。


5. 与 GMP 调度器的集成

netpoll() 的调用时机

调用位置阻塞模式触发条件作用
findRunnable() 非阻塞优化netpoll(0)本地队列为空、有 netpollWaiters快速检查是否有就绪的网络 G
findRunnable() 最后手段netpoll(delay)所有队列为空、steal 失败阻塞等待网络事件或下一个定时器
sysmon 监控线程netpoll(0)距上次 netpoll 超过 10ms安全网:即使所有 P 都忙于 CPU 计算,也保证网络 G 不饿死
startTheWorldWithSema()netpoll(0)STW(GC)结束后恢复 STW 期间就绪的网络 G

netpoll(delay) 参数语义

netpollWaiters 优化

netpollWaiters 是原子计数器,追踪当前挂起在 netpoller 上的 goroutine 数量。调度器在调用 netpoll() 前检查 netpollAnyWaiters()——如果为零,跳过 epoll_wait 系统调用,避免无网络 I/O 时的无谓开销。

sched.pollingNet 串行化

findRunnable 中通过 sched.pollingNet 原子变量确保同一时刻只有一个 P 执行 netpoll,避免多核机器上对内核 epoll 实例的竞争。

sysmon 的角色

sysmon 是独立的 M(不需要 P),守护线程方式运行,休眠策略从 20μs 指数增长到最大 10ms:


6. 截止时间(Deadline)实现

SetReadDeadline 的内部流程

conn.SetReadDeadline(time.Now().Add(5*time.Second))
  → runtime_pollSetDeadline(pd, absoluteNanotime, 'r')
    → 存储 pd.rd = absoluteNanotime
    → 启动内联定时器 pd.rt,回调函数 = netpollReadDeadline
    → 递增 pd.rseq(使旧定时器回调失效)

定时器到期时:

netpollReadDeadline(pd)
  → 检查 pd.rseq 是否匹配(防止已重设的旧截止时间误触发)
  → pd.rd = -1(标记过期)
  → 发布 atomicInfo(设置 pollExpiredReadDeadline 位)
  → netpollunblock(pd, 'r') → 唤醒 goroutine
  → goroutine 在 netpollcheckerr 中发现 pollErrTimeout → 返回 os.ErrDeadlineExceeded

关键陷阱:Deadline 是绝对时间,不是超时时间

conn.SetReadDeadline(time.Now().Add(5 * time.Second))
conn.Read(buf1)  // ✅ 有 5 秒
// ... 过了 3 秒 ...
conn.Read(buf2)  // ⚠️ 只剩 2 秒!不是重新计时 5 秒

想要每次操作都有独立超时,必须在每次 Read/Write 前重设:

for {
    conn.SetReadDeadline(time.Now().Add(5 * time.Second))  // 每次重设
    n, err := conn.Read(buf)
    // ...
}

7. 文件 I/O 为什么不走 netpoller

Linux 的 epoll 不支持普通文件。 对普通文件 fd 调用 epoll_ctl 返回 EPERM。这是内核限制——epoll 只支持 socket、pipe、eventfd 等。

FD.Init() 检测到 fd 不可 poll 时,走阻塞路径:

对比

类型I/O 路径goroutine 状态M 状态P 状态
网络 I/O(net.Conn.Readnetpoller(epoll)_Gwaiting_Grunnable释放去跑其他 G不释放
文件 I/O(os.File.Read阻塞 syscall_Gsyscall阻塞在内核可能被 sysmon 抢走

社区有通过 io_uring 支持异步文件 I/O 的提案(issue #31908),但因 io_uring 的固定大小 submission ring 与 goroutine 的无限增长模型存在架构冲突,目前仍未实现。


8. 与其他运行时对比

维度Go netpollerNode.js (libuv)Java Virtual ThreadsRust tokio
模型M:N 调度 + runtime 内置 epoll单线程事件循环 + worker poolM:N(Loom)+ 阻塞时自动 unmount多线程 work-stealing + async/.await
用户代码风格同步(透明)async/await/callback同步(透明)显式 async/.await
每连接开销~2-8KB(goroutine 栈)~1KB(handle + 闭包)~1KB(虚拟线程)几百字节(Future 状态机)
多核利用天然(GOMAXPROCS 个 P)需要 cluster 模块天然(ForkJoinPool)天然(多线程 runtime)
颜色问题无(全异步)(async fn 不能直接调 sync fn)
GC有(STW 影响尾延迟)有(V8 GC)有(成熟但重)

9. 历史演进

版本时间变化
Go 1.02012无集成 netpoller,网络 I/O 直接阻塞 OS 线程
Go 1.12013.5netpoller 引入(Linux epoll + macOS kqueue),release notes: “fewer context switches on network operations”
Go 1.22013.12扩展到 Windows(IOCP)和 BSD(kqueue),“网络性能提升约 30%“
Go 1.52015.8运行时从 C 重写为 Go,netpoller 代码迁移到 Go
Go 1.142020.2定时器重构:deadline 定时器内联到 pollDesc(rt/wt 字段),减少 lock contention 和上下文切换;同版引入异步抢占(SIGURG)
Go 1.232024.8Timer/Ticker 语义重构(无缓冲 channel),Windows 时间分辨率从 15.6ms 提升到 0.5ms
Go 1.242025.2新的 runtime 内部 mutex 实现,CPU 开销平均降低 2-3%,MPTCP 默认启用(Linux)

Pitfalls

1. 文件 I/O 静默阻塞 OS 线程

os.File.Read 不走 netpoller,高并发文件读写会导致线程膨胀。解法:用 worker pool 限制并发文件 I/O 数量。

2. 不设 Deadline 导致 goroutine 泄漏

客户端断网(无 FIN/RST)时,conn.Read 的 goroutine 永远挂在 netpoller 上。必须设置 Deadline

3. Deadline 是绝对时间不是超时

见第 6 节。每次操作前必须重设,否则后续操作继承已缩短的剩余时间。

4. DNS 解析可能阻塞 OS 线程

cgo resolver(macOS 默认)调用 getaddrinfo,阻塞 OS 线程。限制为 500 并发,但仍可能导致线程膨胀。解法:GODEBUG=netdns=go 强制使用纯 Go resolver(走 netpoller)。

5. CPU 密集型 goroutine 延迟网络事件处理

所有 P 都忙于 CPU 计算时,findRunnable 中的 netpoll(0) 无法被调用。sysmon 的 10ms 兜底保证了网络 G 不会永远饿死,但延迟最高可达 10ms。解法:CPU 密集型任务与 I/O 密集型任务分离,或在计算循环中插入 runtime.Gosched()


信息来源


分享这篇文章:

上一篇
Go 基础:错误处理与 Errors Are Values
下一篇
Go 运行时(二):并发三色标记清除收集器