编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • Go 专栏博客
      • 内存模型与栈堆布局
      • 指针与逃逸分析
      • 结构体内存布局对齐
      • 字符串与切片底层
      • 接口与类型系统
      • map哈希表底层实现
      • 零值初始化设计哲学
      • GMP协程调度器机制
      • 通道channel源码剖析
      • sync同步原语剖析
      • map并发安全与哈希
      • Go内存模型一致性
      • 加权信号量与限流
      • errgroup并行控制
      • 协程泄漏排查与修复
      • 并发设计模式详解
      • GC三色标记与屏障
      • 内存分配器深挖
      • defer延迟执行机制
      • 定时器四叉堆实现
      • 抢占式调度器原理
      • 协程栈扩容与缩容
      • 上下文取消与传播
      • 泛型与类型约束
      • 反射机制与unsafe
      • 迭代器与rangefunc
      • 错误处理与panic
      • 网络轮询器netpoller
        • 1. 案例引入
          • 1.1 一段崩在哪
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 netpoller 在 GMP 中的位置
          • 2.2 为什么同步代码 + 异步 IO
        • 3. epoll/kqueue/iocp 抽象层
          • 3.1 Linux epoll 的三步操作
          • 3.2 macOS kqueue 的等价实现
        • 4. 连接的完整生命周期
          • 4.1 Listen → Accept 流程
          • 4.2 Read 阻塞与 G 挂起
          • 4.3 epoll 事件触发与 G 唤醒
        • 5. netpoller 与 GMP 调度协作
          • 5.1 findrunnable 中的 netpoll(false)
          • 5.2 sysmon 的后台轮询
          • 5.3 唤醒 G 到可运行队列
        • 6. goroutine-per-connection 模型
          • 6.1 HTTP 长连接的 goroutine 池
          • 6.2 连接关闭的清理路径
        • 7. 百万连接的性能数字
          • 7.1 内存开销分解
          • 7.2 线程模型对比
        • 8. 平台差异与边界场景
          • 8.1 epoll 边缘触发 vs 水平触发
          • 8.2 文件 I/O 不走 netpoller
        • 9. 诊断与陷阱
          • 9.1 pprof 与 GODEBUG 诊断
          • 9.2 fd 泄漏与 netpoller 性能陷阱
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一次 Read 从阻塞到唤醒的全路径
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • HTTP服务端源码分析
      • JSON序列化与编解码
      • 数据库SQL连接池
      • 文件IO与零拷贝
      • 结构化日志与配置
      • 单元测试与基准
      • cgo与系统调用切换
      • 编译链接与PGO优化
      • 写作模板
    • 开发技巧

  • JavaScript入门

  • CodeX
  • Go入门到精通
  • 专栏博客
杨充
2026-05-23
目录

网络轮询器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. 案例引入
    • 1.1 一段崩在哪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 netpoller 在 GMP 中的位置
    • 2.2 为什么同步代码 + 异步 IO
  • 3. epoll/kqueue/iocp 抽象层
    • 3.1 Linux epoll 的三步操作
    • 3.2 macOS kqueue 的等价实现
  • 4. 连接的完整生命周期
    • 4.1 Listen → Accept 流程
    • 4.2 Read 阻塞与 G 挂起
    • 4.3 epoll 事件触发与 G 唤醒
  • 5. netpoller 与 GMP 调度协作
    • 5.1 findrunnable 中的 netpoll(false)
    • 5.2 sysmon 的后台轮询
    • 5.3 唤醒 G 到可运行队列
  • 6. goroutine-per-connection 模型
    • 6.1 HTTP 长连接的 goroutine 池
    • 6.2 连接关闭的清理路径
  • 7. 百万连接的性能数字
    • 7.1 内存开销分解
    • 7.2 线程模型对比
  • 8. 平台差异与边界场景
    • 8.1 epoll 边缘触发 vs 水平触发
    • 8.2 文件 I/O 不走 netpoller
  • 9. 诊断与陷阱
    • 9.1 pprof 与 GODEBUG 诊断
    • 9.2 fd 泄漏与 netpoller 性能陷阱
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次 Read 从阻塞到唤醒的全路径
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 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)
}
1
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 个连接的 Read goroutine 正常——卡在 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 在这里的角色是什么?—— 所有活跃连接的 Read goroutine 通过 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 章
1
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 泄漏
1
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 中
1
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)       │
            └──────────────────────────┘
1
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?

论证:

  1. 回调地狱 vs 线性控制流——Node.js 的 conn.on('data', callback) 把业务逻辑拆散到不同回调中。Go 的 for { conn.Read(buf); process(buf) } 让业务逻辑保持线性——可读性远超回调。goroutine 是 Go 的"廉价线程"——它让阻塞不成为问题(不像 OS 线程的 8MB 栈代价)。

  2. goroutine 让每个连接"看起来"独占一个线程——10 万连接 = 10 万 goroutine,但下面的 OS 线程只有 GOMAXPROCS 个(通常 ≤ CPU 核心数)。当一个 goroutine 阻塞在 Read 上时,它被挂起(gopark),不占用 OS 线程——P 可以立即切换到另一个 goroutine。

  3. epoll/kqueue 让 10 万连接的等待成本降到 O(1)——内核负责监视所有 fd,只在有数据到达时才通知 runtime。没有 epoll 的话——10 万连接的 select/poll 每次需要遍历 10 万个 fd——O(N) 成本。

  4. 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
}
1
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
}
1
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, ...)  → 等待事件
1
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
1
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 的等待)
1
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
}
1
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(不包括内核网络栈处理)
1
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)
    // ...
}
1
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)
            }
        }

        // ... 休眠 ...
    }
}
1
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)
}
1
2
3
4

goready 的放置策略:

goready(G):
  ├── 如果当前 P 的 runq 未满 → 放入当前 P 的本地 runq
  │       → G 很快被调度(不需要全局锁)
  │
  └── 如果当前 P 的 runq 已满 → 放入全局 sched.runq
          → G 需要等待某个 P 从全局队列中获取
1
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 循环
    }
}
1
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)
  └─────────────────────────────────────┘
1
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
}
1
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
1
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 倍)
1
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
//                                               ↑ 边缘触发
1
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
1
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 事件时间线
1
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 }
}
1
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 进一步被消耗
1
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 }
        }
    }
}
1
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 长度)
1
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 状态分布
1
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 路径。

上次更新: 2026/06/13, 21:14:36
错误处理与panic
HTTP服务端源码分析

← 错误处理与panic HTTP服务端源码分析→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式