--- 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泄漏排查]]