mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2312 字
7 分钟
Go netpoll:高并发网络的秘密武器
2023-08-21

Go 能轻松处理百万级并发连接,秘密就是 netpoll,将 Linux epoll(或 macOS kqueue)与 Go 调度器深度集成。当一个 goroutine 在网络 IO 上阻塞时,它不会阻塞操作系统线程,而是挂起 goroutine,将 M 让出来运行其他 goroutine。当数据就绪时,epoll 唤醒对应的 goroutine 继续执行。


epoll 基础#

select/poll 的问题#

机制复杂度连接数限制问题
selectO(n)1024(FD_SETSIZE)每次调用需传入全部 fd
pollO(n)无限制每次调用需传入全部 fd
epollO(1)无限制只通知就绪的 fd

epoll 的三个 API#

int epoll_create(int size); // 创建 epoll 实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // 注册/修改/删除 fd
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // 等待就绪
flowchart TD A["epoll_create()"] --> B["创建 epoll 实例"] B --> C["epoll_ctl(ADD)<br/>注册 fd 到 epoll"] C --> D["epoll_wait()<br/>等待就绪事件"] D --> E{"有 fd 就绪?"} E --> |"是"| F["返回就绪的 fd 列表"] E --> |"否"| G["阻塞等待<br/>(或超时返回)"] F --> H["处理就绪事件"] H --> D style F fill:#4CAF50,color:#fff

Go netpoll 架构#

pollDesc 结构体#

每个网络连接在 Go 中对应一个 pollDesc

src/runtime/netpoll.go
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 是一个小型状态机,存储 pdReadypdWait*gnil 四种值。

pollCache:批量分配 pollDesc#

所有 pollDesc 由全局的 pollCache 管理,采用批量分配策略:

src/runtime/netpoll.go
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:

src/runtime/netpoll_epoll.go
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#

src/runtime/netpoll_epoll.go
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]epollevent
retry:
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#

src/runtime/netpoll.go
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#

src/runtime/netpoll.go
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
}
}
}

netpollunblocknetpollblock 的逆操作。它将 rg/wg*gpdWait 状态转为 pdReady0,并返回之前等待的 goroutine 指针。如果状态已经是 pdReady,说明之前已经通知过了,直接跳过。

非阻塞 IO + epoll 工作流程#

连接建立#

sequenceDiagram participant G as Goroutine participant PD as pollDesc participant EP as epoll G->>PD: socket() → 设置非阻塞 G->>PD: connect() → EINPROGRESS G->>EP: epoll_ctl(ADD, fd, EPOLLOUT) EP-->>G: epoll_wait → fd 可写 G->>PD: 连接建立成功

读取数据#

flowchart TD A["goroutine 调用 conn.Read()"] --> B["syscall.Read(fd, buf)"] B --> C{"返回 EAGAIN?"} C --> |"否(有数据)"| D["直接返回数据"] C --> |"是(无数据)"| E["pd.WaitRead()"] E --> F["将当前 g 存入 pd.rg"] F --> G["gopark() — 挂起 goroutine"] G --> H["M 继续运行其他 goroutine"] H --> I["epoll_wait 返回:fd 可读"] I --> J["netpoll() 获取就绪的 goroutine 列表"] J --> K["goready() — 唤醒等待的 goroutine"] K --> L["goroutine 恢复执行"] L --> M["再次 syscall.Read()"] M --> N["这次有数据,返回"] style G fill:#FF9800,color:#fff style K fill:#4CAF50,color:#fff

关键代码路径#

// 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
}
}
}
src/internal/poll/fd_poll_runtime.go
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 来检查就绪事件:

src/runtime/proc.go
func schedule() {
// ... 调度逻辑
// 检查 netpoll
list := netpoll(0) // 非阻塞检查
if !list.empty() {
injectglist(&list) // 将就绪的 goroutine 注入调度队列
}
}

netpoll 被调用的时机:

  1. schedule():每次调度时检查
  2. sysmon():后台监控线程定期检查
  3. findRunnable():找不到可运行的 G 时检查
  4. GC 的 stopTheWorld 之后:恢复前检查

pollDesc 的状态机#

pollDescrg(读等待)和 wg(写等待)字段有精确的状态语义,它们不只是”有无 goroutine 等待”的布尔值,而是一个小型状态机:

状态值含义
0没有 goroutine 等待,也没有就绪事件
pdReady(1)有就绪事件,但没有 goroutine 在等。下一个 WaitRead 会立即返回
pdWait(2)有 goroutine 正在等待,但还未完成注册
*g(>2)指向等待中的 goroutine 结构体
stateDiagram-v2 [*] --> "0 (空闲)" "0 (空闲)" --> "pdWait" : goroutine 调用 WaitRead\n设置 gpp=pdWait "pdWait" --> "0" : gopark 完成\nnetpollblockcommit 将 gpp 设为 *g "0" --> "pdReady" : epoll 通知 fd 就绪\n但无 goroutine 等待 "pdReady" --> "0" : goroutine 调用 WaitRead\n发现已就绪,立即返回 "pdWait" --> "pdReady" : epoll 在 gopark 之前就通知\nWaitRead 立即返回

这个状态机的关键设计:pdReady 是”缓存”的就绪通知。如果 epoll 在 goroutine 调用 WaitRead 之前就通知了 fd 就绪,状态会被设为 pdReady,下一次 WaitRead 直接返回,不需要 gopark/epoll_wait 的完整循环。这避免了”通知丢失”问题。

源码:runtime/netpoll.gonetpollblocknetpollunblock 函数。

Deadline 与 Timer 的集成#

Go 的 net.Conn 支持 SetReadDeadline/SetWriteDeadline/SetDeadline,超时后 Read/Write 返回 os.ErrDeadlineExceeded。这个机制不只是应用层的定时器,而是与 netpoll 和 runtime timer 深度集成的。

超时实现链路#

flowchart TD A["conn.SetReadDeadline(t)"] --> B["FD.SetDeadline(t)"] B --> C["pollDesc.setDeadlineImpl()"] C --> D["计算 delta = t - now"] D --> E{"delta &gt; 0?"} E --> |"是"| F["modTimer(pd.rt, delta)\n 注册 runtime timer"] E --> |"否"| G["直接设为已过期"] F --> H["timer 到期回调"] H --> I["netpollDeadline(ctx)"] I --> J["netpollunblock(pd, 'r')\n 唤醒等待的 goroutine"] J --> K["goroutine 恢复执行\n 返回 ErrDeadlineExceeded"] style F fill:#FF9800,color:#fff style K fill:#F44336,color:#fff

超时通过 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.rtpollDesc 内嵌的 timer 结构体,不需要额外分配。deltimer + addtimer 的组合实现了 deadline 的更新。如果新的 deadline 已经过期(delta <= 0),直接调用 netpollunblock 唤醒等待者,不走 timer 路径。

源码:runtime/netpoll.gopoll_runtime_pollSetDeadlinenetpollDeadline 函数。

一个容易踩的坑:Deadline 是绝对时间#

// 错误:每次循环都设同样的 deadline
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
buf := make([]byte, 1024)
n, err := conn.Read(buf) // 第一次 Read 5s 后超时,后续可能立即超时
if err != nil {
break
}
}
// 正确:每次 Read 前刷新 deadline
for {
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 与调度器的集成#

graph TD subgraph "调度器" S1["schedule()"] S2["findRunnable()"] S3["sysmon()"] end subgraph "netpoll" N1["netpoll(0) — 非阻塞"] N2["epoll_wait(0)"] N3["返回就绪 goroutine 列表"] end subgraph "goroutine 唤醒" R1["injectglist()"] R2["goready()"] R3["放入本地或全局队列"] end S1 --> N1 S2 --> N1 S3 --> N1 N1 --> N2 N2 --> N3 N3 --> R1 R1 --> R2 R2 --> R3 style N2 fill:#4CAF50,color:#fff style R3 fill:#FF9800,color:#fff

conn.Read() 的完整底层流程#

  1. 用户代码:n, err := conn.Read(buf)
  2. net.Conn.Readnet.TCPConn.Readnet.conn.Read
  3. net.conn.Readnet/fd_posix.go: FD.Read
  4. FD.Readsyscall.Read(fd, buf) [非阻塞]
  5. syscall.Read 返回 EAGAIN(无数据)
  6. FD.ReadpollDesc.WaitRead()
  7. WaitReadruntime.netpollblock()
  8. netpollblockruntime.gopark() [挂起 goroutine]
  9. 调度器:M 切换到运行其他 goroutine
  10. [内核:数据到达,epoll 通知]
  11. sysmon/scheduleruntime.netpoll(0)
  12. netpollepoll_wait(0) → 返回就绪 fd
  13. netpollgoready(等待的 goroutine)
  14. goroutine 被放入运行队列
  15. goroutine 恢复执行
  16. 再次 syscall.Read(fd, buf) → 成功读取数据
  17. 返回数据给用户代码

性能特征与调优#

为什么 Go 网络性能好?#

特性Go 的实现好处
非阻塞 IO所有网络 fd 设为非阻塞不阻塞 M
epoll 集成netpoll 封装 epollO(1) 事件通知
goroutine 挂起gopark 挂起而非阻塞线程M 可运行其他 G
批量唤醒netpoll 返回就绪列表减少调度次数

调优参数#

// 设置 epoll 的最大等待时间
runtime.GOMAXPROCS(n) // 更多 P = 更多并发调度
// 使用 SO_REUSEPORT 允许多个 listener
lc := 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 fd

Q5:一个 goroutine 阻塞在网络 IO 上,最多等多久?#

取决于调度器调用 netpoll 的频率。sysmon 是不绑定 P 的独立线程,检查间隔会动态调整:空闲时从约 20μs 起步逐步增长,最长约 10ms;schedulefindRunnable 也会在调度循环中非阻塞调用 netpoll(0)。所以数据就绪后的唤醒延迟通常远小于 10ms,最坏情况不超过 sysmon 的最长间隔。

小结#

netpoll 的核心思路是把 epoll 事件与 Go 调度器绑定,epoll_wait 返回的就绪 fd 直接映射到挂起的 goroutine,通过 goready 唤醒后注入全局运行队列。整个链路 epoll_wait → goready → injectglist → schedule 环环相扣,网络 I/O 因此不再阻塞 M。

pollDesc 的状态机(0pdWait*gpdReady0)是一个容易被忽视的细节,它确保了 epoll 通知和 goroutine 等待之间的同步:如果在 goroutine 调用 WaitRead 之前 epoll 就通知了 fd 就绪,状态会被缓存为 pdReady,下一次 WaitRead 直接返回,不会丢失通知。Deadline 机制则通过 runtime timer 在超时时刻调用 netpollunblock 唤醒等待的 goroutine,不需要额外创建 goroutine 来做超时检查。

但有一个边界:文件 I/O 不走 netpoll,读写文件仍然会阻塞 M,这是 Go 运行时的一个已知限制,也是 aioio_uring 相关提案的推动力。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go netpoll:高并发网络的秘密武器
https://blog.souloss.cn/posts/golang/go-netpoll/
作者
Souloss
发布于
2023-08-21
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时