Go 能轻松处理百万级并发连接,秘密就是 netpoll,将 Linux epoll(或 macOS kqueue)与 Go 调度器深度集成。当一个 goroutine 在网络 IO 上阻塞时,它不会阻塞操作系统线程,而是挂起 goroutine,将 M 让出来运行其他 goroutine。当数据就绪时,epoll 唤醒对应的 goroutine 继续执行。
epoll 基础
select/poll 的问题
| 机制 | 复杂度 | 连接数限制 | 问题 |
|---|---|---|---|
| select | O(n) | 1024(FD_SETSIZE) | 每次调用需传入全部 fd |
| poll | O(n) | 无限制 | 每次调用需传入全部 fd |
| epoll | O(1) | 无限制 | 只通知就绪的 fd |
epoll 的三个 API
int epoll_create(int size); // 创建 epoll 实例int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 注册/修改/删除 fdint epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待就绪Go netpoll 架构
pollDesc 结构体
每个网络连接在 Go 中对应一个 pollDesc:
type pollDesc struct { link *pollDesc // 链表:串联到 pollCache 中 lock mutex // 保护以下字段的互斥锁 fd uintptr // 文件描述符
// 读事件相关 rseq uintptr // 读序列号,检测 fd 重用或 timer 重置 rg uintptr // 等待读的 goroutine(或 pdReady/pdWait) rt timer // 读超时计时器 rd int64 // 读截止时间(纳秒时间戳)
// 写事件相关 wseq uintptr // 写序列号 wg uintptr // 等待写的 goroutine(或 pdReady/pdWait) wt timer // 写超时计时器 wd int64 // 写截止时间
// ... 其他字段(closing, everr 等)}rseq/wseq 用于检测文件描述符被复用或计时器被重置的场景,防止旧事件唤醒错误的 goroutine。rg/wg 是一个小型状态机,存储 pdReady、pdWait、*g 或 nil 四种值。
pollCache:批量分配 pollDesc
所有 pollDesc 由全局的 pollCache 管理,采用批量分配策略:
type pollCache struct { lock mutex first *pollDesc // 空闲链表头}
func (c *pollCache) alloc() *pollDesc { lock(&c.lock) if c.first == nil { // 批量分配约 4KB 的 pollDesc 结构体 const pdSize = unsafe.Sizeof(pollDesc{}) n := pollBlockSize / pdSize if n == 0 { n = 1 } mem := persistentalloc(n*pdSize, 0, &memstats.other_sys) // 串成链表 for i := uintptr(0); i < n; i++ { pd := (*pollDesc)(add(mem, i*pdSize)) pd.link = c.first c.first = pd } } pd := c.first c.first = pd.link unlock(&c.lock) return pd}persistentalloc 保证这些结构体分配在不会被 GC 扫描的内存中,只有 epoll/kqueue 模块内部引用它们。批量分配避免了每次打开连接都向系统申请内存。
netpollinit:初始化 epoll 实例
netpollinit 在程序启动时被调用,创建 epoll 实例和用于中断 epoll_wait 的 pipe:
var epfd int32 = -1 // epoll 文件描述符,-1 表示未初始化
func netpollinit() { epfd = epollcreate1(_EPOLL_CLOEXEC) if epfd < 0 { throw("runtime:epollcreate1 failed") } // 创建非阻塞 pipe,用于 netpollBreak 中断 epoll_wait r, w, errno := nonblockingPipe() if errno != 0 { throw("runtime:pipe failed") } // 注册 pipe 读端到 epoll ev := epollevent{ events: _EPOLLIN, } *(**pollDesc)(unsafe.Pointer(&ev.data)) = &pollDesc0 // 标记为 break pipe epollctl(epfd, _EPOLL_CTL_ADD, r, &ev)}epollcreate1(_EPOLL_CLOEXEC) 创建 epoll 实例,_EPOLL_CLOEXEC 保证 fork 后子进程不会继承 epoll fd。nonblockingPipe() 创建一对非阻塞的 pipe,读端注册到 epoll 上。这个 pipe 的作用是:当调度器需要立即中断 epoll_wait(比如有新的 goroutine 需要运行),就向 pipe 写入一个字节,epoll_wait 收到事件后立即返回。
netpollopen:注册 fd 到 epoll
func netpollopen(fd uintptr, pd *pollDesc) int32 { // 设置边缘触发 + 读/写事件 + one-shot var ev epollevent ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET *(**pollDesc)(unsafe.Pointer(&ev.data)) = pd return epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)}关键标志位:
_EPOLLET:边缘触发模式,只在状态变化时通知一次,不会重复通知_EPOLLIN | _EPOLLOUT:同时监听读写事件_EPOLLRDHUP:监听对端关闭连接
Go 选择边缘触发而非水平触发,是因为 goroutine 模型下每次唤醒后都会尝试完整地读写直到 EAGAIN,不需要内核反复通知。
netpoll:事件循环
netpoll 是调度器调用的核心函数,执行 epoll_wait 并返回就绪的 goroutine 列表:
// src/runtime/netpoll_epoll.go(核心逻辑)func netpoll(delay int64) gList { if epfd == -1 { return gList{} } // 计算等待时间 var waitms int32 if delay < 0 { waitms = -1 // 无限等待 } else if delay == 0 { waitms = 0 // 非阻塞 } else { waitms = int32(delay / 1000000) // 纳秒转毫秒 }
var events [128]epolleventretry: n := epollwait(epfd, &events[0], int32(len(events)), waitms) if n < 0 { // 被信号中断,重试 if waitms > 0 { return gList{} } goto retry }
var toRun gList for i := int32(0); i < n; i++ { ev := &events[i] if *(**pollDesc)(unsafe.Pointer(&ev.data)) == &pollDesc0 { // break pipe 事件:消费 pipe 数据,不唤醒 goroutine continue } pd := *(**pollDesc)(unsafe.Pointer(&ev.data)) // 根据事件类型,设置 rg/wg 为 pdReady var mode int32 if ev.events&(_EPOLLIN|_EPOLLRDHUP|_EPOLLHUP|_EPOLLERR) != 0 { mode += 'r' } if ev.events&(_EPOLLOUT|_EPOLLHUP|_EPOLLERR) != 0 { mode += 'w' } if mode != 0 { netpollready(&toRun, pd, mode) } } return toRun}每次 epoll_wait 最多返回 128 个事件。对每个事件,根据 epoll_event.data 找到对应的 pollDesc,然后调用 netpollready 将等待的 goroutine 加入 toRun 列表。如果是 break pipe 的事件,直接跳过,它的目的只是让 epoll_wait 提前返回。
netpollBreak:中断 epoll_wait
var netpollBreak uint32
func netpollBreak() { // 避免重复写入 if atomic.Cas(&netpollBreak, 0, 1) { // 向 break pipe 写入一个字节 for { n := write(netpollBreakWr, netpollBreakBuf[:], 0) if n == 1 { break } } }}netpollBreak 在以下场景被调用:调度器发现有空闲的 P 但没有可运行的 G,而 epoll_wait 可能正在阻塞等待。写入一个字节到 break pipe 后,epoll_wait 立即返回,调度器可以重新检查是否有工作要做。atomic.Cas 保证不会重复写入,避免 pipe 溢出。
netpollready:唤醒等待的 goroutine
func netpollready(toRun *gList, pd *pollDesc, mode int32) { var rg, wg *g if mode == 'r' || mode == 'r'+'w' { rg = netpollunblock(pd, 'r', false) } if mode == 'w' || mode == 'r'+'w' { wg = netpollunblock(pd, 'w', false) } if rg != nil { toRun.push(rg) } if wg != nil { toRun.push(wg) }}
func netpollunblock(pd *pollDesc, mode int32, ioready bool) *g { gpp := &pd.rg if mode == 'w' { gpp = &pd.wg } for { old := atomic.Loaduintptr(gpp) if old == pdReady { return nil // 已经就绪,无需重复唤醒 } if old == 0 && !ioready { return nil // 没有等待者 } var new uintptr if ioready { new = pdReady } if atomic.Casuintptr(gpp, old, new) { if old == 0 || old == pdWait { return nil // 没有实际的 goroutine 在等 } return (*g)(unsafe.Pointer(old)) // 返回等待的 goroutine } }}netpollunblock 是 netpollblock 的逆操作。它将 rg/wg 从 *g 或 pdWait 状态转为 pdReady 或 0,并返回之前等待的 goroutine 指针。如果状态已经是 pdReady,说明之前已经通知过了,直接跳过。
非阻塞 IO + epoll 工作流程
连接建立
读取数据
关键代码路径
// src/internal/poll/fd.go (简化版)func (fd *FD) Read(p []byte) (int, error) { for { n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p) if err != syscall.EAGAIN { return n, err // 有数据或真实错误 } // EAGAIN:没有数据,等待 if err = fd.pd.WaitRead(); err != nil { return n, err } }}func (pd *pollDesc) WaitRead() error { return pd.wait('r')}
func (pd *pollDesc) wait(mode int) error { // 将当前 goroutine 注册到 pollDesc netpollblock(pd, mode) // 挂起 goroutine return nil}goroutine 挂起与唤醒
挂起:netpollblock
// src/runtime/netpoll.go (简化版)func netpollblock(pd *pollDesc, mode int) bool { gpp := &pd.rg // 读等待 if mode == 'w' { gpp = &pd.wg // 写等待 }
// 设置 pdWait 标记 for { old := atomic.Loaduintptr(gpp) if old == pdReady { atomic.Casuintptr(gpp, pdReady, 0) return true // 已经就绪,不需要等待 } if atomic.Casuintptr(gpp, 0, pdWait) { break // 成功设置为等待状态 } }
// 挂起当前 goroutine gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceEvGoBlockNet, 5) return true}唤醒:netpoll
调度器在多个时机调用 netpoll 来检查就绪事件:
func schedule() { // ... 调度逻辑
// 检查 netpoll list := netpoll(0) // 非阻塞检查 if !list.empty() { injectglist(&list) // 将就绪的 goroutine 注入调度队列 }}netpoll 被调用的时机:
- schedule():每次调度时检查
- sysmon():后台监控线程定期检查
- findRunnable():找不到可运行的 G 时检查
- GC 的 stopTheWorld 之后:恢复前检查
pollDesc 的状态机
pollDesc 的 rg(读等待)和 wg(写等待)字段有精确的状态语义,它们不只是”有无 goroutine 等待”的布尔值,而是一个小型状态机:
| 状态值 | 含义 |
|---|---|
0 | 没有 goroutine 等待,也没有就绪事件 |
pdReady(1) | 有就绪事件,但没有 goroutine 在等。下一个 WaitRead 会立即返回 |
pdWait(2) | 有 goroutine 正在等待,但还未完成注册 |
*g(>2) | 指向等待中的 goroutine 结构体 |
这个状态机的关键设计:pdReady 是”缓存”的就绪通知。如果 epoll 在 goroutine 调用 WaitRead 之前就通知了 fd 就绪,状态会被设为 pdReady,下一次 WaitRead 直接返回,不需要 gopark/epoll_wait 的完整循环。这避免了”通知丢失”问题。
源码:runtime/netpoll.go 中 netpollblock 和 netpollunblock 函数。
Deadline 与 Timer 的集成
Go 的 net.Conn 支持 SetReadDeadline/SetWriteDeadline/SetDeadline,超时后 Read/Write 返回 os.ErrDeadlineExceeded。这个机制不只是应用层的定时器,而是与 netpoll 和 runtime timer 深度集成的。
超时实现链路
超时通过 runtime 的 timer 系统在超时时刻调用 netpollunblock,把等待中的 goroutine 标记为就绪。goroutine 恢复执行后,检查超时标志,返回 ErrDeadlineExceeded。这比”另起 goroutine 等待然后 close”更高效,因为超时路径不引入额外的 goroutine 或锁。
poll_runtime_pollSetDeadline 源码
SetDeadline 最终调用 poll_runtime_pollSetDeadline,它将 deadline 转换为 runtime timer:
// src/runtime/netpoll.go(核心逻辑)func poll_runtime_pollSetDeadline(pd *pollDesc, d int64, mode int) { lock(&pd.lock) // 计算距离 deadline 的纳秒数 delta := d - nanotime() if delta < 0 { delta = 0 // 已过期 }
if mode == 'r' || mode == 'r'+'w' { pd.rd = d // 取消旧的 timer if pd.rt.f != nil { deltimer(&pd.rt) } if delta > 0 { // 注册新 timer,到期调用 netpollDeadline pd.rt.f = netpollDeadline pd.rt.when = d pd.rt.arg = pd addtimer(&pd.rt) } else { // 已过期,立即唤醒 netpollunblock(pd, 'r', false) } } // 写 deadline 类似处理... unlock(&pd.lock)}
// timer 到期回调func netpollDeadline(arg any) { pd := arg.(*pollDesc) netpollunblock(pd, 'r', false) // 唤醒读等待的 goroutine}关键细节:pd.rt 是 pollDesc 内嵌的 timer 结构体,不需要额外分配。deltimer + addtimer 的组合实现了 deadline 的更新。如果新的 deadline 已经过期(delta <= 0),直接调用 netpollunblock 唤醒等待者,不走 timer 路径。
源码:runtime/netpoll.go 中 poll_runtime_pollSetDeadline 和 netpollDeadline 函数。
一个容易踩的坑:Deadline 是绝对时间
// 错误:每次循环都设同样的 deadlineconn.SetReadDeadline(time.Now().Add(5 * time.Second))for { buf := make([]byte, 1024) n, err := conn.Read(buf) // 第一次 Read 5s 后超时,后续可能立即超时 if err != nil { break }}
// 正确:每次 Read 前刷新 deadlinefor { conn.SetReadDeadline(time.Now().Add(5 * time.Second)) buf := make([]byte, 1024) n, err := conn.Read(buf) // 每次 Read 都有 5s 超时 if err != nil { break }}SetDeadline 接受的是绝对时间点,不是超时 duration。如果设了一次 deadline 后循环 Read,后续的 Read 可能在已过期的 deadline 上立即返回错误。
netpoll 与调度器的集成
conn.Read() 的完整底层流程
- 用户代码:
n, err := conn.Read(buf) net.Conn.Read→net.TCPConn.Read→net.conn.Readnet.conn.Read→net/fd_posix.go: FD.ReadFD.Read→syscall.Read(fd, buf)[非阻塞]syscall.Read返回EAGAIN(无数据)FD.Read→pollDesc.WaitRead()WaitRead→runtime.netpollblock()netpollblock→runtime.gopark()[挂起 goroutine]- 调度器:M 切换到运行其他 goroutine
- [内核:数据到达,epoll 通知]
sysmon/schedule→runtime.netpoll(0)netpoll→epoll_wait(0)→ 返回就绪 fdnetpoll→goready(等待的 goroutine)- goroutine 被放入运行队列
- goroutine 恢复执行
- 再次
syscall.Read(fd, buf)→ 成功读取数据 - 返回数据给用户代码
性能特征与调优
为什么 Go 网络性能好?
| 特性 | Go 的实现 | 好处 |
|---|---|---|
| 非阻塞 IO | 所有网络 fd 设为非阻塞 | 不阻塞 M |
| epoll 集成 | netpoll 封装 epoll | O(1) 事件通知 |
| goroutine 挂起 | gopark 挂起而非阻塞线程 | M 可运行其他 G |
| 批量唤醒 | netpoll 返回就绪列表 | 减少调度次数 |
调优参数
// 设置 epoll 的最大等待时间runtime.GOMAXPROCS(n) // 更多 P = 更多并发调度
// 使用 SO_REUSEPORT 允许多个 listenerlc := net.ListenConfig{ Control: func(network, address string, c syscall.RawConn) error { return c.Control(func(fd uintptr) { syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEPORT, 1) }) },}常见问题 FAQ
Q1:Go 的 netpoll 和 nginx 的 epoll 有什么区别?
机制相同(都使用 epoll),但集成方式不同:nginx 是事件驱动回调模型,Go 是 goroutine 阻塞/唤醒模型。Go 的优势是编程模型更简单(同步写法),nginx 的优势是没有调度器开销。
Q2:为什么 Go 不用 io_uring?
Go 团队长期在评估 io_uring,但目前 netpoll+epoll 已经足够好。io_uring 的优势在大量小 IO 场景,但 Go 的 goroutine 模型已经通过并发掩盖了 IO 延迟。截至本文写作时,主线仓库尚未合并 io_uring 的 netpoll 实现,相关讨论仍在提案阶段,此处存疑,建议核对 go.dev/cl 和 Go 提案issue 的最新状态。
Q3:netpoll 能用于文件 IO 吗?
不能。文件 IO 在 Linux 上总是就绪的(不返回 EAGAIN),epoll 对常规文件无效。Go 的文件 IO 使用系统调用,会阻塞 M。
Q4:如何监控 netpoll 的状态?
# 使用 Go 执行追踪器$ go test -trace=trace.out$ go tool trace trace.out
# 查看 epoll 状态$ cat /proc/$(pidof myprogram)/fdinfo/3 # 3 是 epoll fdQ5:一个 goroutine 阻塞在网络 IO 上,最多等多久?
取决于调度器调用 netpoll 的频率。sysmon 是不绑定 P 的独立线程,检查间隔会动态调整:空闲时从约 20μs 起步逐步增长,最长约 10ms;schedule 和 findRunnable 也会在调度循环中非阻塞调用 netpoll(0)。所以数据就绪后的唤醒延迟通常远小于 10ms,最坏情况不超过 sysmon 的最长间隔。
小结
netpoll 的核心思路是把 epoll 事件与 Go 调度器绑定,epoll_wait 返回的就绪 fd 直接映射到挂起的 goroutine,通过 goready 唤醒后注入全局运行队列。整个链路 epoll_wait → goready → injectglist → schedule 环环相扣,网络 I/O 因此不再阻塞 M。
pollDesc 的状态机(0 → pdWait → *g → pdReady → 0)是一个容易被忽视的细节,它确保了 epoll 通知和 goroutine 等待之间的同步:如果在 goroutine 调用 WaitRead 之前 epoll 就通知了 fd 就绪,状态会被缓存为 pdReady,下一次 WaitRead 直接返回,不会丢失通知。Deadline 机制则通过 runtime timer 在超时时刻调用 netpollunblock 唤醒等待的 goroutine,不需要额外创建 goroutine 来做超时检查。
但有一个边界:文件 I/O 不走 netpoll,读写文件仍然会阻塞 M,这是 Go 运行时的一个已知限制,也是 aio 和 io_uring 相关提案的推动力。
参考资料
- Go Runtime Source: netpoll_epoll.go - epoll 实现
- Go Runtime Source: netpoll.go - netpoll 逻辑与 pollDesc 状态机
- Go Runtime Source: proc.go - 调度器集成
- Go Internal Poll: fd_poll_runtime.go - Deadline 与 Timer 集成
- The Go netpoller - 深度分析文章
- Linux epoll(7) - epoll 手册
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






