226 lines
8.4 KiB
Markdown
226 lines
8.4 KiB
Markdown
|
|
---
|
|||
|
|
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 参数传递中切片的逃逸行为
|