This repository has been archived on 2026-05-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
obsidian/DEV/GO/GMP调度模型.md
T

169 lines
5.6 KiB
Markdown
Raw 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, golang, runtime, GMP, goroutine, scheduler]
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)、寄存器状态、栈指针、等待队列指针
```go
// 创建 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
// 限制 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 调度循环
```mermaid
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
```
**调度流程**:
1. **新建 G** → 先尝试放入 P 的本地队列(`runq`),满了则放入全局队列
2. **P 取 G** → 从本地队列取(优先),本地为空时从全局队列取,再空则"工作窃取"其他 P
3. **M 执行 G** → M 拿着 P 分配的 G 执行代码
4. **G 阻塞** → G 进入等待队列,M 继续执行下一个 G,P 寻找新的 M 绑定
5. **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 核心,实现了真正的并行执行。
## 关键参数
```go
// 获取和设置 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 还是并行的吗?还是只是并发?
## 关联笔记
- [[DEV/GO/GC垃圾回收]]
- [[DEV/GO/Channel详解]]
- [[DEV/GO/内存分配与逃逸分析]]
- [[DEV/GO/Goroutine泄漏排查]]