vault backup: 2026-04-24 23:45:19

This commit is contained in:
2026-04-24 23:45:19 +08:00
parent 388d4439de
commit f05be3890e
21 changed files with 3816 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
---
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泄漏排查]]