Golang GMP调度模型
Go语言(Golang)的并发能力是其核心优势之一,主要依赖于goroutine(协程)和调度器。Go的调度器采用GMP模型(G-M-P模型),这是从Go 1.1版本开始引入的成熟调度机制,能够高效地将大量goroutine映射到少量的操作系统线程上,实现高并发和多核利用。
1. GMP模型的演变背景
早期的Go(1.0版本)使用GM模型:
- G:Goroutine(协程)。
- M:Machine(操作系统线程)。
- 所有G放在一个全局队列中,所有M从全局队列获取G执行。
- 问题:
- 全局队列需要锁保护,导致锁竞争激烈,影响并发性能。
- 当G发生系统调用(syscall)阻塞时,整个M阻塞,无法执行其他G,导致资源浪费。
- 不支持多核并行,无法充分利用CPU。
为了解决这些问题,Go 1.1引入P(Processor,逻辑处理器),形成当前的GMP模型。P的引入实现了**工作窃取(Work Stealing)**机制,极大提升了调度效率和公平性。
2. GMP模型的核心组件
G(Goroutine):
- Go的轻量级协程,用户态线程。
- 创建开销极小(初始栈仅2KB,可动态增长到GB级)。
- 数量级:可轻松创建数十万甚至百万级G(受内存限制)。
- 为什么这么多:每个G初始仅2KB栈,开销极低;调度在用户态完成,无需内核介入;1台4核8G机器轻松支撑数十万并发连接。
-状态包括:运行中、就绪、可等待等。
M(Machine):
- 操作系统内核线程(OS thread)。
- 每个M对应一个内核线程,由OS调度到CPU核上执行。
- 数量级:默认最多10000个M(由
runtime/debug.SetMaxThreads限制),但实际受OS线程限制(Linux默认约几千)。 - 为什么不宜过多:OS线程创建/销毁成本高(KB~MB级栈);上下文切换需内核介入;过多线程反而增加调度开销。通常M数量略多于P(用于处理syscall等场景)。
- M必须绑定P才能执行G。
P(Processor):
- 逻辑处理器,管理G的运行队列和上下文。
- 数量级:由
GOMAXPROCS决定,默认等于CPU逻辑核数(可通过GOMAXPROCS环境变量或runtime.GOMAXPROCS()动态设置)。 - 为什么等于CPU核数:P是真正执行G的实体,数量匹配CPU核数可实现真正的并行(无伪并行开销);每个P绑定一个M即可发挥多核优势。
- 每个P维护一个本地运行队列(runq),最多存放256个G。
- P是实现M:N调度(多G映射到少M)的关键,避免全局锁竞争。
关系:M必须持有P才能运行G。一个P可以被多个M轮流使用,但同一时刻只绑定一个M。G通过P的队列被分配到M上执行。
3. GMP模型的队列机制
- 本地队列(Local Run Queue):每个P有一个本地队列,优先从这里取G执行。减少锁竞争。
- 全局队列(Global Run Queue):当本地队列满或新G创建时,部分G放入全局队列。全局队列有锁,但使用频率低。
- 调度优先级:M优先 from 自己的P本地队列取G → 如果空,从其他P窃取G → 最后从全局队列取。
- 防饿死机制:为避免全局队列G被饿死,Go调度器采用以下策略:
- 每调度61个本地G后,强制检查一次全局队列(调度计数器机制)。
- 工作窃取时,如果本地队列空,先窃取再检查全局队列,而非完全忽视全局队列。
- 定时检查机制:调度器定期扫描全局队列,确保长期未被调度的G有机会执行。
4. 调度策略
工作窃取(Work Stealing):
- 当一个M的P本地队列为空时,它会随机从其他P窃取一半G到自己的队列。
- 无锁并发偷取实现:
- 循环队列:P的本地队列
runq是一个长度为 256 的循环数组。 - 设计思路(为什么 Owner 和 Stealer 都从 Head 取?):
- FIFO 公平性:Go 的本地队列设计为 FIFO(先进先出),Owner 从
head取(runqget),Stealer 也从head偷(runqgrab),确保了 G 的执行顺序相对公平,减少饥饿。 - 减少冲突(runnext):为了解决 Owner 和 Stealer 在
head上的竞争,Go 引入了runnext特权槽。Owner 优先处理runnext中的 G,只有当runnext为空或需要批量处理时才访问runq数组。 - 偷取“老”任务:Stealer 从
head偷取的是队列中最老的任务。在递归任务中,老任务通常代表更上层的调用,包含的工作量可能更大,这有助于更好地平衡负载。
- FIFO 公平性:Go 的本地队列设计为 FIFO(先进先出),Owner 从
- 原子操作与 CAS:
- Owner 写入:Owner 在
tail端写入 G(runqput),只需原子增加tail指针(因为只有 Owner 会写tail,不存在竞争)。 - 并发读取(CAS Head):当 Owner 尝试从
head取任务,或者 Stealer 尝试从head偷任务时,它们都会竞争head指针。通过 CAS(Compare And Swap) 增加head的值,谁成功了谁就获得了任务的所有权。 - 无锁安全:这种设计避免了使用互斥锁,即便在高并发偷取场景下,也能通过硬件层面的原子指令保证指针更新的原子性和可见性。
- Owner 写入:Owner 在
- 循环队列:P的本地队列
- 实现负载均衡,避免某些P空闲而其他P过载。
系统调用(Syscall)处理(Hand Off机制):
- 当G执行阻塞syscall(如网络IO、文件读写)时:
- 当前M阻塞,但P被“交接”给一个新M(或空闲M)。
- 原M继续等待syscall返回。
- 其他G可以继续在P上执行,不阻塞整个线程。
- 这解决了GM模型中“一个G阻塞整个M”的问题。
- 当G执行阻塞syscall(如网络IO、文件读写)时:
网络轮询器(Netpoller):
- 对于网络IO,Go使用异步机制(epoll/kqueue等),避免阻塞M。
- 网络事件就绪时,相应G被唤醒放入队列。
抢占调度(Preemption):
- 早期是协作式(G主动让出,如channel操作、syscall)。
- Go 1.14引入基于信号的抢占调度:长时间运行的G(如死循环)会被信号中断,强制让出CPU。
- 确保公平性,避免单个G垄断CPU,影响GC或其他G。
Channel通信导致的G挂起与恢复:
- 挂起(Suspend):当G执行
ch <- value(发送)或<- ch(接收)时:- 如果 channel 无缓冲区且无等待的接收者/发送者,G被挂起,状态变为
_Gwait,放入channel的等待队列。 - 当前M继续执行其他G,不阻塞。
- 如果 channel 无缓冲区且无等待的接收者/发送者,G被挂起,状态变为
- 恢复(Resume):由对端G唤醒——当有其他G对同一channel执行相反操作(发送→接收/接收→发送)时:
- 对端G的操作直接找到等待队列中的G,将其状态变回
_Grunnable。 - 被唤醒的G放入原P的本地队列,等待调度执行。
- 唤醒者:对端G(执行相反操作的goroutine)。
- 对端G的操作直接找到等待队列中的G,将其状态变回
- 典型场景:
g1 := go func() { <- ch }()→ g1因无数据挂起;g2: ch <- 1→ g2唤醒g1。
- 挂起(Suspend):当G执行
Timer的G挂起与恢复:
- 挂起:当G调用
time.Sleep()或time.After()时:- G被挂起,状态变为
_Gtimer,放入timer堆(按触发时间排序)。 - 当前M继续执行其他G,不阻塞。
- G被挂起,状态变为
- 恢复:由**timer管理器(timer mcache)**唤醒——timer到期时:
- timer管理器扫描timer堆,找到到期的timer,将其从堆中移除。
- 将对应的G状态变回
_Grunnable,放入全局队列或P本地队列。 - 唤醒者:timer管理器(runtime.timerproc,由netpoller驱动)。
- 实现原理:timer基于netpoller实现,利用epoll/kqueue监听timerfd(Linux)或kqueue(macOS),时间到则触发中断,netpoller唤醒对应G。
- 挂起:当G调用
Syscall导致的G挂起与恢复:
- 挂起:当G执行阻塞系统调用(如
read(fd, buf)、write(fd, buf))时:- G被挂起,状态变为
_Gsyscall。 - 当前M也进入阻塞状态(内核层面)。
- G被挂起,状态变为
- 恢复:由内核唤醒——当syscall返回(数据就绪/超时/被信号中断)时:
- 内核将M从睡眠状态唤醒,M回到用户态。
- M将G状态变回
_Grunnable,放入原P的本地队列。 - 唤醒者:操作系统内核(通过中断或信号)。
- Hand Off机制:G进入syscall前,P会被"剥离"给其他M,确保P不被长时间占用。
- 挂起:当G执行阻塞系统调用(如
Netpoller导致的G挂起与恢复:
- 挂起:当G执行网络IO(如
net.Dial、conn.Read)时:- 如果socket无数据,G被挂起,状态变为
_Gwait,放入netpoller的等待队列(按fd索引)。 - 当前M继续执行其他G,不阻塞(异步IO)。
- 如果socket无数据,G被挂起,状态变为
- 恢复:由netpoller唤醒——当socket有数据/可写时:
- epoll/kqueue(Linux/macOS)检测到socket事件就绪,向netpoller发送通知。
- netpoller遍历等待队列,找到对应的G,将其状态变回
_Grunnable。 - 被唤醒的G放入原P的本地队列,等待调度执行。
- 唤醒者:netpoller(独立后台线程,监控epoll/kqueue事件)。
- 优势:netpoller使用内核异步IO机制,所有阻塞G共享一个M监控IO事件,极大减少线程数。
- 挂起:当G执行网络IO(如
5. goroutine创建和调度流程
- 执行
go func()创建新G。 - 新G优先放入当前P的本地队列。
- 如果本地队列满,放入全局队列。
- 当前M从P队列取G执行(如果当前G结束)。
- 如果没有空闲M,runtime会创建新M绑定空闲P。
调度循环:M不断从P队列取G执行 → G结束或阻塞 → 找下一个G。
6. 特殊角色
- M0:程序启动时的主线程,负责初始化(包括调度器、GC等)。
- G0:每个M的调度栈,用于执行调度代码(非用户G)。
7. 查看GMP状态
使用环境变量GODEBUG:
GODEBUG=schedtrace=1000:每1秒输出调度摘要(P/M/G数量、自旋线程等)。GODEBUG=schedtrace=1000,scheddetail=1:更详细输出。
示例输出:
SCHED 0ms: gomaxprocs=4 idleprocs=2 threads=6 spinningthreads=1 idlethreads=2 runqueue=0 [0 0 0 0]8. GMP模型的优势
- 高并发:百万级goroutine仅需少量线程。
- 多核利用:P数量匹配CPU核,支持真正并行。
- 低开销:用户态调度,上下文切换快。
- 公平高效:工作窃取 + 抢占,避免饥饿和阻塞传播。
9. 注意事项
- 设置
GOMAXPROCS:对于CPU密集型任务,设为CPU核数;IO密集型可适当增大。 - 避免goroutine泄漏(如无限循环无让出)。
- Go调度器不断优化(如Go 1.18+的NUMA感知),但核心GMP模型稳定。
GMP模型是Go并发“简单却强大”的核心。可以深入源码(runtime/proc.go),会发现更多细节。


