GO 运行时内部原理

GMP 调度器现场

Goroutine 很便宜,操作系统线程很贵。Go 用 G(goroutine)、M(OS 线程)、P(调度上下文)三种角色,把"海量的轻量任务"和"有限的重量级线程"之间的调度问题,拆成了一套可以本地无锁运行、还能互相"偷"活干的系统。这篇文章配了一个可以逐步播放的调度器模拟器,以及若干条直接引用自 Go 运行时源码的片段。

· 引用 Go 源码 commit 72aa6db7 · 源码遵循 BSD-3-Clause 协议,版权归 The Go Authors 所有,下文均为简短引用并附原文链接
G · goroutine,你写的每一个 go func() M · OS 线程,真正被内核调度、能执行代码的东西 P · 调度上下文,数量等于 GOMAXPROCS,是"允许并行执行 Go 代码的许可证"

01 · 为什么不直接让线程调度 goroutine

从 G-M 模型到 G-M-P 模型

最早期的 Go 调度器只有 GM 两种角色:所有可运行的 goroutine 挤在一个全局队列里,每个线程要拿任务就必须去抢这个队列的锁。goroutine 数量一多,线程之间抢锁抢得比干活还凶,这是 2012 年前后 Go 调度器被公认的老大难问题。

另一个更麻烦的问题是:goroutine 用的堆、缓存这些资源,是挂在线程上的。一个 goroutine 一旦阻塞在系统调用里,线程就跟着被内核挂起,它手里缓存的内存分配状态、待运行的其它 goroutine 队列,全都一起被冻结,没法转交给别的线程继续用。

Dmitry Vyukov 在 2012 年提出的方案,就是现在的 G-M-P 模型:凭空插入第三个角色 P,把"可以并行跑 Go 代码的名额"和"操作系统线程"解耦。每个 P 拥有自己的本地可运行队列、内存分配缓存;M 想执行 Go 代码,必须先持有一个 P。这样一来:

02 · 三种角色分别是什么

G / M / P 的真实定义

这三个都是 Go 运行时里真实存在的 struct,定义在 src/runtime/runtime2.go。字段很多(g 有 90+ 个),下面只挑和调度直接相关的部分。

struct g

一个 goroutine 的全部状态:栈边界、当前状态机(runnable/running/syscall/…)、指向正在使用它的 m。极轻量——初始栈只有 2KB,可以创建几十万个。

struct m

对应一个真实的 OS 线程。持有 g0(调度专用栈)、curg(当前在执行的 goroutine)、以及一个 p 指针——只有拿到 P,M 才能执行用户 Go 代码。

struct p

调度上下文,数量固定为 GOMAXPROCS。核心资产是本地可运行队列 runq,以及内存分配缓存 mcache——这两样东西不用锁就能被拥有它的 M 访问。

runtime2.go · L471-L490在 GitHub 上查看 ↗
type g struct {
	// Stack parameters.
	// stack describes the actual stack memory: [stack.lo, stack.hi).
	// stackguard0 is the stack pointer compared in the Go stack growth prologue.
	// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
	// stackguard1 is the stack pointer compared in the //go:systemstack stack growth prologue.
	// It is stack.lo+StackGuard on g0 and gsignal stacks.
	// It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
	stack       stack   // offset known to runtime/cgo
	stackguard0 uintptr // offset known to cmd/internal/obj/*
	stackguard1 uintptr // offset known to cmd/internal/obj/*

	_panic    *_panic // innermost panic
	_defer    *_defer // innermost defer
	m         *m      // current m
	sched     gobuf
	syscallsp uintptr // if status==Gsyscall, syscallsp = sched.sp to use during gc
	syscallpc uintptr // if status==Gsyscall, syscallpc = sched.pc to use during gc
	syscallbp uintptr // if status==Gsyscall, syscallbp = sched.bp to use in fpTraceback
	stktopsp  uintptr // expected sp at top of stack, to check in traceback

注意 syscallsp / syscallpc 这几个字段:goroutine 陷入系统调用时,运行时要把它当时的栈指针、程序计数器先记下来,GC 扫描栈的时候要用。这就是为什么"阻塞在系统调用"对 goroutine 本身来说,是一个需要被精确记录的正式状态,而不是线程层面看不见的黑盒。

runtime2.go · L616-L646在 GitHub 上查看 ↗
type m struct {
	g0      *g     // goroutine with scheduling stack
	morebuf gobuf  // gobuf arg to morestack
	divmod  uint32 // div/mod denominator for arm - known to liblink (cmd/internal/obj/arm/obj5.go)

	// Fields whose offsets are not known to debuggers.

	procid     uint64            // for debuggers, but offset not hard-coded
	gsignal    *g                // signal-handling g
	goSigStack gsignalStack      // Go-allocated signal handling stack
	sigmask    sigset            // storage for saved signal mask
	tls        [tlsSlots]uintptr // thread-local storage (for x86 extern register)
	mstartfn   func()
	curg       *g       // current running goroutine
	caughtsig  guintptr // goroutine running during fatal signal

	// Indicates whether we've received a signal while
	// running in secret mode.
	signalSecret bool

	// p is the currently attached P for executing Go code, nil if not executing user Go code.
	//
	// A non-nil p implies exclusive ownership of the P, unless curg is in _Gsyscall.
	// In _Gsyscall the scheduler may mutate this instead. The point of synchronization
	// is the _Gscan bit on curg's status. The scheduler must arrange to prevent curg
	// from transitioning out of _Gsyscall if it intends to mutate p.
	p puintptr

	nextp           puintptr // The next P to install before executing. Implies exclusive ownership of this P.
	oldp            puintptr // The P that was attached before executing a syscall.
	id              int64

关键是这句注释:"A non-nil p implies exclusive ownership of the P"——一个 M 只要 p 字段非空,就独占这个 P,不需要加锁去访问它的本地队列。这正是 GMP 模型无锁快路径的根基。

runtime2.go · L774-L820(节选)在 GitHub 上查看 ↗
type p struct {
	id          int32
	status      uint32 // one of pidle/prunning/...
	link        puintptr
	schedtick   uint32     // incremented on every scheduler call
	syscalltick uint32     // incremented on every system call
	sysmontick  sysmontick // last tick observed by sysmon
	m           muintptr   // back-link to associated m (nil if idle)
	...

	// Queue of runnable goroutines. Accessed without lock.
	runqhead uint32
	runqtail uint32
	runq     [256]guintptr
	// runnext, if non-nil, is a runnable G that was ready'd by
	// the current G and should be run next instead of what's in
	// runq if there's time remaining in the running G's time
	// slice. It will inherit the time left in the current time
	// slice. If a set of goroutines is locked in a
	// communicate-and-wait pattern, this schedules that set as a
	// unit and eliminates the (potentially large) scheduling
	// latency that otherwise arises from adding the ready'd
	// goroutines to the end of the run queue.
	//
	// Note that while other P's may atomically CAS this to zero,
	// only the owner P can CAS it to a valid G.
	runnext guintptr

runq [256]guintptr 就是本地队列本体——一个定长的环形缓冲区,runqhead/runqtail 两个下标圈出当前有效区间。runnext 更有意思:它是一个"插队"槽位,专门放"当前 goroutine 刚刚通过 channel/锁唤醒的另一个 goroutine",让生产者-消费者这种紧密协作的 goroutine 对能立刻排到下一个执行,而不是排到本地队列尾部等轮询——这也是 Go 调度器对"局部性"的一处专门优化。

03 · 现场直播

一次真实的调度过程

下面不是动画特效,是一段真实模拟出来并逐条校验过的调度轨迹:3 个 P、初始 3 个 M、8 个 goroutine。规则严格来自上面的源码和后面会引用的 stealWork / retake:本地队列优先、空了找全局队列、再空了去偷别的 P 一半的队列、系统调用不会立刻交出 P、sysmon 发现 P 卡住太久才会强制收回。全部 8 个 goroutine 跑完之后,我用一个独立脚本校验了每一步快照的一致性(没有 goroutine 同时挂在两个地方、没有队列指向不存在的 G),细节见文末"这个模拟器做了哪些简化"。

sysmon:后台线程,周期性检查有没有 P 卡在系统调用里太久
全局队列 Global Run Queue
点击"下一步"或"播放",从头看这 8 个 goroutine 怎么被调度完的。
0 / 0

04 · 本地队列空了怎么办

工作窃取(work stealing)

模拟器里第 4 步、第 8 步、第 11 步、第 15 步都发生了"偷"——这不是我瞎编的行为,findRunnable 在本地队列和全局队列都找不到活干时,会调用 stealWork,按随机顺序依次尝试从别的 P 那里偷走一半的本地队列:

proc.go · L3844-L3905(节选)在 GitHub 上查看 ↗
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
	pp := getg().m.p.ptr()

	ranTimer := false

	const stealTries = 4
	for i := 0; i < stealTries; i++ {
		stealTimersOrRunNextG := i == stealTries-1

		for enum := stealOrder.start(cheaprand()); !enum.done(); enum.next() {
			...

			// Don't bother to attempt to steal if p2 is idle.
			if !idlepMask.read(enum.position()) {
				if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil {
					return gp, false, now, pollUntil, ranTimer
				}
			}
		}
	}

stealTries = 4:最多完整地把所有其它 P 轮询 4 遍才放弃。stealOrder.start(cheaprand()) 保证每个 P 每轮尝试偷的顺序是打乱的,避免所有空闲 P 总是先去挤兑同一个受害者。只有在最后一遍(stealTimersOrRunNextG)才会连 runnext 这个"插队槽"也偷走——前面说过 runnext 是留给紧密协作的 goroutine 对的,能不动它就不动它。

模拟器简化:真实的 runqsteal 偷取数量算法比"取一半"更精细(要重新计算队首队尾、处理并发 CAS 失败重试),这里为了可读性简化成了"向下取整的一半,至少偷 1 个"。

05 · goroutine 卡在系统调用里怎么办

系统调用与 P 的转移

模拟器第 15-23 步是全篇最关键的一段:G7 进入系统调用(entersyscall)。这里有个反直觉的地方——P 不会立刻被交出去。Go 赌的是大多数系统调用很快就返回,如果每次系统调用都要交接 P、可能还要唤醒或创建新线程,开销反而更大。所以 P 会先乐观地保持挂在原来的 M 上,只是标记成 _Psyscall 状态。

真正负责收拾"赌输了"情况的,是一个独立于所有 P 之外、专门跑在自己线程上的后台角色:sysmon。它不需要 P 就能运行,每隔至少 20 微秒醒来一次,巡查所有 P;发现一个 P 连续两次巡查都还在 _Psyscall 状态(也就是说这个系统调用已经跨过了至少一个 sysmon 周期,不像是"马上就回来"的那种),就强制把 P 收回来,交给另一个线程:

proc.go · L6737-L6767(retake 节选)在 GitHub 上查看 ↗
		// Try to prevent the P from continuing in the syscall, if it's in one at all.
		thread, ok := setBlockOnExitSyscall(pp)
		if !ok {
			// Not in a syscall, or something changed out from under us.
			goto done
		}

		// Retake the P if it's there for more than 1 sysmon tick (at least 20us).
		if syst := int64(pp.syscalltick); !sysretake && int64(pd.syscalltick) != syst {
			pd.syscalltick = uint32(syst)
			pd.syscallwhen = now
			thread.resume()
			goto done
		}

		// On the one hand we don't want to retake Ps if there is no other work to do,
		// but on the other hand we want to retake them eventually
		// because they can prevent the sysmon thread from deep sleep.
		if runqempty(pp) && sched.nmspinning.Load()+sched.npidle.Load() > 0 && pd.syscallwhen+10*1000*1000 > now {
			thread.resume()
			goto done
		}

		// Take the P. Note: because we have the scan bit, the goroutine
		// is at worst stuck spinning in exitsyscall.
		thread.takeP()
		thread.resume()
		n++

		// Handoff the P for some other thread to run it.
		handoffp(pp)

对应模拟器里:第 18 步 sysmon 第一次巡查,发现 P1 在系统调用里,但这是第一次看到,先记一笔就放过;第 21 步第二次巡查,P1 还没回来——retake,把 P1 转交给新线程 M3。原来的 M1 完全不知情,继续在内核里等它的系统调用返回。等系统调用真的结束(第 22 步 syscall-exit),M1 想拿回原来的 P1,发现它已经易主了,只能把 G7 重新扔回全局队列(第 23 步),自己转入 parked 状态等待下次被复用。G7 最终在第 24-25 步被 P0 从全局队列里捡起来正常跑完。

这一套机制解释了一个常被问到的问题:为什么一个 goroutine 阻塞在系统调用(比如同步文件 IO)不会拖垮整个 Go 程序的并行度——最坏情况下也只是耽误一个 sysmon 周期,P 就会被收回去继续干别的活,而不是被这个卡住的线程永远攥在手里。

06 · 实践

GOMAXPROCS 意味着什么

GOMAXPROCS 设置的是 P 的数量,也就是同一时刻最多有多少个 M 能并行执行 Go 代码(不是能创建多少个 goroutine,也不是能创建多少个线程——阻塞在系统调用的 M 不占用这个名额)。默认等于 runtime.NumCPU()

07 · 参考与说明

引用与这个模拟器做了哪些简化

☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电