图解 Go 的 GMP 调度模型
goroutine 随手一个 go 就起,成千上万个也不卡,Go 到底怎么调度它们的?这篇用图和大白话把头疼的 GMP 调度模型讲明白:G、M、P 各是什么、怎么配合、为啥这么设计能又轻又快,看完这套理论就不再是天书。
写 Go 最爽的一点,就是并发简单到离谱。想并发跑个函数,前面加个 go 就完事了。
go doSomething() // 就这,一个协程起来了
随手起几万个 goroutine,程序也稳得很。换成几万个线程试试,机器早跪了。
那 Go 凭啥能这么造?答案就是它背后那套 GMP 调度模型。这东西是很多人眼里的“头疼理论”,但其实用图一摆、拿个比方一套,特别好懂。这篇就把它讲透。
为什么不直接用线程
先想个问题:并发不是有线程吗,Go 干嘛不直接用?
因为线程太重了。
每个线程都要操作系统管着,创建、销毁、切换都得内核插手,还得占一块不小的栈内存。你起几万个线程,光是调度和内存开销就能把机器压垮。
Go 的思路是:别让每个并发任务都占一个真线程。 它搞了个更轻的东西——goroutine(协程)。
goroutine 是 Go 自己在用户态管理的,创建极便宜、栈很小(还能按需增长),切换也不用惊动操作系统内核。所以你才能随手起几万个。
但问题来了:goroutine 再轻,最终也得落到真线程上才能被 CPU 执行。谁来安排哪个 goroutine 上哪个线程跑?这就是 GMP 要解决的事——它是 goroutine 和线程之间的调度中枢。
先认识 G M P 三个字母
GMP 就是三个东西的首字母。先一个个认清楚它们是谁。
- G(Goroutine):就是协程,你
go出去的那个任务。它是要被执行的活儿。 - M(Machine):真正干活的系统线程。它对应一个操作系统线程,是真正能被 CPU 调度执行的家伙。
- P(Processor):处理器,一个逻辑上的调度器。它手里攥着一队待跑的 G,还持有跑 G 所需要的资源。
用个车间的比方,一下就清楚了:
- G 是一个个待加工的活儿(工单)
- M 是工人(真正动手干活的)
- P 是工作台(上面摆着一叠待办工单,还备着干活的工具)
关键规矩是:工人(M)必须站在一个工作台(P)前,才能从台上取工单(G)来干。 三者凑齐,活儿才跑得起来。
它们仨怎么配合
光认识还不够,得看它们怎么搭一块儿转。上图:
P1(工作台) P2(工作台)
+-------------+ +-------------+
| 本地队列: | | 本地队列: |
| G G G G | | G G |
+------+------+ +------+------+
| |
M1(工人) M2(工人)
| |
CPU 核 CPU 核
全局队列: G G G G G ...(公共工单池)
捋一下这张图里的运转逻辑:
- 每个 P 都挂着一个本地队列,里面排着一串待跑的 G
- M 必须绑定一个 P 才能干活,绑上之后,就从这个 P 的本地队列里一个个取 G 来执行
- 还有一个全局队列,是公共的 G 池子,本地队列空了可以来这儿捞
P 有几个?由 GOMAXPROCS 决定,默认等于 CPU 核数。P 的数量,就限定了同时能并行跑的 goroutine 数量——毕竟工作台就那么多,工人再多也得排队上台。
所以整个画面是:几个工作台(P)各带一队工单(G),工人(M)站上台一个个处理。goroutine 成千上万,但真正在“台面上”并行跑的,就 P 那么几个。其余的都在队列里排着,轮流上。
队列空了怎么办 偷活儿
这里有个很妙的设计,专门解决“忙的忙死、闲的闲死”。
设想一下:M1 那个 P 的本地队列还堆着一堆 G,M2 这边的 P 却把活儿干完了,队列空了。M2 就干瞪眼歇着?那不浪费嘛。
Go 的做法叫 work stealing(工作窃取):
- M2 发现自己 P 的本地队列空了
- 先去全局队列捞一批 G 过来
- 全局队列也没有?那它就跑去别的 P 的队列里“偷”一半 G 过来自己跑
拿车间比方就是:一个工人把自己台上的活儿干完了,不闲着,跑去隔壁忙不过来的台子上匀一半工单过来接着干。
这么一来,负载就自动均衡了,谁也不会闲着,CPU 利用率拉满。这也是 GMP 效率高的一大原因。
遇到阻塞会怎样
还有个绕不开的问题:要是某个 G 干活时卡住了(比如读文件、等网络这种阻塞操作),咋办?
如果放任不管,这个 G 卡住,占着的 M(线程)跟着卡,M 绑的 P 上那一队 G 就全被堵死了,一堆活儿干等着。这可不行。
Go 的处理很聪明:G 卡住时,把 P 摘下来,转交给别的 M。
- 某个 G 陷入阻塞,拖住了当前的 M
- 调度器一看不对,赶紧把这个 M 上的 P 解绑下来
- 给这个 P 找一个(或新建一个)别的 M 绑上,P 队列里剩下的 G 接着在新 M 上跑
- 等那个阻塞的 G 缓过来了,再想办法给它安排 M 继续
还是车间那套:一个工人被某个特别费劲的活儿缠住脱不开身,工头就把他面前那张摆满工单的工作台端走,交给另一个空闲工人,让台上剩下的活儿继续流转,不至于全卡在这一个人身上。
这一招保证了:个别 goroutine 阻塞,不会拖垮整批 goroutine。 这对写高并发服务太重要了。
小结
GMP 这套“头疼理论”,拿车间一套,其实清清爽爽:
- G 是活儿(goroutine),M 是工人(系统线程),P 是工作台(调度器,带本地队列和资源)
- 工人(M)得站上工作台(P)才能取活儿(G)干;P 的数量(默认 = CPU 核数)决定了并行度
- 队列空了会 work stealing:先捞全局队列,再去别的 P 那儿偷一半活儿,谁也不闲着
- 某个 G 阻塞时,把 P 摘给别的 M,剩下的活儿照跑,个别卡顿不拖垮全局
说到底,Go 就是用 GMP 这层调度,把成千上万个轻量 goroutine,巧妙地铺到少数几个真线程上跑,既省了线程的重开销,又靠偷活儿和 P 转移把 CPU 喂饱。
想通“G 是活、M 是人、P 是台子”这三个角色,再看那张图,GMP 就不再是天书了。那就带着这套比方去翻翻源码或者做做实验吧。
