Table of contents
Open Table of contents
TL;DR
Go 运行时在创建网络 socket 时将其设为非阻塞,当 Read/Write 返回 EAGAIN 时,通过 gopark 挂起 goroutine(不占 OS 线程),将 fd 注册到 epoll/kqueue(边缘触发),就绪后通过调度器的 findRunnable 或 sysmon 唤醒 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
实际执行路径:
- socket 在创建时已被设为非阻塞模式(
syscall.SetNonblock(fd, true)) syscall.Read返回EAGAIN(数据未就绪)- goroutine 被
gopark挂起(状态_Gwaiting),OS 线程被释放去执行其他 goroutine - fd 已注册在 epoll 实例上,内核跟踪就绪状态
- 调度器在
findRunnable或sysmon中调用epoll_wait,发现 fd 就绪 - 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 的会被静默丢弃 |
atomicInfo | 将 pollClosing/pollEventErr/pollExpiredReadDeadline/pollExpiredWriteDeadline/pollFDSeq(20bit) 打包为一个 uint32,netpollcheckerr 无需加锁即可检查 |
rt/wt | 内联 timer,不走堆分配(Go 1.14 重构,减少 lock contention) |
rseq/wseq | 重设截止时间时递增,旧定时器回调发现 seq 不匹配则静默退出 |
pollCache — 对象池
type pollCache struct {
lock mutex
first *pollDesc // 空闲链表头
}
分配策略:
- 批量分配:一次申请 4KB,切分为多个
pollDesc,摊薄分配成本 - 非 GC 内存(
persistentalloc):pollDesc被 epoll 内核数据结构引用(epoll_event.data存的是pollDesc指针),GC 不能移动或回收它 - 空闲链表复用:连接关闭后
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 判断
平台对比
| 平台 | 后端 | 源码 | 触发模式 | 特点 |
|---|---|---|---|---|
| Linux | epoll | runtime/netpoll_epoll.go | 边缘触发(EPOLLET) | 注册时 EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET,一次 epoll_wait 最多返回 128 事件 |
| macOS/BSD | kqueue | runtime/netpoll_kqueue.go | 边缘触发(EV_CLEAR) | 每个 fd 注册两个 filter(EVFILT_READ + EVFILT_WRITE),fd 关闭时自动注销 |
| Windows | IOCP | runtime/netpoll_windows.go | 完成通知(completion-based) | 与 readiness-based 模型不同,通知的是 I/O 完成而非就绪 |
| Solaris/AIX | /dev/poll, pollset | 各自实现 | 水平触发 | 需要在每次 pollWait 前调用 netpollarm 重新注册 |
为什么用边缘触发
边缘触发(ET)只在状态变化时通知(数据从无到有),不在状态持续时重复通知(缓冲区有未读数据)。Go 的重试循环(读到 EAGAIN 才挂起)保证了正确性。好处:
- 避免 goroutine 还没处理完数据时被重复唤醒
- 减少
epoll_wait返回的事件数量 - 注册一次即可,不需要每次重新 arm(Solaris 等水平触发平台需要)
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) 参数语义
delay < 0:无限阻塞,直到有事件delay == 0:非阻塞,立即返回delay > 0:最多阻塞delay纳秒(转换为 epoll_wait 的毫秒参数)
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:
- 检查距上次 netpoll 是否超过 10ms,超过则触发一次非阻塞
netpoll(0) - 将就绪的 goroutine 注入全局运行队列
- 这是网络 I/O 不被 CPU 密集型任务饿死的最后保障
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 时,走阻塞路径:
syscall.Read直接阻塞 OS 线程(M)- 运行时通过 handoff 机制补偿:
sysmon检测到 M 阻塞超过 10ms → 将 P 转交给新 M - 代价:线程膨胀。高并发文件 I/O 会创建大量 OS 线程
对比:
| 类型 | I/O 路径 | goroutine 状态 | M 状态 | P 状态 |
|---|---|---|---|---|
网络 I/O(net.Conn.Read) | netpoller(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 netpoller | Node.js (libuv) | Java Virtual Threads | Rust tokio |
|---|---|---|---|---|
| 模型 | M:N 调度 + runtime 内置 epoll | 单线程事件循环 + worker pool | M: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.0 | 2012 | 无集成 netpoller,网络 I/O 直接阻塞 OS 线程 |
| Go 1.1 | 2013.5 | netpoller 引入(Linux epoll + macOS kqueue),release notes: “fewer context switches on network operations” |
| Go 1.2 | 2013.12 | 扩展到 Windows(IOCP)和 BSD(kqueue),“网络性能提升约 30%“ |
| Go 1.5 | 2015.8 | 运行时从 C 重写为 Go,netpoller 代码迁移到 Go |
| Go 1.14 | 2020.2 | 定时器重构:deadline 定时器内联到 pollDesc(rt/wt 字段),减少 lock contention 和上下文切换;同版引入异步抢占(SIGURG) |
| Go 1.23 | 2024.8 | Timer/Ticker 语义重构(无缓冲 channel),Windows 时间分辨率从 15.6ms 提升到 0.5ms |
| Go 1.24 | 2025.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()。
信息来源
runtime/netpoll.go、runtime/netpoll_epoll.go、runtime/netpoll_kqueue.go— Go 源码(确定)internal/poll/fd_unix.go、internal/poll/fd_poll_runtime.go— Go 源码(确定)runtime/proc.go(findRunnable、sysmon)— Go 源码(确定)- Scalable Go Scheduler Design Doc (Dmitry Vyukov, 2012)(确定)
- Go 1.1/1.2/1.14/1.24 Release Notes — go.dev/doc(确定)
- io_uring proposal #31908(确定)