Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

为什么需要 GMP?

根本问题:如何用少量 OS 线程,高效运行海量并发任务?

操作系统线程很重(栈默认 1-8MB,切换需要内核介入,创建销毁开销大)。但我们想要能轻松启动 百万级并发 的语言。

这逼出了一个核心设计决策:在用户态实现调度


三个核心实体

G(Goroutine)— 任务

  • 用户态”线程”,初始栈仅 2-4KB,可动态增长
  • 本质是一个描述”待执行函数”的结构体
  • 状态:Runnable / Running / Waiting / Dead

M(Machine)— 执行者

  • 对应一个真实的 OS 线程
  • 必须绑定一个 P 才能执行 G
  • 数量受 SetMaxThreads 限制(默认 10000)

P(Processor)— 调度上下文

  • 逻辑处理器,持有本地运行队列
  • 数量 = GOMAXPROCS(默认 = CPU 核数)
  • 是连接 G 和 M 的”桥梁”
1
2
3
4
5
6
7
8
9
10
11
G G G        ← 等待运行的 Goroutines
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ P │ │ P │ │ P │ ← GOMAXPROCS 个 P
│ [G,G,G] │ │ [G,G,G] │ │ [G,G,G] │ 本地队列
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
M M M ← OS 线程
│ │ │
CPU核 CPU核 CPU核

为什么要有 P?

这是整个模型最精妙的设计。

没有 P 的朴素方案:所有 M 竞争一个全局队列 → 锁竞争严重

引入 P 的好处:

  1. 本地队列无锁访问 — 每个 P 有自己的队列,M 绑定 P 后直接取,不需要全局锁
  2. M 阻塞时 P 可转移 — M 因系统调用阻塞,P 立刻甩给其他 M,不浪费 CPU
  3. 全局队列作为补充 — 溢出或公平性需要时才访问

调度的核心机制

① 工作窃取(Work Stealing)

当某个 P 的本地队列为空时,不闲置,而是去”偷”别人的任务:

1
2
3
4
P0 队列空闲          P1 队列繁忙
│ │
└──── steal ────→ [G,G,G,G,G,G]
偷走一半 ──→ [G,G,G]

这保证了 CPU 始终保持忙碌,负载自动均衡。

② 系统调用处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
G 发起阻塞系统调用(如文件 IO)


M 陷入内核,无法运行其他 G


P 立刻与 M 解绑,寻找空闲 M(或新建)


新 M 绑定 P,继续执行队列中其他 G ← 关键!不浪费

系统调用返回


原 M 尝试获取 P(可能进入休眠等待)

③ 抢占调度

早期 Go 是协作式(只在函数调用时切换),Go 1.14 引入异步抢占

  • sysmon 监控线程每 10ms 检测运行超时的 G
  • 通过信号(SIGURG)强制中断,实现真正的抢占

完整调度循环(一个 M 的视角)

1
2
3
4
5
6
7
findRunnable():
1. 每隔61次,先查全局队列(防饥饿)
2. 查本地 P 队列
3. 查全局队列
4. 查网络 I/O 就绪的 G(netpoller)
5. 窃取其他 P 的队列
6. 都没有 → M 休眠,P 放回空闲池

数量关系总结

实体 数量 决定因素
G 百万级 用户创建,轻量
P 少量固定 GOMAXPROCS,通常 = CPU 核数
M 动态 按需创建,上限 10000

核心约束:同时运行的 G 数量 ≤ P 数量 ≤ CPU 核数,这正是 GMP 控制并发度的本质。


一句话总结

GMP 的本质是:用 P 的数量 限制真正的并行度,用 工作窃取 保证负载均衡,用 M 与 P 的分离 处理阻塞,从而用极低的成本在用户态调度海量 Goroutine。

评论