netpoller由sysmon协程自适应驱动:初始20微秒间隔调用epoll_wait,无事件则逐步拉长至10毫秒,有活跃连接即收缩;sysmon仅获取就绪fd并派生goroutine,不处理数据。

netpoller 是怎么被驱动的
Go 的异步 IO 不靠轮询或信号,而是由 sysmon 协程定期唤醒 netpoller 检查就绪事件。这个过程不是“每毫秒扫一遍”,而是自适应的:初始间隔约 20 微秒,若无事件则逐步拉长到 10 毫秒;一旦有活跃连接,又会快速收缩回短间隔。
关键点在于:sysmon 并不直接处理网络数据,它只负责调用 netpoll 底层的 epoll_wait(Linux)或 kqueue(macOS),拿到就绪 fd 列表后,为每个就绪事件创建一个新 goroutine,并把它推入某个 P 的本地运行队列。
-
sysmon是 runtime 启动时自动创建的后台 goroutine,无法被用户代码控制或关闭 - 如果所有 P 都忙,新生成的 IO 响应 goroutine 可能被扔进全局队列,等待窃取
-
netpoller本身不持有缓冲区,数据读写仍由用户 goroutine 在恢复后调用read/write系统调用完成
goroutine 恢复执行时发生了什么
当一个发起 http.Get 的 goroutine 因 socket 未就绪而挂起,它不会被销毁,而是被标记为 Gwaiting 状态,其栈和寄存器上下文保存在堆上。等 sysmon 把对应事件转成新 goroutine 后,调度器会在下一个调度周期把原 goroutine 置为 Grunnable,并最终在某个 P 上恢复执行。
注意:恢复不是“接着系统调用返回”,而是从 netpoll 封装层(如 internal/poll.FD.Read)内部跳回用户代码——你看到的 resp, err := http.Get(...) 这一行,在底层其实跨越了至少两次用户态上下文切换。
- goroutine 恢复时,局部变量、defer 链、panic/recover 状态全部保持原样
- 如果挂起期间 GC 发生,该 goroutine 的栈可能被扫描或移动,但 runtime 保证指针重写正确
- 没有“协程栈切换开销”这种说法——Go 的 goroutine 栈是动态的,挂起/恢复不涉及栈拷贝,只保存 PC 和寄存器
为什么有时 IO 响应看起来“慢半拍”
常见现象:明明 epoll_wait 已返回就绪事件,但对应 goroutine 过了几十微秒才开始处理。这不是调度器卡顿,而是典型的负载失衡表现:
- 所有 P 的本地队列都满,新生成的响应 goroutine 被塞进全局队列,而全局队列只有当 P 本地队列空时才尝试偷取
- 某个 P 正在执行长时间运行的 CPU 密集型任务(比如大循环、正则匹配),导致无法及时调度新 goroutine
- 设置了
GOMAXPROCS过小(如 1),所有 IO 响应 goroutine 必须排队等待唯一一个 P
验证方式:用 go tool trace 查看 Proc Status 面板,观察各 P 的 runnable goroutine 数量是否严重不均;同时检查是否存在长时间未让出的 Grunning 状态。
epoll/kqueue 层面的细节不能忽略
Go 的 netpoller 默认使用边缘触发(ET)模式,且对每个 socket 自动设置 SOCK_NONBLOCK。这意味着:
- 一次
epoll_wait返回后,必须把该 fd 上所有可读数据一次性读完,否则下次不会再次通知(除非新数据到达) - Go 标准库的
conn.Read内部已做循环读取,但如果你绕过标准库直接调用syscall.Read,就可能漏掉数据 - 大量空闲连接(如 keep-alive)会持续占用
epoll监听列表,但sysmon的自适应机制会让它们几乎不触发实际epoll_wait调用
真正影响性能的不是 epoll 本身,而是你如何组织 goroutine 与连接生命周期——比如一个长连接上混用多个逻辑请求,却没用 channel 或 context 控制并发,就容易造成 goroutine 积压和响应延迟。











