尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

深入剖析Go GPM调度模型:从goroutine排队到抢占式调度

深入剖析Go GPM调度模型:从goroutine排队到抢占式调度 排查线上服务时最头疼的问题之一就是 goroutine 数量莫名其妙涨到几十万还在涨CPU 却闲得发慌。要解释这个现象绕不开 Go 的调度器也就是常说的 GPM 调度模型。Goroutine 是 Go 最核心的并发原语而调度模型决定了 goroutine 怎么被创建、排队、切换、抢占和销毁。弄懂这套模型不只是面试刚需更是定位性能问题、写高并发服务的底层能力。这篇就按我在项目中实际排查问题时的拆解思路把 GPM 调度的细节展开讲一遍适合那些已经写过不少 goroutine、但对内部行为还是一团黑盒的开发者。1. 先从“人话”层面理解 GPM 模型1.1 调度器解决的根本问题每开一个系统线程内核都要给它分配独立的栈空间通常 1MB 起步创建和切换线程都要陷入内核态上下文切换要保存一堆寄存器状态。线程一多光是切换开销就能把 CPU 吃满。Java 早期的线程模型之所以在超高并发下撑不住很大原因就是“一个请求一个线程”的成本太贵。Go 的选择是用非常轻量的 goroutine 替代线程在用户态自己做调度。一个 goroutine 初始栈只有 2KB4KB按需增长创建成本极低几万个 goroutine 并发在 Go 里是常态。但“用户态调度”这件事本身不简单它需要解决三个核心问题谁负责排队、谁负责执行、以及做 IO 或系统调用时怎么不浪费 CPU。GPM 模型就是为了回答这三个问题而设计的。1.2 G、P、M 到底是什么G 全称 Goroutine就是你在代码里go func()创建的那个任务。它只代表一段需要执行的代码和运行所需的栈、寄存器上下文。G 不绑定任何线程是个纯粹的“可移植任务”。M 全称 Machine本质是操作系统线程真正干活的人。M 负责从队列里取出 G恢复 G 的栈和寄存器然后把控制权交给 G 的代码。M 的数量不是固定的Go 运行时会按需创建和销毁 M但在任何时刻真正执行用户代码的 M 数量不会超过 P 的数量。P 全称 Processor中文一般叫“逻辑处理器”。注意它不是 CPU而是一个调度上下文可以理解成“工位”。每个 P 持有自己的本地任务队列P 的数量决定了系统中允许同时有多少个 G 处于 Running 状态。默认情况下P 的数量等于 CPU 核数可以通过GOMAXPROCS调整。这么说吧G 是工作任务M 是员工P 是员工的工位。员工必须有工位才能干活工位总数固定所以同时干活的员工也是有限的。这个固定工位数就是 Go 并发能力的“天花板”。1.3 为什么非要有 P 这个中间层Go 早期版本1.0 之前其实是 GM 模型只有全局队列所有 M 都从同一个全局队列取 G 执行。听起来挺简单但问题是多个 M 同时取任务必然要抢同一把锁一旦 goroutine 数量上来锁竞争就成了最大的瓶颈CPU 大量时间花在等待锁上而不是执行任务。后来引入 P 这个中间层核心思路就是“本地化”。每个 P 维护一个本地队列新创建的 G 优先放到当前 P 的本地队列里M 干活时也优先取自己绑定 P 上的任务只有本地队列空了才去碰全局队列。这样大部分操作都没有锁竞争只有本地队列全满、或者需要从全局队列取任务时才走锁。P 还承担了 M 和 G 之间的解耦作用M 在执行系统调用陷入内核时跟它绑定的 P 可以立刻被另一个空闲 M 接管原有的 M 返回后也不至于让整个调度停摆。换句话说P 是“调度权利”的载体。M 只是执行者谁拿到 P谁才有资格运行 G。这个设计让 Go 的并发能力从“线程数”中解放出来真正受控于调度器的逻辑处理器数量。2. 调度器的关键数据结构与核心机制2.1 本地队列、全局队列与 runnext每个 P 上都挂着一个本地运行队列runq容量是固定的 256 个槽位用数组加环形头尾指针实现。代码里go func()创建一个新 G 时调度器会优先把它放到当前 P 的本地队列尾部但这里有个细节不是直接排队尾而是先看runnext这个一格的小缓冲区。runnext是 P 上专门留出的一个快速通道。当某个 G 主动让出 CPU 或者被抢占时如果它还有继续运行的价值调度器会把“下一条该执行的指令”放到runnext里。下次调度循环会优先执行runnext里的 G这样能最大程度利用缓存局部性减少因为切换带来的冷启动损失。你可以理解为工位旁边放了一个“接下来最可能马上要做的任务”的临时框子。当本地队列也满了新 G 才会被放到全局队列sched.runq里。全局队列是所有 P 共享的访问它需要拿一把全局锁所以它的优先级最低。调度器每次从全局队列取任务时也不会一次只取一个而是取一批尽量摊薄锁的开销。批的大小跟当前全局队列长度和 P 的数量有关大致思路是“分给每个 P 多一点避免频繁抢全局锁”。调度循环取任务时有一个固定的优先级顺序看runnext有没有任务看当前 P 的本地队列头部本地队列空了去全局队列拿一批全局队列也空去看网络轮询器netpoll有没有唤醒的 G最后实在没有就去其他 P 的本地队列里“偷”一半任务来执行。这个顺序很关键。它保证了优先执行自己最近的上下文避免跨 P 的隐性竞争只有“真的没事干”才会去抢别人的任务同时把“偷”的量控制在一半左右避免某个 P 被掏空。2.2 Goroutine 的生命周期状态机goroutine 在运行期间会在几个状态之间切换排查问题时理解状态是第一步。常见状态如下状态中文含义常见驻留位置Gidle空闲空闲 G 缓存池gFreeGrunnable就绪等待运行本地队列 / 全局队列Grunning正在执行绑定在某个 P 上与 M 一起运行Gsyscall正在系统调用中M 陷入内核P 可能已被释放Gwaiting阻塞等待channel、锁、网络IO、timer 等等待队列Gdead已执行完待复用gFree 缓存池关键点在于Gdead这层设计。goroutine 执行完并不会直接销毁并归还内存给操作系统而是把 G 对象放入空闲池下次创建时直接复用。这种做法大幅降低了重复创建 goroutine 带来的内存分配压力也是高并发场景下 Go 表现稳定的原因之一。真正需要重点关注的是Gsyscall和Gwaiting这两个状态。Gwaiting通常意味着 goroutine 在等某个条件成立比如等 channel 数据、等锁、等网络事件。大量 goroutine 卡在Gwaiting本身就说明业务里可能存在某种“一直等不到结果”的情况。而Gsyscall则关系到 P 的释放和 M 的调度我们在下面系统调用部分展开讲。2.3 GOMAXPROCS 到底控制什么怎么设才不心虚GOMAXPROCS控制的是同时执行用户 Go 代码的 P 的数量。也可以理解为同时处于 Running 状态的 goroutine 个数的上限。注意线程 M 的数量不受它直接限制M 可以在没有任务时休眠或者因系统调用阻塞而临时增加。但真正“执行你的业务逻辑”的 goroutine 并发数基本就被 P 的数量卡死。默认情况下Go 运行时会在初始化时读取主机的 CPU 核数来设置 P 数量。在裸机或虚拟机里这个默认值一般就是最优经验值。但在容器环境下要格外小心如果容器的 CPU 配额被限制为 2 核而宿主机器是 64 核Go 在早期版本识别不到配额就会默认创建 64 个 P导致大量上下文切换和线程争抢性能反而更差。Go 1.15 之后运行时能感知 cgroup 配额但为了稳妥很多团队还是会在容器启动时用automaxprocs这类工具或者手动runtime.GOMAXPROCS(n)按配额设置。一个反向的误区是“P 设得越大越好”。P 数量超过实际可用 CPU 后多出来的 P 只能靠切片调度轮流执行增加的不过是排队和切换开销。我在压测里见过有人把 GOMAXPROCS 调到 64 跑一个 8 核的容器毛刺和延迟都明显升高降到 8 之后反而更稳。除非你的服务里大量 goroutine 在等 IO网络、磁盘P 多一点能提高宽带利用率否则不要轻易盖过物理核数。3. 调度的完整流程与关键场景3.1 从 main 函数被调起来看调度器启动很多文章上来就讲调度循环却忽略了一个问题第一个 goroutine 是怎么被调度起来的实际上Go 程序启动时运行时会先创建主线程并把它包装成 M0然后创建 P0再把runtime.main这个特殊的 goroutine 放到 P0 的本地队列里由 M0 来执行它。main函数里的业务代码、以及你写的第一个go func()本质上都是在这个主 goroutine 上逐步展开的。每个 M 在启动时还会绑定一个特殊的 G叫 G0。G0 不执行用户的业务代码它专门用来运行调度器自身的函数比如schedule()、findRunnable()、栈增长处理以及垃圾回收时需要的栈扫描。为什么要单独留一个 G0因为调度器在切换 G 时必须保存和恢复栈上下文如果连这个操作都在某个用户 G 的栈上做容易互相污染。G0 就像员工休息室里的“管理者专属工位”干活的人要换班时管理者在这个工位上完成交接。启动完成后真正驱动整个系统运转的是 M 上不断循环的三步调用schedule()找到一个可运行的 G调用execute()把 G 交给 M 执行等 G 运行结束或阻塞后回到schedule()继续找下一个任务。这个无限循环就是调度主循环也叫调度器的事件泵。3.2 主调度循环findRunnable() 的取任务顺序findRunnable()是调度循环里最重要也最复杂的函数本质上是一个“抢活算法”。它在找任务时会按下面的顺序尝试先看runnext如果有直接拿再看当前 P 的本地队列runq本地队列为空加锁从全局队列取一批全局队列也没货调用netpoll()看有没有因为网络事件而被唤醒的 G都没有就进入 work stealing 环节随机选其他 P从它们的本地队列尾部偷一半的任务过来偷不到则让自己进入自旋状态短暂等待一段时间后再试如果长时间没有新任务M 会让出 CPU进入休眠状态等唤醒信号。我把 work stealing 单独说一下。它偷的是“队列尾部的任务”而不是头部这么设计是为了尽量不打乱目标 P 已有的执行顺序同时把最老的任务先带走减少任务滞留时间。每次偷一半也很有讲究偷太多会让被偷的 P 很快又陷入空转偷太少则可能刚偷完又得再偷一次一半是一个妥协值。在实战中work stealing 是解决“某些 P 忙死、某些 P 饿死”问题的关键。如果一个服务里大量 goroutine 是顺序创建的理论上它们都会集中在某一个 P 的本地队列里如果没有偷取机制多核资源就浪费了。偷取让空闲的 P 主动去别人那“借活”整体负载才能被摊平。3.3 系统调用时 M 和 P 如何 hand offgoroutine 执行到文件读写、加锁等会触发系统调用或陷入内核的操作时线程会阻塞在内核态此时如果还占着 P其他任务就无法在这个 P 上运行。Go 的做法是hand off把当前 M 和 P 解绑让 P 挂到空闲 M 上继续执行其他 G当前 M 则在系统调用返回后再重新“找工作”。具体流程是这样G 进入entersyscall状态后运行时会把这个 G 所在的 M 标记为“正在系统调用中”然后尝试释放 M 绑定着的 P。释放出来的 P 会进入空闲 P 列表或者直接唤醒一个空闲的 M 来绑定它。系统调用结束时原来的 M 会尝试重新获取一个 P如果当时有空的 P 就直接绑定回去继续执行之前的 G如果没有空闲 P就把这个 G 丢到全局队列里排队自己去休眠。这套机制保证了即使有 goroutine 在做很慢的磁盘 IO、锁等待也不会浪费整个 CPU 核。需要注意的是频繁触发系统调用会带来额外的 M 创建和销毁成本比如大量os.ReadFile或者高并发 DNS 解析thread 数量会波动这在观察runtime监控指标时能看得很明显。3.4 网络 IO 与 netpoller为什么高并发网络服务里 goroutine 不卡线程如果我们做的是一个高并发 TCP 或 HTTP 服务每个连接一个 goroutine按道理大量连接同时读写 IO岂不是会制造大量阻塞的 M其实不会。Go 对网络 IO 做了特殊优化它把自己的 socket 都设置成非阻塞模式底层基于 epoll / kqueue 构建了一个网络轮询器 netpoller。当 goroutine 执行网络读的时候如果数据还没准备好它不会真让线程陷入read阻塞而是把自身状态改成Gwaiting注册到 netpoller 的等待队列里然后直接让出 P。此时 M 依然是空闲的可以立刻从 P 的队列里取下一个 G 执行。等到内核通知这个 socket 可读了netpoller 后台线程会唤醒对应的 G把它塞回某个 P 的本地队列等待调度。这个设计的直接收益是一万个网络连接同时挂在服务端真正阻塞的线程可能就几个绝大多数 goroutine 都安静地躺在等待队列里CPU 可以持续处理已经就绪的任务。这也是为什么 Go 做网关、代理、HTTP 服务时几千甚至几万并发是家常便饭。排查时如果发现某段时间 goroutine 数量暴增但线程数稳定多半就是网络等待类 goroutine 堆积而不是调度器卡死。3.5 抢占式调度从协作式到异步抢占Go 1.14 前的调度是协作式抢占只有 goroutine 在函数调用、栈扩容等明确的“安全点”才会检查抢占标志。如果某个 goroutine 陷入纯计算死循环比如for {}且循环体里没有任何函数调用它就能一直霸占 P其他 goroutine 全部饿死。这问题在线上很致命一个死循环就能拖垮整个进程。Go 1.14 引入了基于信号的异步抢占。后台的sysmon监控线程大约每 10ms 检查一次运行中的 G如果发现某个 G 运行时间超过阈值就会向它所在的 M 发送一个SIGURG信号。M 收到信号后会暂停当前 G 的执行强制触发调度器切换到下一个任务。这样一来即使循环体里没有任何函数调用也无法无限期霸占 CPU。实操中这个改进非常实用。我遇到过调度阻塞导致的“全站卡顿”排查半天发现是有个第三方库在纯for循环里做重试判断1.14 之前是噩梦之后虽然还是会影响局部性能但不会再制造整个进程无法调度的情况。不过需要注意异步抢占只是“能打断”不合作的 G并不代表频繁打断没有成本。一个 G 被抢占意味着上下文切换如果业务里大量短小且密集计算的 goroutine 在抢 CPU调度开销依然会高。4. 实战排查调度器相关的现场观察与调优4.1 调度现场用什么工具看遇到 goroutine 暴涨或性能毛刺第一件事不是改代码而是先还原“现场”。Go 运行时提供了两个特别好用的内置观测手段。第一个是环境变量GODEBUGschedtrace1000。设置后每隔 1000ms 运行时会往 stderr 输出一条调度信息输出类似这样SCHED 1024ms: gomaxprocs4 idleprocs0 threads7 spinningthreads1 idlethreads3 runqueue0 [1 2 0 3]字段含义分别是当前时间、P 总数、空闲 P 数、M 总数、自旋 M 数、空闲 M 数、全局队列长度以及方括号里每个 P 的本地队列长度。我一般重点看两个点idleprocs持续为 0 说明 CPU 一直吃满方括号里有没有某个值长期特别大这可能是局部负载不均的信号也可能是该 P 上的任务长时间无法执行完。第二个是go tool pprof里的 goroutine 维度。线上服务暴露 pprof 端口后执行go tool pprof http://localhost:6060/debug/pprof/goroutine可以看到所有 goroutine 的调用栈汇总。重点排查chan receive、select、IO wait、mutex这些等待类型的栈占比如果某一类等待数量异常高基本就是泄漏或任务堆积的位置。如果还想看清楚单次调度延迟和队列波动go tool trace是利器。在代码里通过runtime/trace包开启 trace采样几十秒再用go tool trace打开采集文件能看到每个 P 上 goroutine 的完整时间线哪个阶段在 Running哪个阶段在 Wait被谁唤醒了。这个信息在排查“偶发几百毫秒延迟”时非常准比盲目猜代码高效得多。4.2 goroutine 泄漏的常见模式与排查goroutine 泄漏的本质是goroutine 进入了某个永远等不到条件的等待状态占着栈和内存不放。常见模式有这么几种往 channel 发送数据但没有接收方或者从 channel 接收数据但永远不会有发送方。在select里等多个 case结果所有 case 都不可能就绪又没设超时。使用sync.Mutex时某处 Lock 之后因为提前 return 忘记 Unlock导致其他 goroutine 一直等锁。使用time.Ticker后忘记Stop配合select使用时会让对应 goroutine 一直定期被唤醒看起来像泄漏。调用无超时的context.WithTimeout形同虚设或者某个第三方库内部自己启动了 goroutine 却不清理。排查泄漏有个很实用的路子先跑一段时间的压测或用runtime.NumGoroutine()画曲线。如果 goroutine 数量随着请求线性增长、压测停止后不回落基本就是泄漏了。然后用 pprof dump 两份相隔 30 秒的 goroutine 栈对比新增了哪些栈那个栈基本就是泄漏点。修复时优先考虑加超时控制、用context控制退出信号、把 channel 的关闭时机理清楚。我自己踩过比较隐蔽的一个坑是业务里用sync.WaitGroup等一组任务但其中一个子 goroutine 因为 panic 直接退出了Done永远不会被调用导致外面的 Wait 一直卡住。Go 里 goroutine panic 如果不recover会直接终止整个进程这点反而救了命但如果被某个中间层recover掉了就会出现“任务消失但等待不解除”的诡异现象。排查时要留个心眼。4.3 高并发服务里的调度器调优心得调度器整体上是不太需要手动调优的Go 团队已经把它做得相当通用。但我实际做完几个高并发项目的优化后总结出几条经验比调参数本身更值得执行。第一先看业务是否在“等”上远多于“算”。如果大量 goroutine 在等待网络响应、数据库查询P 的数量可以适当高于物理核数让 CPU 在等待间隙多接一些活。如果服务是纯计算密集比如音视频编解码、图片处理P 保持和核数一致就好多了只会增加切换成本。第二容器环境下,务必确认GOMAXPROCS感知到的核数。最好的方式是在启动日志里打印runtime.GOMAXPROCS(0)返回的值确认它跟你向 POD 申请的 CPU 配额一致。如果不一致又不想引入额外库直接在 main 里按环境变量手动指定即可。这个一行代码能解决的问题藏在监控里时能让你排查一整周。第三使用go tool trace时要学会看“时间线”里的空隙。如果一个 P 上有大片空闲而其他 P 上任务积压说明负载不均衡或 work stealing 没有及时生效。另一个方向是如果每个 P 上都是密密麻麻的短任务调度本身可能成了瓶颈这时候合并任务、减少 goroutine 创建频率往往比加机器更有效。4.4 一个值得养成的习惯给 goroutine 都设退出路径最后分享一个我从 Go 调度器细节里悟出来的工程习惯每个 goroutine 在被创建时都要想清楚它“最迟什么时候必须退出”。这句话听起来很朴素但很多线上问题正是“goroutine 没有退出路径”造成的。所谓退出路径不一定是立刻能退出的逻辑而是给等待加一个上限。比如用select等 channel 数据时带上time.After或context.Done调用第三方库的阻塞接口时把它放到带超时的 goroutine 里处理启动后台任务时优先接受一个ctx参数而不是裸启动。这样一来即使某些底层条件异常goroutine 也能被外部信号终止而不是变成雪崩里的一个雪球。这看起来跟调度模型无关但理解调度模型会帮你形成一种“反向思维”“这个 goroutine 现在停在哪个状态它能不能被唤醒谁会唤醒它”带着这种视角写代码你会自然避开那些会让 goroutine 永远沉睡的设计。排查时也不再害怕那种几百 GB 堆栈的 pprof 文件因为你已经知道一个健康的 Go 程序里绝大多数 goroutine 都应该停在明确且可解释的等待队列中。
返回列表