图解 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 就不再是天书了。那就带着这套比方去翻翻源码或者做做实验吧。

更多推荐

章节目录