Go GMP 调度模型

Posted on Oct 5, 2023

Go 为什么能轻松起几万个 goroutine?核心在于 GMP 调度模型。


三个角色

组件全称含义
GGoroutine用户态轻量级协程,携带着要执行的函数和栈
MMachine操作系统线程,真正执行代码的实体
PProcessor逻辑处理器,持有本地运行队列和 G 的上下文
┌──────────────────────────────────────┐
│             Go Runtime               │
│                                      │
│  ┌─────┐  ┌─────┐  ┌─────┐          │
│  │  P  │  │  P  │  │  P  │  ...     │
│  │ ┌─┐ │  │ ┌─┐ │  │ ┌─┐ │          │
│  │ │M│ │  │ │M│ │  │ │M│ │          │
│  │ └─┘ │  │ └─┘ │  │ └─┘ │          │
│  └──┬──┘  └──┬──┘  └──┬──┘          │
│     │        │        │              │
│  ┌──┴────────┴────────┴──┐          │
│  │    Global Run Queue    │          │
│  └────────────────────────┘          │
└──────────────────────────────────────┘

数量关系

组件数量说明
PGOMAXPROCS(默认 CPU 核数)同时并行执行的 G 上限
M上限 10000,实际动态增减阻塞时 runtime 会新建 M
G无限制(受内存制约)初始栈只有 2KB
import "runtime"

fmt.Println(runtime.GOMAXPROCS(0)) // 当前 P 数量
fmt.Println(runtime.NumGoroutine()) // 当前 G 数量

调度流程

  1. 每个 P 有一个本地 G 队列(256 容量)
  2. M 绑定一个 P,从 P 的本地队列取 G 执行
  3. 本地队列空了,从 Global Run Queue
  4. 全局也空了,从其他 P 的本地队列偷一半(work stealing)
┌─────────────────────────────────────┐
│  P0 本地队列                        │
│  ┌──┬──┬──┬──┬──┐                  │
│  │G1│G2│G3│G4│G5│ ← M0 正在运行 G1 │
│  └──┴──┴──┴──┴──┘                  │
└─────────────────────────────────────┘

┌─────────────────────────────────────┐
│  P1 本地队列                        │
│  ┌──┬──┐                            │
│  │G6│G7│ ← M1 正在运行 G6          │
│  └──┴──┘                            │
│  ┌──────────────────────┐           │
│  │  Global Run Queue    │ ← 被偷之前│
│  │  (空的,P0 来偷)     │   空,P0   │
│  └──────────────────────┘   过来偷   │
└─────────────────────────────────────┘

关键调度点

G 会在以下时机让出执行权:

  • 主动调用 runtime.Gosched()
  • channel 操作阻塞ch <- vv := <-ch
  • 系统调用 — 文件 IO、网络 IO 等(此时 M 被阻塞,P 会绑定新 M)
  • 抢占调度 — Go 1.14 起基于信号的抢占,防止死循环占用 CPU
  • 函数调用 — 检查栈是否要扩容(morestack

系统调用 vs 网络 IO

同步系统调用

G1 → M1 → P1   执行 syscall (read 文件)
syscall 阻塞 → M1 和 P1 解绑
runtime 找/新建 M2 绑定 P1 继续执行其他 G
syscall 返回 → G1 回到 P1 队列,M1 休眠

网络 IO(异步)

G1 执行 net.Dial → runtime 把 fd 注册到 netpoller
G1 挂起,M 去执行其他 G
netpoller 检测到 fd 就绪 → G1 重新变为 runnable

这就是 Go 网络 IO 不占用线程的原因。

总结

  • GMP 是 Go 高并发的基石
  • M:N 调度:M 个 goroutine 映射到 N 个 OS 线程
  • P 的数量决定并行度,默认等于 CPU 核数
  • work stealing 保证负载均衡
  • 网络 IO 走 netpoller,不阻塞 M