Files

226 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<br/>全局等待队列"
GR["待调度的 goroutine"]
end
subgraph "P Pool<br/>最多 1024 个 P"
P1["P1<br/>local queue"]
P2["P2<br/>local queue"]
Pn["Pn..."]
end
subgraph "M Pool<br/>OS Threads"
M1["M1 (OSThread)"]
M2["M2 (OSThread)"]
end
subgraph "G Stack"
G1["g1 stack<br/>~2KB start"]
G2["g2 stack<br/>~2KB start"]
G3["g3 stack<br/>~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 参数传递中切片的逃逸行为