5.6 KiB
5.6 KiB
tags, create time
| tags | create time | ||||||
|---|---|---|---|---|---|---|---|
|
2026-04-24 10:00 |
GMP 调度模型
概述
Go 的并发模型建立在 GMP 调度架构之上。与传统的 OS 线程 1:1 模型不同,Go 引入了 G(Goroutine)、M(Machine/Worker)、P(Processor)三层抽象,实现了高效的用户态调度。
思考:如果 Go 直接使用 OS 线程(1:1 模型),同时启动百万级 goroutine 会发生什么?内存开销和上下文切换代价分别有多大?
GMP 的核心目标是:用最小的调度开销,最大化多核 CPU 的并行利用率。
G — Goroutine(协程)
G 是 Go 并发执行的最小单元——协程。它的核心特征:
- 初始栈空间 2KB,可动态伸缩(最大可达 1GB)
- 栈的伸缩采用分段扩展策略(segment),每次增长时翻倍
- 每个 G 包含:程序计数器(PC)、寄存器状态、栈指针、等待队列指针
// 创建 goroutine 的代价极低
go func() {
// 这个匿名函数运行在一个独立的协程中
fmt.Println("running in goroutine")
}()
// 对比:创建 OS 线程的代价
// runtime/debug.SetThreadThreadsLimit() 限制线程数
// 通常 OS 线程栈默认 1~8MB,且不可动态伸缩
为什么 2KB 起步? 大多数函数不需要很大的栈空间。小默认值让创建海量协程成为可能。
M — Machine(执行器)
M 代表工作线程,绑定一个 OS 线程,真正执行 G 的代码。
- M : OS Thread = 1 : 1,M 是 G 的执行载体
- M 负责执行 G 的代码,同时参与 GMP 调度循环
- 当 G 阻塞(如网络 IO、channel 操作)时,M 会被 P 释放去执行其他 G
// 限制 Go 使用的 OS 线程数,理解 M 的规模
// GOPTHREADS=1000 go run main.go
// runtime.NumGoroutine() 返回 goroutine 数量
// runtime.NumCPU() 返回逻辑 CPU 数量
// M 的数量默认是 GOMAXPROCS(默认 NumCPU)
P — Processor(处理器)
P 是调度器本地状态的抽象,可以理解为一个"局部调度器"。
- P 维护一个本地 goroutine 队列(runq),最多缓存 256 个待执行的 G
- P 的数量由
GOMAXPROCS控制(Go 1.5+ 默认等于 CPU 逻辑核心数) - 每个时刻,一个 P 只能绑定一个 M 来执行代码
P 的核心职责:
| 职责 | 说明 |
|---|---|
| 本地调度 | 从自己的 runq 取出 G 分配给绑定的 M 执行 |
| 工作窃取(Work Stealing) | 当自己的队列为空时,从其他 P 的队列中"偷"一半的 G |
| 全局队列 | 所有 P 共享一个全局 G 队列(最多容纳 64 个),用于负载均衡 |
| 系统调用处理 | 当绑定的 M 进入系统调用时,P 会"脱落"(handoff),找一个空闲 M 绑定 |
GMP 调度循环
flowchart TD
subgraph GQueue["G 队列"]
G1["G1 待执行"]
G2["G2 待执行"]
G3["G3 待执行"]
G4["G4 待执行"]
G5["G5 待执行"]
end
subgraph GlobalQ["全局队列 (最多64个)"]
GQ["Global run queue"]
end
subgraph LocalQ["P 本地队列 (最多256个)"]
PQL["P local runq"]
end
subgraph MExec["M 执行 G"]
ME["执行 G 的代码"]
end
subgraph OSys["操作系统"]
NET["网络 IO / 系统调用"]
SYS["系统调用 - 阻塞"]
end
GQueue --> |入队| GlobalQ
GlobalQ --> |P 空闲时取| PQL
PQL --> |分配给 M| ME
ME --> |执行完 G| ME
ME --> |新建 G| PQL
ME --> |阻塞 channel/IO | NET
ME --> |系统调用| SYS
SYS --> |P 脱落, 另找 M| PQL
NET --> |完成阻塞| PQL
PQL --> |队列太长, 移到全局| GQ
GQ --> |工作窃取: 从其他 P 偷一半| PQL
classDef queue fill:#e3f2fd,stroke:#1565c0
classDef exec fill:#fff3e0,stroke:#e65100
classDef sys fill:#fce4ec,stroke:#c62828
class GlobalQ,PQL queue
class ME exec
class NET,SYS sys
调度流程:
- 新建 G → 先尝试放入 P 的本地队列(
runq),满了则放入全局队列 - P 取 G → 从本地队列取(优先),本地为空时从全局队列取,再空则"工作窃取"其他 P
- M 执行 G → M 拿着 P 分配的 G 执行代码
- G 阻塞 → G 进入等待队列,M 继续执行下一个 G,P 寻找新的 M 绑定
- G 恢复 → G 被放回到某个 P 的队列中,等待下次被调度
为什么不用 M:N 模型?
早期 Go 讨论过 M:N 模型(N 个 G 映射到 M 个 OS 线程),最终选择了 G:M ≈ 1:1 + P 用户态调度,原因:
| 对比维度 | M:N 模型 | Go 的 GMP 模型 |
|---|---|---|
| 系统调用阻塞 | M 被阻塞,N 个 G 全部卡住 | G 阻塞,M 脱手,P 找新 M |
| 抢占式调度 | 需要内核协作 | 用户态即可,P 完全控制 |
| 实现复杂度 | 极高(需要内核干预) | 相对简洁 |
| Go 版本 | 1.1 之前实验过 | 1.1+ 确定方案 |
关键点:Go 1.5 引入 GOMAXPROCS = NumCPU,每个 P 绑定一个逻辑 CPU 核心,实现了真正的并行执行。
关键参数
// 获取和设置 GOMAXPROCS
import "runtime"
func main() {
n := runtime.GOMAXPROCS(0) // 获取当前值
runtime.GOMAXPROCS(4) // 设置为 4
fmt.Println("M 的最大并行度:", n)
}
// 其他 runtime 参数
fmt.Println("Goroutine 数量:", runtime.NumGoroutine())
fmt.Println("逻辑 CPU 数量:", runtime.NumCPU())
思考:
GOMAXPROCS=1时,Go 还是并行的吗?还是只是并发?