先抛一个问题:你的Go服务最多能同时撑起多少个goroutine?几十万?上百万?确实可以,但真正值钱的不是“能起多少”,而是这几十万个goroutine如何被高效塞进十几个CPU核心上跑完,还不卡、不乱、不浪费。答案就在Go语言最核心的运行时组件里:goroutine调度器。今天这篇,我把GMP模型和工作窃取这两块彻底拆开聊,从设计逻辑到调度策略,再到实战调优和问题排查,尽量一次说透。
1. GMP模型:为什么Go调度器需要三个角色
1.1 从线程池到GMP的演进逻辑
如果让你设计一个并发调度器,最朴素的想法是什么?一个线程池:一个全局任务队列,若干OS线程不断从队列里拿任务执行。Go调度器早期确实接近这种思路,全局队列加锁保护,每个线程取任务都要抢锁。核心数不多还好说,核心一多,锁竞争直接成了整个并发系统的瓶颈。
GMP模型的思路是把“任务”和“执行者”解耦,再在中间加一层“本地队列”。M代表操作系统线程(Machine),P代表逻辑处理器(Processor),G代表goroutine。P是调和层,它持有本地可运行队列,M要干活必须先绑定一个P。这层P的存在,让大多数调度决策都不需要碰全局锁,这是Go在百万级goroutine下还能保持调度吞吐的根本原因。
用生活化点的话说:M是你公司里的员工,P是工位,G是工单。员工必须领到一个工位才能干活,工位有自己的任务小托盘。大部分时候,员工拿起托盘上的任务直接干,不用去翻公司的总任务墙,既快又省事。总任务墙就是全局队列,只有在工位上的任务干完了,员工才需要去那边找新活儿。
1.2 G、M、P各自的状态与生命周期
G是goroutine的结构体,栈从2KB起步可动态扩缩,状态机包括几个关键状态:
_Gidle:刚创建还没开始调度_Grunnable:可运行,正在排队等P_Grunning:正在某个M上执行_Gsyscall:卡在系统调用里,对应的M和P处于分离状态_Gwaiting:被阻塞,等待channel、锁或事件唤醒_Gdead:执行结束,g对象会被回收并复用
M是OS线程,创建成本比goroutine高得多,所以Go会尽量复用。默认M数量上限很高,但真正在干活的M不会超过P的数量,因为一个P同时只能绑一个M。M数量异常增长,往往是G阻塞在无法剥离的syscall里,导致大批新M被创建出来顶班。
P是逻辑处理器,数量等于GOMAXPROCS,P少的时候M再多也白搭。每个P内部的本地运行队列长度是256个G,另外在runnext还有一个专属槽位给最新创建的G——这是个很小的细节,但对创建goroutine密集的程序来说影响很大,后面会展开讲。P本身也不是蓝图的,运行时可以根据情况把它们从M上解绑、重新绑定,实现线程的弹性调度。
1.3 为什么要加P这层“中间商”
先做一个假设:没有P,只有M和G,全局一个队列。当几千个核心同时创建goroutine、取goroutine,所有操作都压在全局队列的同一把锁上,粒度再细也是海量冲突。加了P之后:
- 创建goroutine优先进runnext或本地runq,无锁
- 取goroutine优先从runnext和本地runq取,无锁
- 本地队列满了才去挤全局队列,少量竞争
换句话说,P把调度器的热路径从“全局
