图 1:G、P、M、内核线程四层结构,一层管一层。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。GMP 是 Go 面试的必答题,但很多人只背得出三个字母的全称,讲不清为什么需要中间这层 P。这篇文章按「为什么 → 是什么 → 怎么运转」的顺序把它讲透。
为什么 Go 不把协程直接交给操作系统?
因为内核线程太贵:栈动辄 MB 级起步,创建销毁要走系统调用,切换要陷入内核、保存恢复整套寄存器。开一万个线程操作系统就开始喘了。Go 的答案是把调度器搬进用户态:协程(goroutine)的栈初始只有 KB 级、可以按需增长,切换只是在用户态换几个寄存器,成本比线程低一个量级。代价是 Go 得自己实现一套调度器,把海量协程公平地摊到有限的内核线程上——这就是 GMP 模型。
三个角色各管一件事。
G 是 goroutine,任务本体:函数入口、栈、执行现场。它是最轻的单位,创建一万个毫无压力。
M 是 machine,工作线程,真正干活的执行体,一对一绑定内核线程。操作系统只看得见 M,看不见 G。
P 是 processor,调度上下文,是 G 和 M 之间的中间层。每个 P 带一个本地可运行队列,M 必须绑定一个 P 才能从队列里取 G 来跑。P 的数量由 GOMAXPROCS 决定(默认等于 CPU 核数),它就是「同一时刻最多有几个协程在并行执行」的阀门。为什么需要 P?因为有了它,M 阻塞时队列可以整体转手,调度状态不用跟着线程走——这是 GMP 相比早期 GM 模型最关键的一步。
模型怎么保证没有协程被饿死?
三套机制兜底。第一是 work stealing:一个 P 的本地队列空了,就从别的忙的 P 那里偷一半 G 过来,全局队列和别的 P 都是货源,忙闲自动均衡。第二是阻塞处理:G 陷入系统调用时,M 与 P 解绑,P 带着整个本地队列投奔另一个空闲(或新建)的 M,剩下的协程不受拖累;channel 这类纯用户态阻塞更便宜,G 直接让出,M 接着跑队列里下一个。第三是 sysmon 巡逻:一个独立于 P 的监控线程定期检查,发现某个 G 跑得太久就发起抢占(Go 1.14 起是基于信号的异步抢占,不再依赖函数调用时机),把长任务踢回队列。
跑一段代码,感受「一万个 G,十个 P」。
下面程序起一万个协程同时阻塞睡眠,观察并行度上限(本机 Go 1.27,go run gmp.go):
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
fmt.Printf("CPU 核数 = %d, GOMAXPROCS = %d(P 的数量)\n",
runtime.NumCPU(), runtime.GOMAXPROCS(0))
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(50 * time.Millisecond) // 模拟阻塞,让出执行权
}()
}
fmt.Printf("运行中协程数 NumGoroutine = %d,而并行上限仍是 %d\n",
runtime.NumGoroutine(), runtime.GOMAXPROCS(0))
wg.Wait()
fmt.Println("全部协程完成")
}
本机实测输出:
CPU 核数 = 10, GOMAXPROCS = 10(P 的数量)
运行中协程数 NumGoroutine = 10001,而并行上限仍是 10
全部协程完成
一万个 G 同时活着,但真正并行执行的只有 10 路——一万个任务、十个执行位,剩下的都在队列里等调度,这就是 GMP 的直观画面。
图 2:work stealing 与阻塞转手,GMP 的两条生命线。
面试现场怎么答出层次。
推荐结构:先一句话定调「GMP 是用户态调度器,把 G 摊到 M 上,P 是中间的调度上下文」;再逐个讲清三个角色和「M 必须绑定 P」这条铁律;然后主动展开三套兜底机制(偷、让、抢);最后落到「协程为什么比线程省」——KB 级可增长栈、用户态切换不陷入内核、调度策略还能感知 channel 等高层语义。能讲到「P 的存在让 M 阻塞时队列整体转手」和「sysmon 异步抢占」这两点,基本就把背答案的人甩开了。
回到那道原题的下半句:「使用协程真的能节省时间吗?」现在可以答得很稳——协程省的是创建与切换的成本,不是计算本身;I/O 密集的并发场景收益巨大,纯 CPU 密集任务并行度照样受 GOMAXPROCS 封顶。你在生产里排查过 goroutine 泄漏或调度延迟吗?当时是怎么定位的?欢迎分享。