ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Go调度器原理:GMP模型详解

2026/8/15 2:02:35 拓冰建站 浏览量
Go调度器原理:GMP模型详解 Go并发编程实战三十四Go调度器原理GMP模型详解摘要本文从一次容器内goroutine暴涨排查说起用ASCII架构图讲清G、M、P三者的关系演示goroutine的调度流程、work stealing机制、系统调用时P的交接分析Go 1.14异步抢占并分享一个GOMAXPROCS在容器中误配导致CPU被打满的踩坑案例。一、容器里突然冒出几万个goroutine去年有个服务部署到K8s上限制2核CPU。某天监控告警goroutine数从平时的200飙到3万多接口全部超时。我进去看goroutine dump发现大量goroutine卡在runtime.gopark状态是runnable排着队等不到执行机会。仔细看堆栈这些goroutine都是正常业务逻辑创建的只是没轮到它们跑。2核CPU理论上能跑很多goroutine为什么排了3万个问题根因是容器里GOMAXPROCS被设成了宿主机的核数32核调度器创建了32个P但容器只有2个CPU核大量P在抢不到CPU时间片时把goroutine堆积在本地队列里。改成2之后立刻恢复正常。理解GMP模型的实际价值在于遇到调度层面的问题时能快速定位。下面把这个模型从头到尾讲一遍。二、G、M、P分别是什么Go调度器有三个核心角色简称GMP。GGoroutine就是goroutine本身。每个go语句创建一个G它包含执行栈、指令指针、调度信息。G非常轻量初始栈只有2KB可以按需增长。一台机器开十万G毫无压力。MMachine是操作系统线程。M负责真正执行代码它和操作系统的线程一一对应。M是重量级的创建和切换成本高。M的数量默认上限是10000一般远小于G的数量。PProcessor是逻辑处理器。P是G和M之间的中间层持有本地G队列和缓存。P的数量由GOMAXPROCS决定默认等于CPU核数。只有拿到P的M才能执行G。用一句话概括它们的关系G是要执行的任务M是干活的人P是工位。人必须坐在工位上才能干活一个工位一次只能坐一个人。三、GMP架构图Go 调度器全局视图 ----------------------------------------------------------- | 全局运行队列 (Global Queue) | | [G7] [G8] [G9] [G10] ... | ----------------------------------------------------------- | (P本地队列空时到这里取G) | ---------- ---------- ---------- | P0 | | P1 | | P2 | -- GOMAXPROCS3 | Local Q | | Local Q | | Local Q | |[G1][G2] | |[G4][G5] | |[G6] | --------- --------- --------- | | | | 绑定 | 绑定 | 绑定 v v v ---------- ---------- ---------- | M0 | | M1 | | M2 | -- OS线程 | 正在执行 | | 正在执行 | | 正在执行 | | G1 | | G4 | | G6 | --------- --------- --------- | | | v v v ---------- ---------- ---------- | CPU核0 | | CPU核1 | | CPU核2 | ---------- ---------- ---------- 说明: - 每个P有一个本地G队列(容量256)还有一个全局队列 - M必须绑定P才能执行G - P的本地队列空了会去全局队列偷全局也空了就去别的P偷(work stealing)再看一张P内部结构的细节图。P 的内部结构 --------------------------------------------- | P (Processor) | | | | ------------- ------------------- | | | 本地G队列 | | 本地G队列(无锁) | | | | (runnext) | | [G1] [G2] [G3] | | | | 下一个要跑的G | ------------------- | | ------------- | | | | --------------------------------------- | | | mcache (内存分配缓存) | | | | 小对象分配不需要加锁直接从缓存分配 | | | --------------------------------------- | | | | --------------------------------------- | | | defer链表 / timer管理 | | | --------------------------------------- | | | | 状态: _Pidle / _Prunning / _Psyscall / ... | ---------------------------------------------P有几个关键状态。_Pidle表示空闲没活干。_Prunning表示正在被M使用执行G。_Psyscall表示绑定的M陷入了系统调用P随时可能被拿走。_Pgcstop和_Pdead是GC和销毁状态。四、调度的完整流程一个goroutine从创建到执行经历这几个步骤。go func() {...}() 创建一个G | v ------------------ | G被放入当前P的 | 优先放本地队列满了放全局队列 | 本地运行队列 | ------------------ | v ------------------ | M从绑定的P本地 | 每次调度先看runnext再看本地队列 | 队列取G执行 | 本地队列空了走work stealing ------------------ | v ------------------ | G执行完毕 / 主动 | runtime.gosched()主动让出 | 让出 / 被抢占 | Go 1.14异步抢占(信号中断) ------------------ | v ------------------ | 回到队列等下次 | | 调度执行 | ------------------运行时你会发现5个G不会同时执行只有2个P而是两个两个地跑。P的数量限制了同时执行的G数量。五、Work Stealing机制当P的本地队列空了它不会傻等而是去偷别的P的G来执行。这叫work stealing。P0本地队列空了去偷P1的G P0: [空] -----偷----- P1: [G4][G5][G6] | v P0: [G6] -----偷到一半--- P1: [G4][G5] 偷取规则: 1. 先看全局队列有没有G 2. 全队列没有从其他P的本地队列偷一半 3. 都没有就挂起M(M和P解绑P变idle)work stealing的好处是负载均衡。某个P上的G特别多其他P空闲时自动分担不需要程序员手动分配。比如你在主goroutine里连续创建100个G它们初始都堆在当前P的本地队列里但其他空闲P会过来偷走一半很快所有P都在干活。六、系统调用时P的交接G执行系统调用比如文件IO、网络IO时M会被操作系统挂起。Go调度器不会让P跟着M一起等而是把P交出来给别的M用。正常状态: M0绑定P0执行G1 M0 --绑定-- P0 -- 执行 G1 G1发起系统调用(阻塞): M0 --陷入syscall-- P0被解绑 -- P0绑定M1 (M0阻塞等待IO) | | v v P0变_Psyscall M1从P0本地队列取G2执行 系统调用返回: M0恢复 -- 尝试拿回P0(或找一个空闲P) 如果没有空闲P -- M0把G1放回队列M0挂起或销毁这个机制保证了系统调用不会阻塞调度。想象一下10个goroutine同时发起网络请求每个请求都会阻塞M但P只有2个。M阻塞后P被释放给其他M所以goroutine不会因为系统调用而饿死。Go的netpoller在此基础上更进一步网络IO用epoll/kqueue做了非阻塞封装连M都不会阻塞。七、独家踩坑GOMAXPROCS在容器中的误配开头那个3万goroutine的故事根因是GOMAXPROCS设置错误。Go 1.5以后GOMAXPROCS默认等于runtime.NumCPU()也就是CPU核数。问题在于在容器里runtime.NumCPU()返回的是宿主机的核数不是容器的CPU限制。宿主机: 32核 容器限制: 2核 (cgroup limit) runtime.NumCPU() 32 (读了宿主机信息!) GOMAXPROCS 32 (创建了32个P!) 结果: 32个P抢2核CPU调度开销巨大goroutine堆积这个问题在Go 1.22之前的版本都存在。修复方式有三种。第一种代码里手动设置。在main函数开头加一行runtime.GOMAXPROCS(2)硬编码容器核数。简单粗暴但不够灵活。第二种用uber-go/automaxprocs库它会自动读取cgroup的CPU限制来设置GOMAXPROCS。这是业界最常用的方案。import_go.uber.org/automaxprocs// 自动设置GOMAXPROCS第三种Go 1.22开始runtime会感知cgroup的CPU限制需要Linux环境且cgroup v2但旧版本还是建议用automaxprocs。我当时选了第二种在所有服务的main包里加上automaxprocs的匿名导入一行代码解决问题。从此再没出过goroutine暴涨的事故。八、对比分析把GMP模型和传统的1:1线程模型放一起比一下。1:1线程模型比如Java的Thread每个用户线程对应一个操作系统线程。线程创建和切换要陷入内核开销大。典型线程栈1MB开1000个线程就吃1GB内存。调度由内核负责程序员无法干预。GMP是M:N模型M个goroutine映射到N个操作系统线程。goroutine创建和切换在用户态完成开销极小。初始栈2KB开十万个也才几百MB。调度由Go runtime负责work stealing保证负载均衡系统调用不阻塞调度。代价是runtime要管理调度、栈伸缩、抢占、GC配合出了问题排查难度高。理解GMP对写好并发代码有几个实际帮助。第一GOMAXPROCS不要乱设。第二goroutine可以放心大量创建但要控制总量避免内存爆掉。第三CPU密集型的goroutine如果没有函数调用点Go 1.14之前无法被抢占1.14引入了基于信号的异步抢占解决了这个问题。第四排障时能看懂goroutine dump里的G状态。九、总结这是Go并发编程系列的最后一篇。GMP模型的核心就是G要跑就必须有M绑定P。P的本地队列、work stealing、系统调用时P的交接这三套机制让Go能高效调度海量goroutine。回头看这个系列从goroutine基础到channel、context、并发模式、数据竞争、atomic、并发数据结构再到GMP调度器我们把Go并发的方方面面都过了一遍。并发编程的核心始终是两条线一条是正确性数据竞争、竞态条件、同步原语一条是性能锁竞争、原子操作、调度开销。写并发代码的时候心里始终挂着这两条线出了问题知道往哪个方向查这个系列的目的就达到了。