深蓝黑背景封面。左侧是一个自上而下的四层调度结构:最上层一排五个小方块标注「待运行的 G(协程)」;向下箭头指向青色卡片「P:调度上下文 + 本地队列」,注「P 的数量决定并行度」;再向下箭头指向卡片「M:工作线程」,注「M 必须绑定一个 P 才能跑 G」;最底层灰色卡片「内核线程」,注「操作系统真正调度的单位」。结构右侧竖排注「协程调度全程在用户态完成」。右侧大标题「Go 的 GMP 调度模型是怎么回事」,副标题「G、M、P 三层角色,协程在用户态被调度」。左上标注「面试经典 · Go 后端」,右上圆角胶囊标注「Day 05 · 11-22」。 图 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 的直观画面。

深蓝黑背景概念图。主体是层级结构图:顶层灰色卡片「全局队列」,装着两个 G 方块;中间层左右两个青色 P 卡片「P1 本地队列」(三个 G)与「P2 本地队列」(空),各自由紫色 M 卡片「M1」「M2」绑定;底层灰色长条「内核线程 K1 · K2 · K3」。一条紫色虚线箭头从 P1 队列指向 P2 队列,标注「work stealing:空闲的 P 从忙的 P 偷走一半 G」。右侧两张机制卡:青色卡「G 阻塞(如系统调用):M 与 P 解绑,P 带着整个队列投奔别的 M」;紫色卡带循环箭头「sysmon 巡逻:抢占跑太久的 G,把 starvation 扼杀在萌芽」。底部总结条:协程为什么省——KB 级初始栈、切换在用户态,不陷入内核;另注 P 的数量通常等于 CPU 核数。 图 2:work stealing 与阻塞转手,GMP 的两条生命线。

面试现场怎么答出层次。

推荐结构:先一句话定调「GMP 是用户态调度器,把 G 摊到 M 上,P 是中间的调度上下文」;再逐个讲清三个角色和「M 必须绑定 P」这条铁律;然后主动展开三套兜底机制(偷、让、抢);最后落到「协程为什么比线程省」——KB 级可增长栈、用户态切换不陷入内核、调度策略还能感知 channel 等高层语义。能讲到「P 的存在让 M 阻塞时队列整体转手」和「sysmon 异步抢占」这两点,基本就把背答案的人甩开了。

回到那道原题的下半句:「使用协程真的能节省时间吗?」现在可以答得很稳——协程省的是创建与切换的成本,不是计算本身;I/O 密集的并发场景收益巨大,纯 CPU 密集任务并行度照样受 GOMAXPROCS 封顶。你在生产里排查过 goroutine 泄漏或调度延迟吗?当时是怎么定位的?欢迎分享。