--- tags: [go/lang, goroutine, gmp-scheduler, work-stealing, stack-dynamic] create time: 2026-08-08 19:00 update time: 2026-08-08 19:00 --- # Goroutine 调度模型 ## 概述 Go 的并发能力源自其内置的 goroutine 和 M:N 调度模型。与操作系统线程 1:1 映射不同,Go 使用 GMP 三方协作架构将数千个 goroutine 高效地复用到有限的 OS 线程上。理解 GMP 调度原理是排查性能瓶颈、避免 goroutine leak 的基石。 > [!NOTE] 为什么 Go 要设计自己的调度器? > 操作系统线程的创建和切换成本较高(通常几 MB 栈空间 + 内核态切换),而 goroutine 只需 ~2KB 初始栈且在内核态之外完成调度。这使得 Go 可以同时运行数百万个 goroutine 而不拖慢系统。 ## 核心原理 ### 生命周期状态 一个 goroutine 在生命周期中会经历以下状态转换: ```mermaid stateDiagram-v2 [*] -->idle: 创建后初始化 idle-->gwaiting: 阻塞中(等待IO/锁/channel) gwaiting-->gorunnable: 被唤醒放入 runqueue gorunnable-->grunning: 被 M 取出执行 grunning-->gidle: 正常返回退出 grunning-->gsyscall: 执行系统调用 gsyscall-->gorunnable: 系统调用返回 gsyscall-->gwaiting: 继续阻塞 state gwaiting { channel_wait: 等待channel读写 mutex_wait: 等待sync.Mutex timer_wait: 等待定时器触发 netpoll_wait: 等待网络事件 } style grunning fill:#c8e6c9 style gorunnable fill:#e3f2fd style gwaiting fill:#fff3e0 style gsyscall fill:#fce4ec ``` - **Gidling(空闲)**:刚创建或已退出的 goroutine,尚未进入可调度状态 - **Grunnable(就绪)**:在 global 或 local runqueue 中等待被调度 - **Grunning(运行)**:绑定了某个 P,正在执行代码 - **Gwaiting(等待)**:阻塞于某些外部事件(channel IO、mutex、timer) - **Gsyscall(系统调用)**:正在进行系统调用,此时不占用 CPU 但持有 P ### G / M / P 三者的关系 这是面试最高频的问题之一。三者的定义和关系如下: | 角色 | 全称 | 职责 | 类比 | |------|------|------|------| | **G** | Goroutine | 用户级协程,包含栈、指令指针、状态等 | 任务/工作单元 | | **M** | Machine Thread | 绑定到 OS 内核线程,真正执行代码 | CPU 核心 | | **P** | Processor | 调度的本地资源,拥有 local runqueue 和执行权限 | 调度上下文/沙盒 | ```mermaid graph TD subgraph "Global RunQueue
全局等待队列" GR["待调度的 goroutine"] end subgraph "P Pool
最多 1024 个 P" P1["P1
local queue"] P2["P2
local queue"] Pn["Pn..."] end subgraph "M Pool
OS Threads" M1["M1 (OSThread)"] M2["M2 (OSThread)"] end subgraph "G Stack" G1["g1 stack
~2KB start"] G2["g2 stack
~2KB start"] G3["g3 stack
~2KB start"] end GR -.->|"work stealing"| P1 P1 -->|"pop"| G1 P2 -->|"pop"| G2 Pn -->|"pop"| G3 M1 -->|"绑定"| P1 M1 -->|"执行"| G1 M2 -->|"绑定"| P2 M2 -->|"执行"| G2 ``` 关键约束: - **只有绑定了 P 的 M 才能执行 goroutine**。一个 M 必须拥有一个 P 才能运行 G。 - **P 的数量由 `GOMAXPROCS` 决定**,默认是当前机器的逻辑 CPU 数量,最大值为 1024。 - **M 可以多于 P**:当 M 在系统调用中被阻塞时,runtime 会创建新的 M 来获取新的 P 继续工作。 ### Local RunQueue 与 Global RunQueue 每个 P 维护一个长度为 256 的 local runqueue。调度器的工作循环(schedloop): 1. 从自身 local runqueue 中取一个 goroutine 执行 2. 如果 local queue 为空,尝试偷取其他 P 的 queue 3. 如果偷不到,从 global runqueue 取 4. 如果 global 也空,主动让出 P 进入休眠 ### Work Stealing 机制 当某个 P 的 local queue 为空而其他 P 繁忙时,空 P 会随机选择一个目标 P,从中偷取约一半的 goroutine。这就是 work-stealing 的核心思想——负载均衡通过窃取而非推送实现,减少了全局锁的竞争。 具体算法(semi-global work-stealing): - 发送者将自己的 goroutine push 到本地 queue 尾部 - 接收者在 queue 满时将一半元素 steal 到全局 queue - 空闲 P 随机选一个忙碌 P,从其 queue 头部 steal 一半元素 > [!TIP] 面试常考点 > 为什么从头部偷而不是尾部?因为头部是最近加入的 goroutine(时间局部性好),从头部偷可以减少对原 P 的 cache 干扰。原 P 自己取的是尾部的旧元素。 ### Stack 动态伸缩 Go 的 goroutine 栈采用段式内存管理,初始大小为 2KB,可动态伸缩: ``` +----------------------------+ | Segment 1 (8KB) | | +------------------------+ | | | GOROUTINE STACK | | | | [2KB grow → up to 1GB] | | | +------------------------+ | +----------------------------+ ^ ^ minSize maxSize 2KB (x86: 4KB) ~1GB ``` 伸缩规则: | 场景 | 操作 | 新大小 | |------|------|--------| | 需要更多栈空间 | GrowStack | old * 2 (不超过 maxStackSize) | | 大量未使用的栈空间 | ShrinkStack | old / 2 (不低于 minStackSize) | - **minStackSize** = 2KB(32位平台为 4KB) - **maxStackSize** = 1GB - 扩容时会分配更大的段并拷贝原有数据,缩容类似 > [!WARNING] 常见误区 > "goroutine 栈初始 2KB,最大 1GB"这句话容易引起误解。实际上 goroutine 不会一开始就申请 2KB——Go 1.4 之前是这样,之后采用了段式分配。goroutine 第一次需要栈时只申请一个小段,按需增长。 ## 代码示例 ### 观察 GOMAXPROCS 的影响 ```go func main() { runtime.GOMAXPROCS(2) // 限制为 2 个逻辑处理器 var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func(id int) { defer wg.Done() fmt.Printf("G%d running on M-%d\n", id, runtime.Stack(nil, false)) }(i) } wg.Wait() } ``` 调整 GOMAXPROCS 的值可以直接控制并发度上限。设为 1 时所有 goroutine 串行执行;设为大于 CPU 数量的值时,多余的 P 会通过 spin 模式短暂竞争 CPU。 ### 栈逃逸检查 ```go func capturesVar() func() int { x := 42 return func() int { return x } // x 逃逸到堆上 } // 编译命令:go build -gcflags="-m -m" // 输出: ./main.go:4:10: func captured by closure escapes to heap ``` 栈逃逸意味着变量被分配到堆上而非栈上,这会触发 GC 并且降低局部性。编译器自动处理,开发者可以通过 `-m` 标志查看逃逸分析结果。 ## 实践场景 ### 面试高频问题 **Q: GOMAXPROCS 设置为多少最合适?** 默认值即可(等于机器逻辑 CPU 数)。大多数场景下不需要手动调优。只有纯 CPU 密集型任务偶尔可以从超配获得一点收益,但收益有限且会增加上下文切换开销。 **Q: goroutine 和 OS 线程的区别?** | 维度 | OS 线程 | Goroutine | |------|---------|-----------| | 栈大小 | 静态(通常 1-8MB) | 动态(2KB 起,按需增长) | | 创建成本 | 高(内核态) | 极低(内核外) | | 调度方式 | 内核调度 | Go runtime 调度 | | 并发规模 | 数千 | 百万级 | **Q: 什么情况会导致 goroutine 泄漏?** 1. Channel 无人接收导致 send 侧永久阻塞 2. Context 从未取消,等待 cancelled 信号的 goroutine 无法退出 3. 在 select 中向永不关闭的 channel 发数据 4. 定时器未 Stop 导致 Timer goroutine 泄漏 > [!TIP] 最佳实践 > 使用 `go tool pprof -inuse_goroutines` 定期检查活跃 goroutine 的数量变化趋势,异常增长往往意味着 goroutine leak。 ### 实战建议 - **永远给 goroutine 提供退出路径**:用 context 或 channel 传递取消信号,不要让 goroutine 无限循环。 - **合理设置 GOMAXPROCS**:I/O 密集型和 CPU 密集型有不同策略,但 Go 的默认值对大部分场景都足够好。 - **关注 goroutine 数量**:监控工具中 goroutine 数量持续上升是危险的信号。 ## 扩展阅读 - [[Context 包详解]] — Context 是协调多 goroutine 生命周期的重要工具 - [[Sync 包核心源码]] — sync 包中的锁与 waitgroup 直接影响 goroutine 的状态转换 - [[Select 多路复用机制]] — select 是多 goroutine 间通信的高级形态 - [[切片底层实现]] — goroutine 参数传递中切片的逃逸行为