网络轮询器netpoller
# 28.网络轮询器netpoller
卷三第 28 篇——Go 并发模型的"隐藏主角":netpoller。开发者写的是同步
conn.Read(buf),runtime 在背后通过 epoll/kqueue/iocp 做异步 I/O 多路复用。本篇从epoll_create/epoll_ctl/epoll_wait的底层调用出发,追踪一个 goroutine 从Read阻塞到gopark挂起、再到 epoll 事件触发后goready唤醒的完整链路,最后拆解 netpoller 与 GMP 调度器的协作机制——findrunnable中的netpoll(false)和sysmon中的后台轮询。关键词:epoll、kqueue、netpollblock、netpollgoready、findrunnable、sysmon、goroutine-per-connection。
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. epoll/kqueue/iocp 抽象层
- 4. 连接的完整生命周期
- 5. netpoller 与 GMP 调度协作
- 6. goroutine-per-connection 模型
- 7. 百万连接的性能数字
- 8. 平台差异与边界场景
- 9. 诊断与陷阱
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段崩在哪
某 WebSocket 推送网关——维护 10 万条长连接,每连接一个 goroutine 阻塞在 conn.Read() 上等待客户端消息。服务运行半年平稳,某天运维升级了内核的 fs.file-max,但没有同步调整 Go 进程的 ulimit -n。升级后数小时,Accept 返回 too many open files 错误——但 goroutine 数和内存使用都正常,没有 goroutine 泄漏,CPU 使用率也只有 30%。
// ws_gateway.go —— WebSocket 推送网关(精简版)
package main
import (
"net"
"net/http"
"time"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{}
func handleConnection(conn *websocket.Conn) {
defer conn.Close()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
// ❌ 连接断开时没有记录——fd 是否被释放了?
return
}
processMessage(msg)
}
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
go handleConnection(conn) // goroutine-per-connection
}
func main() {
http.HandleFunc("/ws", wsHandler)
http.ListenAndServe(":8080", nil)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
现象:
lsof -p PID | wc -l→ 1025——恰好是默认ulimit -n的数值(1024 + 少量 stdin/stdout/stderr)- 每个 WebSocket 连接 = 1 个 TCP socket fd + 1 个 goroutine
- 10 万连接 = 10 万个 fd——远超过 1024 的限制
Accept在netFD.accept→syscall.Accept返回EMFILE(too many open files)- 但正在运行的 1000 个连接的
Readgoroutine 正常——卡在 epoll 等待上,不是泄漏
更深层的问题:conn.Close() 在某些错误路径中被跳过了——有连接断开了但 fd 没有被释放。lsof 显示 200 个 fd 处于 CLOSE_WAIT 状态(客户端关闭了连接,服务器端没有关闭)——这些是僵尸 fd,占着 fd 槽位但不再服务任何连接。
# 1.2 顺藤摸到根因
追查:
假设 1:是不是 goroutine 泄漏导致 fd 无法释放?——
pprof goroutine显示 1000 个 goroutine,不是 10 万。正常的连接 goroutine 数 = 活跃连接数,没有泄漏。假设 2:为什么
CLOSE_WAIT的 fd 没被释放?—— 查代码:handleConnection的defer conn.Close()覆盖了正常路径,但在processMessage(msg)内部——有一个panic被全局的recover捕获(HTTP middleware 层),但是handleConnection的defer conn.Close()在这条 panic 路径上没有被执行(因为 panic 发生在不同的 goroutine 调用链上)。假设 3:为什么 fd 总数卡在 1024?——
ulimit -n的硬限制。每到达限制,Accept的netFD.accept返回EMFILE。HTTP server 的 Accept 循环收到错误后继续尝试 Accept——但每次尝试仍然失败(因为 fd 没有空闲槽位),形成一个"忙等"循环。假设 4:netpoller 在这里的角色是什么?—— 所有活跃连接的
Readgoroutine 通过 netpoller 挂起在 epoll 上。netpoller 不负责 fd 的创建和释放——但它是fd 的"等待器"。当 fd 泄漏时,netpoller 还在轮询这些僵尸 fd——浪费 CPU。
这个案例藏着至少 7 个原理点:
① epoll_create/epoll_ctl/epoll_wait 在 Go runtime 中如何被调用? → 第 3 章
② 一个连接从 Accept 到 Read 的全生命周期经历了哪些 netpoller 操作? → 第 4 章
③ Read 阻塞时 G 如何通过 gopark 挂起到 netpoller? → 第 4.2
④ epoll 事件触发后 G 如何被 goready 唤醒并加入运行队列? → 第 4.3
⑤ netpoller 与 GMP 调度器如何协作——findrunnable + sysmon? → 第 5 章
⑥ HTTP 长连接中 goroutine-per-connection 模型如何工作? → 第 6 章
⑦ Go 如何用少量 OS 线程支撑百万连接? → 第 7 章
2
3
4
5
6
7
# 1.3 我们要回答什么
这个 WebSocket 网关案例就是本篇的主线案例。我们从 epoll/kqueue 的底层系统调用出发,追踪一个连接的完整生命周期——从 Accept 到 Read 阻塞到 gopark 挂起,再到 epoll 事件触发后 goready 唤醒——最后展示 netpoller 如何在 findrunnable 和 sysmon 中与 GMP 调度器协作。
本篇路线:
epoll 抽象层 (第 3 章) ── 三步操作:create → ctl → wait
↓
连接生命周期 (第 4 章) ── Listen→Accept→Read→gopark→事件→goready
↓
GMP 协作 (第 5 章) ── findrunnable + sysmon 中的 netpoll
↓
连接模型 (第 6 章) ── goroutine-per-connection + HTTP 长连接
↓
性能数字 (第 7 章) ── 百万连接的内存和线程开销
↓
诊断实战 (第 9 章) ── pprof + GODEBUG + fd 泄漏
↓
综合案例 (第 10 章) ── 回到 WebSocket 网关,根治 fd 泄漏
2
3
4
5
6
7
8
9
10
11
12
13
📌 本篇定位:netpoller 是 Go 并发模型中最隐蔽但最关键的组件——它让"写同步代码、享受异步 I/O"成为现实。读完本篇,我们能回答:"
conn.Read(buf)阻塞时,当前 goroutine 的 G 结构体被挂到了哪里?epoll_wait 返回后,runtime 怎么知道要唤醒哪个 G?"
# 2. 架构概览
# 2.1 netpoller 在 GMP 中的位置
netpoller 是"夹在"goroutine 和 OS 内核之间的 I/O 代理:
用户代码: conn.Read(buf) ← 同步调用——开发者看到的是阻塞
│
▼
net 包: netFD.Read(buf) ← Go 封装: 检查 fd 状态 + 调用 runtime
│
▼
runtime: netpollblock(fd) ← G 挂起: 将 G 放入 netpoller 等待队列
│ │
│ findrunnable: netpoll(false)
│ │
│ sysmon: netpoll(0)
│ │
▼ ▼
OS 内核: epoll_wait(fds) ← 阻塞等待事件(在 sysmon/os 线程中)
│
▼
数据到达 → 内核通知 epoll
│
▼
runtime: netpollgoready() ← G 唤醒: 将 G 放回 P 的 runq
│
▼
用户代码: Read 返回 ← 用户代码醒来——数据已在 buf 中
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
核心数据流:
┌──────────┐
│ 用户代码 │ conn.Read(buf) → 写同步代码
└────┬─────┘
│ 网络 fd 就绪 ────────────── 返回数据
┌────▼──────────────────────────────┐
│ net 包(netFD) │
└────┬─────────────────┬────────────┘
│ 挂起 G │ 唤醒 G
┌────▼────┐ ┌─────▼──────┐
│ gopark │ │ goready │
└────┬────┘ └─────▲──────┘
│ │
┌────▼──────────────────┴──┐
│ runtime netpoller │
│ pollDesc, netpollblock │
└────┬─────────────────▲───┘
│ epoll_ctl │ epoll_wait 返回
┌────▼─────────────────┴───┐
│ OS 内核 (epoll) │
└──────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 2.2 为什么同步代码 + 异步 IO
疑惑:Node.js 用回调/async-await 做异步 I/O——Go 为什么反其道而行之,用同步代码+异步 I/O?
论证:
回调地狱 vs 线性控制流——Node.js 的
conn.on('data', callback)把业务逻辑拆散到不同回调中。Go 的for { conn.Read(buf); process(buf) }让业务逻辑保持线性——可读性远超回调。goroutine 是 Go 的"廉价线程"——它让阻塞不成为问题(不像 OS 线程的 8MB 栈代价)。goroutine 让每个连接"看起来"独占一个线程——10 万连接 = 10 万 goroutine,但下面的 OS 线程只有 GOMAXPROCS 个(通常 ≤ CPU 核心数)。当一个 goroutine 阻塞在 Read 上时,它被挂起(gopark),不占用 OS 线程——P 可以立即切换到另一个 goroutine。
epoll/kqueue 让 10 万连接的等待成本降到 O(1)——内核负责监视所有 fd,只在有数据到达时才通知 runtime。没有 epoll 的话——10 万连接的
select/poll每次需要遍历 10 万个 fd——O(N) 成本。netpoller 是"幕后英雄"——开发者不需要知道 epoll 的存在。
conn.Read(buf)看起来是阻塞调用——但阻塞的不是 OS 线程,而是 goroutine。内部是gopark→ netpoller → epoll → 事件 →goready。
结论:Go 的"同步代码 + 异步 I/O"不是魔法——它是goroutine 廉价挂起/唤醒 + epoll 高效事件通知的组合。同步代码是面纱,epoll 是引擎,goroutine 是桥梁。
# 3. epoll/kqueue/iocp 抽象层
# 3.1 Linux epoll 的三步操作
Go runtime 对 epoll 的封装在 runtime/netpoll_epoll.go 中——三步核心操作:
// runtime/netpoll_epoll.go (概念模型)
// 1. 创建 epoll 实例——全局只有一个
var epfd int32 = -1 // epoll 文件描述符池中的 fd
func netpollinit() {
epfd = epollcreate1(_EPOLL_CLOEXEC) // 系统调用 epoll_create1
if epfd < 0 {
throw("runtime: netpollinit failed")
}
}
// 2. 注册/修改 fd 到 epoll——每个网络 fd 调用一次
func netpollopen(fd uintptr, pd *pollDesc) int32 {
var ev epollevent
ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET // 边缘触发
// ↑ ET 模式——只通知一次状态变化
*(**pollDesc)(unsafe.Pointer(&ev.data)) = pd // 把 pollDesc 挂到事件上
return -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)
}
// 3. 等待事件——被 findrunnable 或 sysmon 调用
func netpoll(delay int64) gList {
if epfd == -1 { return gList{} }
var events [128]epollevent
retry:
n := epollwait(epfd, &events[0], int32(len(events)), int32(delay))
// ↑ delay < 0 → 无限等待
// ↑ delay = 0 → 非阻塞
// ↑ delay > 0 → 等待 delay ms
if n < 0 {
goto retry
}
var toRun gList
for i := int32(0); i < n; i++ {
ev := &events[i]
// 从 epoll 事件中取出 pollDesc → 取出等待的 G
pd := *(**pollDesc)(unsafe.Pointer(&ev.data))
// 把 G 加入待运行列表
netpollgoready(pd) // → 将 G 放回 P 的本地运行队列
}
return toRun
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
pollDesc——连接 G 和 fd 的桥梁(runtime/netpoll.go):
type pollDesc struct {
link *pollDesc // 链表——多个 pollDesc 链接在一起
fd uintptr // 对应的文件描述符
// 读写等待队列——各绑一个 G
rg atomic.Uintptr // 等待 Read 的 G 指针
wg atomic.Uintptr // 等待 Write 的 G 指针
closing bool
user uint32
rseq uintptr
rtimer timer
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 3.2 macOS kqueue 的等价实现
runtime/netpoll_kqueue.go——与 epoll 语义等价,API 不同:
// kqueue 三步:
// 1. kqueue() → 创建 kqueue 实例
// 2. kevent(kq, &changes, ...) → 注册 fd 事件(EV_ADD)
// 3. kevent(kq, nil, &events, ...) → 等待事件
2
3
4
平台适配表:
| 平台 | 系统调用 | 源文件 |
|---|---|---|
| Linux | epoll_create1 / epoll_ctl / epoll_wait | runtime/netpoll_epoll.go |
| macOS/BSD | kqueue / kevent | runtime/netpoll_kqueue.go |
| Windows | CreateIoCompletionPort / GetQueuedCompletionStatus | runtime/netpoll_windows.go |
# 4. 连接的完整生命周期
# 4.1 Listen → Accept 流程
1. net.Listen("tcp", ":8080")
→ syscall.Socket → syscall.Bind → syscall.Listen
→ 创建 netFD{fd: 3, ...}
→ netpollopen(fd=3, pd) ← 把监听 fd 注册到 epoll
2. listener.Accept()
→ netFD.accept()
→ syscall.Accept(3) ← 从监听队列获取新连接
→ 如果队列空 → EAGAIN → netpollblock(pd, 'r')
│
▼
gopark → G 挂起到 netpoller
3. 新连接到达
→ epoll_wait 返回监听 fd 的读事件
→ netpollgoready → goready(G) → G 重新可运行
→ Accept 返回 connFD{fd: 4, ...}
→ netpollopen(fd=4, pd) ← 把新连接 fd 注册到 epoll
→ go handleConn(conn) ← 为每个连接启动一个 goroutine
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 4.2 Read 阻塞与 G 挂起
conn.Read(buf) 的底层链路——以连接 fd=4 为例:
用户代码: conn.Read(buf)
│
▼
netFD.Read(buf):
→ syscall.Read(fd=4, buf)
→ 返回 EAGAIN(数据未就绪——非阻塞模式)
→ 进入 netpoller 等待:
netpollblock(pd, 'r'):
│
├── 1. 把当前 G 存入 pd.rg(pollDesc 的读等待队列)
│ pd.rg.Store(G)
│
├── 2. gopark(netpollblockcommit, ...)
│ → 当前 G 的状态: _Grunning → _Gwaiting
│ → 当前 G 被移出 P 的运行队列
│ → P 从 runq 中取下一个 G 执行
│ → **OS 线程没有阻塞——它在执行另一个 G**
│
└── 3. 当前 G 被挂起——等待 epoll 事件唤醒
此时状态:
G4872: _Gwaiting ← 挂在 pd.rg 上,等待 fd=4 可读
OS 线程 M: 执行另一个 G(完全不感知 G4872 的等待)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
关键汇编路径(runtime/netpoll.go 中的 netpollblock):
// runtime/netpoll.go (简化)
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
// 1. 记录哪个 G 在等待
old := pd.rg.Load()
if !pd.rg.CompareAndSwap(old, getg()) {
// 已有其他 G 在等待——竞争
throw("runtime: double wait")
}
// 2. 挂起当前 G
gopark(netpollblockcommit, unsafe.Pointer(pd), waitReasonIOWait, traceEvGoBlockNet, 0)
// ↑ G 的状态: _Gwaiting, 原因: IOWait
// 3. 被唤醒后——清理状态
pd.rg.Store(nil) // 清除等待记录
return true
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 4.3 epoll 事件触发与 G 唤醒
当对端发送数据到 fd=4 时——从内核到 goroutine 的唤醒链路:
1. 内核: TCP 数据到达 fd=4
→ 内核标记 fd=4 可读
→ epoll 检测到 fd=4 的 EPOLLIN 事件
2. findrunnable 中的 netpoll(false):
→ epoll_wait(epfd, events, 128, 0) ← 非阻塞模式
→ 返回 n=1: events[0] = {fd=4, events=EPOLLIN, data=&pd}
3. 从 epoll 事件的 data 字段取出 pollDesc:
→ pd = events[0].data
→ 读取 pd.rg 获取等待读的 G (G4872)
4. netpollgoready(pd):
→ goready(G4872, ...)
→ G4872 状态: _Gwaiting → _Grunnable
→ 将 G4872 放入当前 P 的本地运行队列(runq)
→ P 在下次调度时选择 G4872 执行
5. G4872 恢复执行:
→ 从 gopark 返回(第 4.2 中的步骤 3 之后)
→ netpollblock 返回 true
→ netFD.Read 重新调用 syscall.Read → 这次成功——数据可用!
→ conn.Read(buf) 返回 → 用户代码继续
总耗时: ~5-20μs(不包括内核网络栈处理)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
关键——唤醒不是"立即"发生的:G4872 被放回 P 的 runq——它需要等待这个 P 上当前正在执行的 G 主动让出 CPU(通过 Gosched()、阻塞或时间片用尽),P 才会从 runq 中取出 G4872 执行。
# 5. netpoller 与 GMP 调度协作
# 5.1 findrunnable 中的 netpoll(false)
findrunnable 是 Go 调度器的"找下一个可执行的 G"函数——它在 P 的 runq 为空时被调用。此时调用 netpoll(false) 检查是否有 I/O 就绪的 G:
// runtime/proc.go (简化)
func findrunnable() (gp *g, inheritTime bool) {
top:
// 1. 检查本地 runq
if gp := runqget(_p_); gp != nil {
return gp, false
}
// 2. 检查全局 runq
if sched.runqsize != 0 {
// ...
}
// 3. ★ netpoll 检查——I/O 就绪的 G
if netpollinited() && lastpoll != 0 {
if list := netpoll(false); !list.empty() {
// ↑ 非阻塞模式——epoll_wait 不等待
// 只检查当前是否有已就绪的事件
gp := list.pop()
// 将其余 G 放回全局 runq
injectglist(&list)
return gp, true
}
}
// 4. 从其他 P 偷取 (work stealing)
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
netpoll(false) 的非阻塞语义——epoll_wait(timeout=0):立即返回,不等待。用在调度路径上——"如果有 I/O 就绪的 G,顺便捡起来执行"。
# 5.2 sysmon 的后台轮询
sysmon 是 Go runtime 的后台监控线程——它在一个专用 OS 线程上运行,周期性调用 netpoll(0):
// runtime/proc.go (简化)
func sysmon() {
for {
// ... 抢占检查、GC 触发 ...
// 周期性调用 netpoll
lastpoll := sched.lastpoll
if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
// ↑ 距离上次 netpoll 超过 10ms
list := netpoll(0) // ← 非阻塞——epoll_wait(timeout=0)
if !list.empty() {
// I/O 就绪的 G → 注入全局 runq
injectglist(&list)
}
}
// ... 休眠 ...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
为什么需要 sysmon 轮询:
- 如果所有 P 都在执行长时间 CPU 密集型 G(没有机会调用
findrunnable)——netpoll不会被触发。 - 此时即使有 I/O 事件就绪,对应的 G 也不会被唤醒。
- sysmon 填补这个真空——它确保最多 10ms 内一定有人检查一次 netpoller。
# 5.3 唤醒 G 到可运行队列
netpollgoready 将 G 从 _Gwaiting 转为 _Grunnable 并放入运行队列:
// runtime/netpoll.go (简化)
func netpollgoready(gp *g) {
goready(gp, 0)
}
2
3
4
goready 的放置策略:
goready(G):
├── 如果当前 P 的 runq 未满 → 放入当前 P 的本地 runq
│ → G 很快被调度(不需要全局锁)
│
└── 如果当前 P 的 runq 已满 → 放入全局 sched.runq
→ G 需要等待某个 P 从全局队列中获取
2
3
4
5
6
这个策略保证 I/O 就绪的 G 能快速地重新获得 CPU 时间。
# 6. goroutine-per-connection 模型
# 6.1 HTTP 长连接的 goroutine 池
Go 的 HTTP server 为每个连接分配一个 goroutine——它不是"池",是"天然廉价":
// net/http/server.go (概念模型)
func (srv *Server) Serve(l net.Listener) error {
for {
conn, err := l.Accept()
if err != nil { continue }
// 每个连接一个 goroutine
go srv.serveConn(conn) // ← 处理 HTTP keep-alive 循环
}
}
2
3
4
5
6
7
8
9
10
HTTP keep-alive 循环——一个 goroutine 处理同一个连接上的多个请求:
serveConn goroutine 的生命周期:
┌─────────────────────────────────────┐
│ for { │
│ req := readRequest(conn) │ ← 如果无数据 → gopark → netpoller
│ handler.ServeHTTP(w, req) │ ← goroutine 被 epoll 事件唤醒
│ if !keepAlive { break } │
│ conn.SetReadDeadline(...) │ ← 设置空闲超时
│ } │
│ conn.Close() │ ← 关闭连接 → netpollclose(fd)
└─────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
100 个活跃请求:100 个 goroutine 在执行 handler(_Grunning) 10000 个空闲连接:10000 个 goroutine 挂起在 netpoller 上(_Gwaiting,不占 OS 线程)
# 6.2 连接关闭的清理路径
连接关闭时,必须从 netpoller 中注销 fd:
// net/net_fd.go (概念模型)
func (fd *netFD) Close() error {
// 1. 从 epoll 中移除
runtime_pollClose(fd.pd.runtimeCtx)
// → epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nil)
// 2. 关闭底层 fd
syscall.Close(fd.sysfd)
// 3. 唤醒正在等待这个 fd 的 G
runtime_pollUnblock(fd.pd.runtimeCtx)
// → pd.rg → goready → 返回错误 net.ErrClosed
}
2
3
4
5
6
7
8
9
10
11
12
13
如果忘记 Close()——fd 永远在 epoll 集合中,netpoller 持续轮询它——CPU 浪费 + fd 槽位泄漏。
# 7. 百万连接的性能数字
# 7.1 内存开销分解
100 万 WebSocket 连接的资源消耗(基于 Go 1.22 64位 Linux):
每个连接的开销:
goroutine 栈: 2KB (起始)
netFD + pollDesc: ~200B
TCP socket buffer: ~4KB (kernel) + ~4KB (kernel) = 8KB (内核)
goroutine 元数据: ~400B (G 结构体)
应用层缓冲区: ~4KB (读) + ~4KB (写) = 8KB (应用)
─────────────────────────────────────────────
每个连接总内存: ~18KB
100 万连接总内存:
用户态: ~10GB (goroutine 栈 + 结构体 + 应用缓冲区)
内核态: ~8GB (TCP socket buffer)
─────────────────────────────────────────────
总计: ~18GB
2
3
4
5
6
7
8
9
10
11
12
13
14
关键——100 万 goroutine × 2KB = 2GB(起始栈大小),随着一些 goroutine 执行 handler 处理,栈会扩容——但大部分空闲连接的 goroutine 保持在 2KB 栈。
# 7.2 线程模型对比
传统线程模型 (C10K 之前的 Java/C++):
10 万连接 → 10 万个 OS 线程
→ 线程栈: 10万 × 8MB = 800GB(不可行)
→ 上下文切换: 10万 × 频率 → CPU 耗尽
epoll + 线程池 (Nginx/Node.js):
10 万连接 → epoll 监视 → 少量工作线程处理
→ 线程数: CPU 核心数(8-16 个)
→ 每个请求处理的代码被拆成回调
Go goroutine + netpoller:
10 万连接 → 10 万 goroutine → epoll → GOMAXPROCS 个 OS 线程
→ OS 线程数: ~CPU 核心数
→ 代码: 同步风格——一个连接一个 for 循环
→ goroutine 挂起和唤醒: ~50-100ns(比 OS 线程上下文切换快 100 倍)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Go 的百万连接配方:
- 配方 1: 每个连接一个 goroutine(2KB 起步)
- 配方 2: epoll 负责 I/O 多路复用(O(1) 事件等待)
- 配方 3: GMP 调度器让阻塞的 G 不占 OS 线程
- 结果: 100 万连接只需要 ~10 个 OS 线程和 ~10GB 内存
# 8. 平台差异与边界场景
# 8.1 epoll 边缘触发 vs 水平触发
Go 使用 EPOLLET(边缘触发) 模式——runtime/netpoll_epoll.go 中注册 fd 时:
ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET
// ↑ 边缘触发
2
边缘触发的含义:epoll 只在 fd 状态变化时通知一次。如果 Read 没有一次性读完所有数据——不会再有第二次通知。Go 的 netFD.Read 在内部循环调用 syscall.Read 直到返回 EAGAIN——确保每次通知后彻底读完。
水平触发 vs 边缘触发:
| 模式 | 通知行为 | 优势 | 劣势 |
|---|---|---|---|
| 水平触发 (LT) | 只要 fd 可读就持续通知 | 简单——不会被"忘记"的数据阻塞 | 每次 epoll_wait 都返回——CPU 浪费 |
| 边缘触发 (ET) | 只在状态变化时通知一次 | 高效——无冗余通知 | 必须一次性读完——否则丢失数据 |
# 8.2 文件 I/O 不走 netpoller
关键区分:netpoller 只处理网络 I/O(socket fd)。普通文件 I/O(磁盘读写)不经过 netpoller——而是通过 Go 的 syscall 路径直接阻塞:
// 网络 I/O → 走 netpoller
conn.Read(buf) → EAGAIN → gopark → netpoller
// 文件 I/O → 不走 netpoller
file.Read(buf) → syscall.Read → 阻塞 OS 线程 → 不能 gopark
2
3
4
5
文件 I/O 阻塞 OS 线程的处理——Go runtime 会创建一个新的 OS 线程(如果需要)来替换被阻塞的线程,保证 GOMAXPROCS 个活跃的 P 一直在运行。
# 9. 诊断与陷阱
# 9.1 pprof 与 GODEBUG 诊断
# 1. goroutine profile——看有多少 G 在 I/O 等待
go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top
flat flat% sum% cum cum%
9800 98.00% 98.00% 9800 98.00% runtime.gopark
(pprof) list gopark
# → 看 waitReason: IOWait → 这些 G 挂在 netpoller 上
# 2. 看具体卡在哪
curl http://localhost:6060/debug/pprof/goroutine?debug=2 | grep -B3 "IO wait"
goroutine 4872 [IO wait]:
main.handleConnection.func1()
/app/ws_gateway.go:20 ← conn.ReadMessage()
# 3. fd 泄漏诊断
lsof -p PID | wc -l # 当前 fd 数量
lsof -p PID | grep CLOSE_WAIT # 僵尸连接
ls /proc/PID/fd | wc -l # 另一种方式
# 4. GODEBUG——netpoller 事件追踪 (Go 1.21+)
GODEBUG=netdns=1 ./app
# 5. trace——看 goroutine 的阻塞/唤醒模式
curl http://localhost:6060/debug/pprof/trace?seconds=10 > trace.out
go tool trace trace.out # 在浏览器中看 netpoller 事件时间线
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 9.2 fd 泄漏与 netpoller 性能陷阱
陷阱 1:未关闭的连接
// ❌ 错误路径没关连接
func handle(conn net.Conn) {
data, err := readData(conn)
if err != nil {
log.Println(err)
return // ← 忘记 conn.Close()——fd 泄漏
}
conn.Close()
}
// ✅ 总是 defer Close
func handle(conn net.Conn) {
defer conn.Close()
data, err := readData(conn)
if err != nil { return }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
陷阱 2:epoll 事件积压——P 太少
如果 GOMAXPROCS=1(单核),10000 个连接中 1000 个同时有数据到达——epoll_wait 返回 1000 个就绪事件——但只有 1 个 P 在处理,其他 999 个 G 在 runq 中排队。这导致部分连接的响应延迟不可预测。
陷阱 3:sysmon 的 10ms 延迟
在纯 CPU 密集型工作负载下(所有 P 持续执行 G,没有阻塞),sysmon 每 10ms 才调用一次 netpoll(0)——这意味着 I/O 就绪的 G 可能被延迟最多 10ms 才被唤醒。
陷阱 4:Accept 循环的忙等
当 fd 耗尽时,Accept 返回 EMFILE——HTTP server 的 Accept 循环继续尝试,每次都失败。修复:使用 net.Listener 的 SetDeadline 或临时暂停 Accept。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章 WebSocket 推送网关的七个疑问,逐条作答:
| 疑问 | 答案 |
|---|---|
| ① epoll 三步操作在 Go runtime 中如何调用? | 第 3.1:epollcreate1 → epollctl → epollwait(runtime/netpoll_epoll.go) |
| ② Accept→Read 的全生命周期? | 第 4 章:Accept 用 netpoll 等连接,Read 用 netpoll 等数据 |
| ③ Read 阻塞时 G 如何挂起? | 第 4.2:netpollblock → gopark → G 的 _Gwaiting 状态 |
| ④ epoll 事件如何唤醒 G? | 第 4.3:netpollgoready → goready → G 放入 P 的 runq |
| ⑤ netpoller 与 GMP 如何协作? | 第 5 章:findrunnable 中的 netpoll(false) + sysmon 的后台轮询 |
| ⑥ goroutine-per-connection 模型? | 第 6 章:每连接一个 goroutine——空闲时 gopark,活跃时 2KB+ 栈 |
| ⑦ 百万连接怎么做到? | 第 7 章:goroutine(2KB) + epoll(O(1)) + GMP(少量线程) ≈ ~10 个 OS 线程 |
案例根因链条:
WebSocket 推送网关 — 10 万长连接
→ ulimit -n=1024 ← 系统限制
→ Accept 返回 EMFILE—fd 耗尽
→ 220 个 CLOSE_WAIT 僵尸 fd 占着槽位
→ 根因: processMessage 中 panic → 全局 recover 捕获
→ handleConnection 的 defer conn.Close() 不执行
→ fd 泄漏 → epoll 中这些 fd 仍然被轮询 → CPU 浪费
→ Accept 循环忙等 → CPU 进一步被消耗
2
3
4
5
6
7
8
修复方案:
// 方案 A:goroutine 级别 recover(治本)
func handleConnection(conn *websocket.Conn) {
defer conn.Close()
defer func() {
if r := recover(); r != nil {
log.Printf("connection panic: %v", r)
}
}()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return // defer conn.Close() 执行
}
processMessage(msg) // 如果这里 panic → 被 defer recover 捕获
}
}
// 方案 B:监控 fd 数量 + 动态调整 ulimit
func main() {
var rLimit syscall.Rlimit
syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)
rLimit.Cur = 1000000 // 设置为 100 万
syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit)
// ...
}
// 方案 C:Accept 错误退避
func acceptWithBackoff(l net.Listener) net.Conn {
backoff := time.Millisecond
for {
conn, err := l.Accept()
if err == nil { return conn }
if isTemporary(err) {
time.Sleep(backoff)
backoff *= 2
if backoff > time.Second { backoff = time.Second }
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 10.2 一次 Read 从阻塞到唤醒的全路径
以 WebSocket 网关中一个 goroutine 的 conn.ReadMessage() 为例:
G4872: conn.ReadMessage()
────────────────────────────────────────────────
1. 用户代码: conn.ReadMessage()
→ websocket.Conn.ReadMessage()
→ net.Conn.Read(buf)
→ netFD.Read(buf)
→ syscall.Read(fd=42, buf) → 返回 EAGAIN
2. netFD 检测 EAGAIN → 进入 netpoller 等待:
→ pollDesc{fd:42, rg: G4872}
→ netpollblock(pd, 'r')
→ pd.rg = G4872 (CAS 写入)
→ gopark(netpollblockcommit, pd, waitReasonIOWait)
→ G4872 状态: _Grunning → _Gwaiting
→ P 切换到下一个 G
此时:
G4872: _Gwaiting, pd.rg=G4872, fd=42 无数据
OS 线程 M: 运行另一个 G
3. 对端发送 WebSocket 帧到 fd=42:
→ 内核: TCP 数据到达 → 标记 fd=42 可读
→ epoll: 记录 EPOLLIN 事件
4. findrunnable 中的 netpoll(false) 或 sysmon 的 netpoll(0):
→ epoll_wait(epfd, events, 128, 0)
→ 返回 events[0] = {fd=42, data=&pd}
→ 从 pd 中取出 pd.rg = G4872
→ netpollgoready(G4872)
→ goready(G4872) → 加入 P 的 runq
5. G4872 在 runq 中等待——
当前 P 正在执行的 G 调用 runtime.Gosched() 或阻塞
→ P 调度: 从 runq 取出 G4872
6. G4872 从 gopark 返回:
→ netpollblock 返回 true
→ netFD.Read 重新 syscall.Read(fd=42, buf)
→ 这次成功——读取 WebSocket 帧数据
→ conn.Read(buf) 返回 n=128
→ conn.ReadMessage() 返回 msg
总耗时: ~5-20μs (从数据到达内核到 G 开始执行)
加调度延迟: ~20-200μs (取决于 P 的 runq 长度)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 10.3 设计哲学回扣
哲学 1:用廉价的 goroutine 掩盖异步复杂性
Go 的同步 I/O 是"假象"——真正的工作由 epoll 在幕后完成。但这层抽象的价值不可估量:开发者不需要接触回调、Promise、async/await——一个 for { conn.Read(buf); process(buf) } 的线性控制流涵盖了 10 万并发连接。goroutine(2KB 起步)让每个连接"拥有"自己的调用栈成为可能——这在 OS 线程时代是不可想象的。
哲学 2:让运行时接管 I/O 调度——而非应用层
Node.js 让应用层通过 event loop 管理异步 I/O——应用代码负责注册回调和调度。Go 把这个责任移到了 runtime:gopark 和 goready 是运行时原语,findrunnable 和 sysmon 控制何时检查 netpoller。应用代码不需要知道 epoll 的存在——这是"接口上移,实现下沉"的经典案例。
哲学 3:用边缘触发换取零冗余通知
Go 选择 epoll 的边缘触发(ET)模式——它牺牲了"自动重复通知"的便利,换取了"一次通知,零冗余"的效率。这要求 netFD.Read 循环读取直到 EAGAIN——但换来的是 epoll_wait 不再为同一个可读 fd 重复返回。这是 Go 的性能偏执——愿意在内部多写一个循环来换取 CPU 效率。
哲学 4:分层解耦——netpoller 不感知协议
netpoller 只理解"fd 可读/可写"——它不知道 TCP、UDP、HTTP、WebSocket 的区别。这使得 netpoller 的核心代码极简(几百行),而协议解析被完全隔离在上层(net、net/http、gorilla/websocket)。职责分离让每个组件都可以独立优化。
# 10.4 速查表
netpoller 核心流程:
| 步骤 | 操作 | G 的状态变化 |
|---|---|---|
| 连接建立 | Accept + netpollopen | 新 G _Grunnable |
| Read 阻塞 | syscall.Read → EAGAIN → netpollblock → gopark | _Grunning → _Gwaiting |
| 数据到达 | epoll_wait 返回 → netpollgoready → goready | _Gwaiting → _Grunnable |
| 连接关闭 | netpollclose → epoll_ctl(DEL) → goready(被阻塞的 G) | 返回错误 |
netpoll 调用点:
| 调用点 | 函数 | timeout | 作用 |
|---|---|---|---|
findrunnable | netpoll(false) | 0 | 调度时顺便检查 I/O 就绪 |
sysmon | netpoll(0) | 0 | 后台轮询——填补调度真空期 |
| ~Go 1.20 之前 | netpoll(delay) | <0 | 专用 netpoll 线程——已被移除 |
百万连接开销:
| 资源 | 每连接 | 100 万连接 |
|---|---|---|
| goroutine 栈 | 2KB | 2GB |
| G 结构体 | ~400B | 400MB |
| pollDesc | ~100B | 100MB |
| TCP buffer (内核) | ~8KB | 8GB |
| OS 线程 | — | ~10 个 |
诊断命令:
# goroutine profile——看 I/O 等待
go tool pprof http://localhost:6060/debug/pprof/goroutine
curl localhost:6060/debug/pprof/goroutine?debug=2 | grep "IO wait"
# fd 泄漏
lsof -p PID | wc -l
lsof -p PID | grep CLOSE_WAIT
ls /proc/PID/fd | wc -l
# epoll 状态
ls -la /proc/PID/fd | grep "\[eventpoll\]"
# trace——看 netpoller 事件时间线
curl http://localhost:6060/debug/pprof/trace?seconds=10 > trace.out
go tool trace trace.out
# 操作系统限制
ulimit -n # 当前 fd 上限
cat /proc/sys/fs/file-max # 系统全局 fd 上限
# GODEBUG
GODEBUG=schedtrace=1000 ./app # 看调度器——G 状态分布
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
下一篇:我们已经掌握了 netpoller 如何让同步 I/O 代码被异步执行——goroutine 的廉价挂起/唤醒是核心。下一步进入 29.syscall 路径与系统调用——看看 Go 如何封装底层系统调用、entersyscall/exitsyscall 如何锁定/释放 P、以及为什么文件 I/O 不走 netpoller 而走 syscall 路径。