Go netpoll 本身不引发上下文切换
你看到的 goroutine 阻塞在 conn.Read(),不是线程(M)被挂起,而是 goroutine 被 gopark 挂起、让出 M 给其他 G 使用。此时 M 仍在运行——它立刻去执行别的 goroutine,不会触发 OS 级上下文切换。
真正触发上下文切换的,是当你手动创建大量线程(比如用 syscall.Clone)、或开了太多阻塞型系统调用(如未配对的 runtime.Entersyscall),又或者用了 CGO_ENABLED=1 且 C 函数长期阻塞。
- netpoll 的 epoll 实例由 runtime 统一管理,所有 fd 的就绪通知都通过 runtime.netpoll 批量唤醒 goroutine,唤醒后直接插入 P 的本地队列,调度开销极低
- 一个 M 可以轮询数万连接,只要这些连接的 goroutine 大部分处于 IO wait 状态,M 就几乎不切换——这是 Go 并发模型的核心优势
- 若 pprof 显示大量 goroutine 卡在 running 状态而非 IO wait,说明业务逻辑(比如死循环、长耗时计算、锁竞争)占着 M 不放,这才是上下文切换飙升的根源
手动调 epoll 会强制引入上下文切换
一旦绕过 runtime 直接调用 syscall.EpollWait,你就脱离了 netpoll 的调度闭环:goroutine 无法被自动 park/unpark,只能靠自己用 runtime.Entersyscall 进入系统调用态——这会让 M 脱离 P,触发一次完整的 OS 级上下文切换;等事件返回再 runtime.Exitsyscall,又要切回来。
- 每个 EpollWait 调用都是一次同步阻塞系统调用,M 在此期间无法复用,P 只能找空闲 M 或新建 M,容易导致 M 泛滥
- 多个 goroutine 同时调 EpollWait 到同一个 epoll fd,会触发内核惊群效应,所有 M 都被唤醒又立刻休眠,CPU 白耗
- 没配对 Entersyscall/Exitsyscall?M 卡死,P 饥饿,整个 GMP 调度器停滞,go tool trace 里能看到大量 ProcStatusGc 或 ProcStatusIdle 异常
对比 select/poll 为什么更伤上下文切换
Linux 下 select 和 poll 每次调用都要把整个 fd 集合从用户态拷贝到内核态,返回时再把就绪集合拷回;epoll 是一次性注册、增量更新,但它们共通的问题是:调用者必须自己维护事件循环线程——这个线程一旦阻塞在 select 或 poll 上,就只能干等,无法同时跑业务逻辑。
- select 返回后要遍历全部 fd 集合判断哪个就绪,O(n) 时间复杂度,fd 数一多,光扫描就吃掉不少 CPU,间接抬高上下文切换频率
- poll 虽不用重传集合,但仍是线性扫描,且没有边缘触发支持,容易重复通知,业务层处理稍慢就堆积唤醒
- 而 Go 的 netpoll 把“等待就绪”和“执行业务”揉进同一个 goroutine 生命周期里:Read → park → epoll_wait 唤醒 → Read 返回 → 继续执行,中间无跨线程跳转
真正该盯的指标不是 epoll_wait 耗时
别花时间看 strace -e epoll_wait 的平均延迟——那只是内核通知快慢,和你的吞吐无关。netpoll 的性能瓶颈从来不在 epoll 层,而在上层是否让 goroutine 快速进出 IO 等待态。
- 用 curl http://localhost:6060/debug/pprof/goroutine?debug=2 查状态为 IO wait 的 goroutine 数量;如果远小于并发连接数,说明很多 G 卡在 running,该查 CPU profile
- netstat -s | grep -i "listen" 看 ListenOverflows,溢出说明 accept 队列满,新连接被丢弃,这时要调 net.ListenConfig.Control 开 SO_REUSEPORT 或加大 net.core.somaxconn
- 检查 http.Server.ReadTimeout 是否设得太长,导致空闲连接长期占着 goroutine 和 fd,堆积后反而拖慢新连接建立
netpoll 的设计目标就是消灭不必要的上下文切换,但它无法保护你写错的业务逻辑。goroutine 卡住的真正原因,99% 都藏在你自己写的 for 循环、锁、或没设 deadline 的 Read 里。