★ Go 的 GMP 调度模型是怎样的?
结论先行
GMP 是 Go 运行时调度器的核心模型:G 是 goroutine(任务),M 是 machine(OS 线程,执行者),P 是 processor(逻辑处理器,持有本地可运行队列,数量默认等于 GOMAXPROCS)。调度的本质是“让 M 不断从 P 的队列取 G 执行”,再配合全局队列、work stealing、系统调用解绑与抢占,实现低成本的高并发。
要点
- 角色分工:G 携带栈与执行状态;M 是真正跑在 CPU 上的线程;P 是 G 与 M 之间的调度上下文,P 的数量决定可并行执行的上限。
- 本地队列:每个 P 有一个无锁的本地 runq(容量 256);新建或被唤醒的 G 优先进本地队列,减少跨 P 的锁竞争。
- 取 G 的顺序:P 先取本地队列,空了取全局队列(带锁),再空则 work stealing——从别的 P 偷取任务,均衡负载、减少空转。
- 阻塞让出:G 在 channel、锁、定时器等阻塞点挂起(gopark),不占 CPU;网络 IO 由 netpoller 异步唤醒,不会一阻塞就占住线程。
- 系统调用:G 进入真正阻塞的系统调用时,M 与 P 解绑(handoff),P 挂到空闲 M 或新建 M 上继续跑别的 G;syscall 返回的 G 重新排队。
- 抢占机制:后台 sysmon 监控,运行超过约 10ms 的 G 会被打上抢占标记(异步抢占),防止死循环 G 饿死其他任务;GC 阶段也会抢占协助。
- 数量关系:P 数等于 GOMAXPROCS(默认 CPU 核数);M 数可以大于 P 数(阻塞与 syscall 时临时增加),受 debug.SetMaxThreads 限制。
- 设计目标:把调度留在用户态、用本地队列与偷取减少锁和线程切换,从而支撑海量并发。
- G 的状态机:运行中、可运行(在队列里等待)、等待中(阻塞于 channel/锁/syscall);调度器只从“可运行”集合挑选 G。
- 关键区分:GOMAXPROCS 限制的是并行度而非并发度;阻塞在 channel 的 G 不占 P,只有真正的系统调用才需要解绑 M。
示例
import "runtime"
func main() {
// 查询并行度;生产环境通常保持默认(=CPU 核数)
_ = runtime.GOMAXPROCS(0)
// 观察调度器:GODEBUG=schedtrace=1000 ./app 每秒打印调度摘要
}
常见追问/记忆点
- 追问:M 与 P 谁多?答:并行执行时 M 约等于 P;syscall/阻塞可让 M 临时多于 P,而 P 永不多于 GOMAXPROCS。
- 追问:为什么 GMP 省线程?答:阻塞不再占线程(挂起+解绑),本地队列加偷取减少锁竞争与空转。
- 追问:全局队列什么时候用?答:本地队列满、新建 G 过多时放全局;P 本地空且偷不到时去全局取。
- 记忆:P 管队列、M 管执行、G 是任务;netpoller 管网络、sysmon 管抢占。