Go 高级教程:深入理解 GMP 调度器
为什么 Go 语言能轻松支撑百万并发? 为什么 Goroutine 切换成本这么低?
这一切的背后,都站着一位神秘的大管家 —— GMP 调度器。
1. 为什么需要 GMP?
在很久很久以前(其实也就几十年前),我们写代码都是直接跟 线程 (Thread) 打交道。线程是操作系统(OS)调度的最小单位。
但是,线程这玩意儿太“贵”了:
- 内存占用高:一个线程栈大概要几 MB。
- 切换成本大:线程切换需要陷入内核态,保存寄存器、上下文,这简直就是“劳民伤财”。
这时候,Go 语言的设计师们拍案而起:“我们要造一种更轻量的线程!” 于是,Goroutine (协程) 诞生了。它初始只要几 KB,切换成本极低。
这就带来了一个问题:操作系统只认识线程,不认识 Goroutine。谁来负责把成千上万个 Goroutine 分配给 CPU 跑呢?
这就需要一个“中间商” —— Go 运行时调度器 (Scheduler)。
图示: Thread 与 Goroutine 的区别
2. GMP 模型大揭秘
GMP 其实是三个角色的缩写:
- G (Goroutine):我们写的代码任务,也就是协程。
- M (Machine):工作线程(Thread),对应操作系统的真实线程。它是真正的干活人(搬砖工)。
- P (Processor):逻辑处理器(Context),可以理解为“调度上下文”或“资源”。它是包工头,负责管理 G,并把 G 交给 M 去执行。
形象的比喻
想象一个大型搬砖工地:
- G (砖头):待搬运的任务。
- M (工人):负责搬砖的劳动力。
- P (手推车):工人必须推着车才能搬砖(因为车里装着搬砖工具和任务清单)。
如果没有 P(手推车),M(工人)就不知道该干啥。
图示: GMP 模型示意图
3. 调度器的核心策略
Go 调度器之所以高效,全靠以下几招“绝活”。
3.1 两个队列
P(手推车)不仅自己带着一小堆砖头(本地队列 Local Queue),工地上还有一个巨大的公共砖头堆(全局队列 Global Queue)。
- 优先吃独食:M(工人)推着 P(车)干活时,优先从 P 的本地队列里取 G(砖头)。这不需要加全局锁,速度飞快!
- 偶尔吃大锅饭:如果本地队列空了,M 就会去全局队列取 G(这时候要加锁,慢点但安全)。
3.2 WORK STEALING (工作窃取)
这是最骚的操作。
如果 M1(工人甲)手脚麻利,把自己车里和全局堆里的砖头都搬完了,而 M2(工人乙)还在呼哧呼哧地干活,M1 绝不会闲着抽烟。
M1 会悄悄跑到 M2 的车里,偷走一半的砖头(G)来干!
这就保证了所有 CPU 核心都一直忙碌,绝不摸鱼。

(图示:工人甲(M1)的车空了,正在从工人乙(M2)满满的车里"窃取"一部分砖头)
3.3 HAND OFF (移交)
如果 M1(工人甲)正在搬一块“超级重”的砖头(G 进行了系统调用,比如读文件,导致线程阻塞),M1 就被卡住了,动弹不得。
这时候,P(手推车)里的其他 G(砖头)咋办?
P 会果断抛弃 M1,带着剩下的 G 转移到另一个空闲的 M(或者新建一个 M)身上继续干活。
这就是 P 的无情剥离,保证了任务队列永远有 CPU 在跑。
4. Show Me The Code
我们用代码来验证一下 G 的轻量级。
我们尝试启动 100 万个 Goroutine,看看电脑会不会爆炸。
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
// 查看当前系统的 P 数量(通常等于 CPU 核数)
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
var wg sync.WaitGroup
count := 1000 * 1000 // 100 万
start := time.Now()
for i := 0; i < count; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 假装做点复杂的数学运算
_ = 1 + 1
}()
}
wg.Wait()
fmt.Printf("Finished %d goroutines in %v\n", count, time.Since(start))
}
运行结果(Mac M1 Pro):
GOMAXPROCS: 10
Finished 1000000 goroutines in 280ms
仅仅用了 280 毫秒!如果换成 Java 线程,这台电脑估计已经闻到焦味了。
5. 总结
Go 调度器的核心哲学:
- 复用线程:避免频繁创建、销毁 M。
- 利用多核:通过 P 管理并发,让 M 并行执行。
- 抢占式调度:防止某个 G 霸占 CPU 太久(Go 1.14 引入基于信号的抢占)。
理解了 GMP,你就理解了 Go 高并发的基石。下次面试官问你“协程为什么快”,请把“工人推车搬砖”的故事讲给他听。
思考题:
如果你的 Go 程序里的 G 全是死循环(比如 for {}),会发生什么?其他的 G 还有机会执行吗?(提示:搜索“GMP 抢占调度”)
本文代码示例: