Go 语言 GMP 调度器的原理是什么

郑重声明:我看不起 "Go 语言 GMP 调度器的原理是什么" 这种技术话题。

我平时没兴趣研究这种问题。因为在面试中被问到的频率太高了,现在想花 2 个小时的时间来了解下。一方面研究下这个问题背后到底有多大的技术含量,另一方面把这个问题的答案写下来。但是我不会让这种内容停留在我的头脑里,所以下次面试被问到,我肯定还说不会 😏

基本概念

GMP 是一个缩写:

  • G(goruntine):就是协程,代码里每 go 一个,G 的数量就多一个
  • M(Machine):就是系统级别的线程,在其他语言里的 thread
  • P(Processor):数量为 GOMAXPROCS,通常默认是 CPU 核心数。

GMP 的意思是,启动多少个 M(线程) 来执行 G(协程),最多允许 P(核心数)个 M 并行执行。

三个不变量

无聊的(简化后的)定义来了:

  1. 只有拿到 P 的 M 才能执行任务
  2. 可运行的 G 只会在某个 P 的本地 runq 或者全局队列
  3. 当 M 进入阻塞状态(syscall/cgo)时,会及时把 P 让出

这几句话看着很费劲,不需要现在理解,接下来会用一些代码例子来说明他们的含义。

GMP 的调试日志

这是一个最简单的代码文件,用来演示启动一个协程:

go 复制代码
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("Hello from goroutine")
	}()
	wg.Wait()
}

然后带上调试参数运行一下:

bash 复制代码
go build demo0.go
GODEBUG='schedtrace=200,scheddetail=1' ./demo0

注意不要用 go run,因为会引入一些 Go 语言运行时的日志。这个二进制版本的日志比较干净,内容是:

bash 复制代码
SCHED 0ms: gomaxprocs=10 idleprocs=7 threads=5 spinningthreads=1 needspinning=0 idlethreads=0 runqueue=0 gcwaiting=false nmidlelocked=1 stopwait=0 sysmonwait=false
  P0: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P1: status=1 schedtick=0 syscalltick=0 m=2 runqsize=0 gfreecnt=0 timerslen=0
  P2: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P3: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P4: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P5: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P6: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P7: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P8: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  P9: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0
  M3: p=0 curg=nil mallocing=0 throwing=0 preemptoff= locks=1 dying=0 spinning=true blocked=false lockedg=nil
  M2: p=1 curg=nil mallocing=0 throwing=0 preemptoff= locks=6 dying=0 spinning=false blocked=false lockedg=nil
  M1: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=nil
  M0: p=nil curg=nil mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=false blocked=true lockedg=1
  G1: status=1() m=nil lockedm=0
  G2: status=4(force gc (idle)) m=nil lockedm=nil
Hello from goroutine

这些日志显示了这些信息:

  • 第一行 SCHED 开头的是汇总信息,告诉我们程序启动了 10 个 P(gomaxprocs=10)。
  • 只有 P1M2 拿着运行
  • P0M3 拿着处于 spinning 状态,也就是等待任务的状态。

没看到 print 相关的 G,是因为任务运行时间太短了,没被 trace 捕获就结束了,这里主要展示 GMP 的详细信息可以用 debug 命令来看。

抢占式调度

go 复制代码
package main

import (
	"fmt"
	"runtime"
	"time"
)

func busy(tag string, d time.Duration) {
	end := time.Now().Add(d)
	x := 0
	for time.Now().Before(end) {
		x++
	}
	fmt.Println(tag, "done", x)
}

func main() {
	runtime.GOMAXPROCS(1)
	go busy("A", 1500*time.Millisecond)
	busy("B", 1500*time.Millisecond)
}

这个代码的运行结果是,有时候 AB 前面,有时候 BA 前面。

我们已经用 runtime.GOMAXPROCS(1) 设定只有一个 P,但是 Go 语言的 GMP 调度器,仍然会 10ms 释放一次时间片,也就意味着,即使 go busy("A") 处于阻塞状态,时间片之后也会让出执行权,交给主线程去运行 B

可以用这个 busy 的函数定义来让抢占式调度更加肉眼可见:

go 复制代码
func busy(tag string, d time.Duration) {
	end := time.Now().Add(d)
	next := time.Now()
	for time.Now().Before(end) {
		if time.Now().After(next) {
			fmt.Print(tag, " ") // 每 ~100ms 打印一次
			next = time.Now().Add(100 * time.Millisecond)
		}
	}
	fmt.Println(tag, "done")
}

程序的打印结果会是 B A B A B A A B A B A B A B A B A B A B A B A B B A B A B A B done。这意味着不是 tag 为 A 的 P 一路执行到底,也不是 tag 为 B 的 P 一路执行到底,他们在 GMP 调度器中交替执行。

P 偷活干(work-stealing)

来看这个代码示例:

go 复制代码
package main

import (
	"runtime"
	"sync"
	"time"
)

func spin(d time.Duration) {
	deadline := time.Now().Add(d)
	for time.Now().Before(deadline) {
	} // 纯CPU忙等
}

func main() {
	runtime.GOMAXPROCS(1) // 先让所有 G 挤到同一个 P 的本地队列

	const N = 120
	var wg sync.WaitGroup
	wg.Add(N)
	for i := 0; i < N; i++ {
		go func() { defer wg.Done(); spin(500 * time.Millisecond) }()
	}

	time.Sleep(30 * time.Millisecond) // 给点时间把队列堆满到 P0

	runtime.GOMAXPROCS(4) // 突然放大并行度:P1~P3 会去"偷" P0 的一半
	wg.Wait()
}

这个代码干了什么呢,首先设定之后一个 P,然后启动 120 个 G 给这个 P 去执行。30 毫秒后,突然增大 P 的数量。

用 debug 日志能看到,运行后半段有这样的日志:

bash 复制代码
P0: status=1 schedtick=46 syscalltick=2 m=0 runqsize=17 gfreecnt=0 timerslen=0
P1: status=1 schedtick=58 syscalltick=0 m=4 runqsize=5 gfreecnt=15 timerslen=0
P2: status=1 schedtick=60 syscalltick=0 m=2 runqsize=5 gfreecnt=18 timerslen=0
P3: status=1 schedtick=42 syscalltick=0 m=3 runqsize=17 gfreecnt=0 timerslen=0

也就是说,本应该 G 全在 P0 上运行,等到 P1、P2、P3 出来后,它们发现 P0 很忙,就去 P0 的队列里拿了几个任务过来执行。

P 的 runq 队列和全局队列

一个 P 想找活干的时候,上面的代码是偷其他 P 的示例。更严谨的流程是,P 先从本地 runq 队列找,再到全局队列找,找不到再去偷其他 P 的。

什么是 runq 队列,什么是全局队列?可以看这个代码:

go 复制代码
package main

import (
	"runtime"
	"sync"
	"time"
)

func spin(d time.Duration) {
	end := time.Now().Add(d)
	for time.Now().Before(end) {
	} // 纯CPU忙等:保持 runnable
}

func main() {
	runtime.GOMAXPROCS(1) // 只有 P0:所有新 G 先进入 P0 的本地 runq

	const N = 600 // 让它明显超过本地 runq 容量(当前实现通常是 256)
	var wg sync.WaitGroup
	wg.Add(N)
	for i := 0; i < N; i++ {
		go func() { defer wg.Done(); spin(800 * time.Millisecond) }()
	}

	time.Sleep(500 * time.Millisecond) // 给运行时时间把"溢出的一半"推到全局队列

	runtime.GOMAXPROCS(4) // 其它 P 进场,会先从"全局队列"拿活(不是偷)
	wg.Wait()
}

debug 状态运行:

bash 复制代码
go build demo4.go   
GODEBUG='schedtrace=200,scheddetail=1' ./demo4 &> demo4.log

日志会比较多,日志前面几行像这样:

bash 复制代码
SCHED 0ms: gomaxprocs=10 idleprocs=9 threads=2 spinningthreads=0 needspinning=0 idlethreads=0 runqueue=0 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
  P0: status=1 schedtick=0 syscalltick=0 m=0 runqsize=0 gfreecnt=0 timerslen=0
  P1: status=0 schedtick=0 syscalltick=0 m=nil runqsize=0 gfreecnt=0 timerslen=0

其中首行的 runqueue=0 就是全局队列,P0 后面的 runqsize=0 是 P0 的本地队列,P1 后面的 runqsize=0 是 P1 的本地队列。可以看到此时的 P1 状态是 0,也就是不可运行。

随着程序的运行,P0 会启动非常多个 G,日志状态是这样:

bash 复制代码
SCHED 200ms: gomaxprocs=1 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=3 runqueue=395 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
  P0: status=1 schedtick=10 syscalltick=2 m=0 runqsize=204 gfreecnt=0 timerslen=1

一般 P 的本地队列默认是上限是 256,达到这个峰值后,就会把任务溢出到全局队列。

再然后,P1、P2、P3 启动,开始从全局队列拿任务(全局队列有任务则不需要偷其他 P 的):

bash 复制代码
SCHED 826ms: gomaxprocs=4 idleprocs=0 threads=5 spinningthreads=0 needspinning=1 idlethreads=0 runqueue=217 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
  P0: status=1 schedtick=35 syscalltick=2 m=0 runqsize=179 gfreecnt=0 timerslen=0
  P1: status=1 schedtick=14 syscalltick=0 m=3 runqsize=90 gfreecnt=0 timerslen=0
  P2: status=1 schedtick=14 syscalltick=0 m=4 runqsize=64 gfreecnt=0 timerslen=0
  P3: status=1 schedtick=13 syscalltick=0 m=2 runqsize=46 gfreecnt=0 timerslen=0

另外,当 P 依次从本地 runq、全局队列、其他 P 都找不到任务时,会再去问一下 netpoll(问一下 OS)有没有新的 G,要是有就执行,没有就自旋(待命)。这就是 P 执行任务的逻辑。

阻塞 syscall 会及时让出

看这个代码例子:

go 复制代码
package main

import (
	"fmt"
	"runtime"
	"time"
)

func main() {
	runtime.GOMAXPROCS(2)

	go func() {
		time.Sleep(2 * time.Second) // 类比阻塞 syscall/cgo
		fmt.Println("blocking done")
	}()

	go func() {
		for i := 0; i < 6; i++ {
			time.Sleep(300 * time.Millisecond)
			fmt.Println("still running", i)
		}
	}()

	time.Sleep(3 * time.Second)
}

运行结果会是:

bash 复制代码
still running 0
still running 1
still running 2
still running 3
still running 4
still running 5
blocking done

这个代码示例的含义是,第一个 G 明明会阻塞任务队列,一直占着 P 执行,但实际上第二个 G 仍然在运行。

说明 GMP 调度器不会因为某个 G 的阻塞,影响到其他 G 的执行。(其实这是协程调度器很基本的要求)

关闭异步抢占

对于这个代码示例:

go 复制代码
package main

import (
	"fmt"
	"runtime"
	"time"
)

func spin() {
	for { /* 紧密循环 */
	}
}

func main() {
	runtime.GOMAXPROCS(1)
	go spin()
	time.Sleep(100 * time.Millisecond)
	fmt.Println("I should still print unless preemption is off")
}

可以分别用两个命令来运行,一个是

bash 复制代码
go build demo7.go
GODEBUG='schedtrace=1000,scheddetail=1' ./demo7

另一种是:

bash 复制代码
go build demo7.go
GODEBUG='schedtrace=1000,scheddetail=1,asyncpreemptoff=1' ./demo7

asyncpreemptoff=1 可以关闭异步抢占。也就是说,如果没有关闭,没有带这个参数,程序会正常运行,打印出:

bash 复制代码
I should still print unless preemption is off

如果关闭了异步抢占,则程序会被死循环卡住。这个例子主要可以体现 GMP 主动让出 CPU 的特点,当关闭了主动让出的能力后,GMP 就会被阻塞住了。

Go 语言源码

我没有深入看源码,比如 G、M、P 的常量定义在 src/runtime/runtime2.go 文件:

再比如 src/runtime/proc.go 文件中的 runqputslow 函数,功能就是判断本地队列有没有满,如果满了就放到全局队列:

进一步深入

这篇文章肯定有不全面和不到位的地方,我不想进一步深入了,也许有人喜欢折腾这些吧。

Go 语言的 GMP,就是协程调度器的一种具体的工程化的实现,估计很多人在意的,是这种工程化实现背后的细节,比如怎么用栈结构来管理任务队列、怎么实现抢占、让出逻辑等。协程调度器的具体实现方式可以有各种各样的变化,但它们的基本原理都是 continuation。只是 Go 语言把协程作为卖点了。只要其他语言愿意,也是可以开发出自己版本的协程调度器的。

那么问题来了,那些喜欢研究 GMP 原理的人,你们有没有了解过其他语言的协程(coroutine)、虚拟线程、异步函数、Process 是怎么实现的,它们都是比线程更轻量的类似于协程的东西,和 Go 语言的 gorountine 有什么区别?横向对比一下?

如果什么时候,我的工作需要,只有我了解这些内容,才能把工作做好,那么我肯定去把这些东西搞明白。

疑问

我之前写过一个观点:

Go 语言 "千辛万苦" 做出了自动的垃圾回收,减轻程序员对于内存管理的头脑负担。而有些面试官 "千辛万苦" 去搞明白 Go 语言 GC 的原理是什么,怎么标记怎么释放之类,不但引以为豪,而且拿来考察候选人。作为 Go 语言的教徒,你知不知道你的行为在否定 Go 语言设计者的努力?如果真的相信用头脑来管理内存的力量,为什么不去搞 Rust?好比我是一个汽车驾驶员,我要去考驾照,难道需要我搞清楚发动机的工作原理、是怎么把汽油燃烧转变为机械动力的、能量转化公式是什么?我又不是在制造汽车,也不是在开发编程语言。

同样的道理:

Go 语言为了让广大程序员能便捷简单地、用上轻量级的协程,"千辛万苦" 搞出来一个 go 关键字,然而有些人却费尽 "千辛万苦" 研究这个调度器是怎么实现的,懂原理则说明会 Go 语言,不懂则说明 Go 语言水平不行,这是什么道理?作为 Go 语言的教徒,你在否定 Go 语言设计者的努力,明白吗?如果这个语言需要你搞清楚协程调度的原理,才能写出好的代码,那就说明这个语言实现的不到位,偏离了设计者的初衷,没有达到设计者本来的意图。

如果你是编程语言的开发者,需要在另一种语言中借鉴、实现、优化 Go 语言的调度器,那么你就尽情研究吧,这样的工作确实需要懂 GMP 调度器的原理。如果不是那样的工作呢?

相关推荐
用户4822137167757 分钟前
C++——类的继承
后端
陈随易9 分钟前
前端之虎陈随易:2025年8月上旬总结分享
前端·后端·程序员
MrSYJ36 分钟前
UserDetailService是在什么环节生效的,为什么自定义之后就能被识别
java·spring boot·后端
张志鹏PHP全栈37 分钟前
Rust第一天,安装Visual Studio 2022并下载汉化包
后端
estarlee43 分钟前
公交线路规划免费API接口详解
后端
无责任此方_修行中1 小时前
从 HTTP 轮询到 MQTT:我们在 AWS IoT Core 上的架构演进与实战复盘
后端·架构·aws
考虑考虑1 小时前
postgressql更新时间
数据库·后端·postgresql
long3162 小时前
构建者设计模式 Builder
java·后端·学习·设计模式
Noii.2 小时前
Spring Boot初级概念及自动配置原理
java·spring boot·后端
探索java2 小时前
Tomcat Server 组件原理
java·后端·tomcat