蛮荆

runtime/HACKING.md

2023-05-11

前言

最近阅读标准库源代码时,偶然间发现 runtime 包下面的一个 markdown 文件,大致读了一下,感觉对理解 GMP 的基础概念和各类 注解指令 非常有帮助,决定翻译出来,正好提高一下自己的英文水平。

文件路径为 $GOROOT/src/runtime/HACKING.md,笔者的 Go 版本为 go1.19 linux/amd64

概述

本文档随时会更新且有可能随着时间变化而过时,文档的目的在于阐明编写 runtime 代码和普通代码之间的差异,所以关注的是一些普遍的概念而非具体的细节。

调度器结构

调度器 管理着 runtime 中三个非常重要的资源: G M P, 即使你不编写底层调度器相关代码,也应该理解着三个概念。

G M P

一个 G 是一个 goroutine, 通过 runtime.g 结构体对象表示,当一个 goroutine 退出时,g 对象会被放入空闲 g 对象池中,便于后续的 goroutine 复用。

一个 M 是一个 系统线程,可以执行用户的 Go 代码、runtime 代码、系统调用、空闲等待,通过 runtime.m 结构体对象表示,同一时间可能有任意数量的 M 阻塞在系统调用。

一个 P 是执行用户代码所需要的资源,例如调度器状态和内存分配器状态,通过 runtime.p 结构体对象表示,P 的数量和 GOMAXPROCS 完全相等。 一个 P 可以理解为操作系统调度器中的 CPU, P 的类型就像 CPU 的状态,可以将一些共享的数据放到 P 对象中,进而提升性能。

调度器 的工作是将一个需要执行代码的 G, 一个负责具体执行的 M 和一个拥有执行所需资源的 P 匹配关联起来,当一个 M 执行代码过程中陷入系统调用时, 需要将其关联的 P 归还到空闲的对象池中,等到该 M 系统调用结束后被唤醒时,需要再次从空闲的对象池中获取一个 P 继续执行剩下的代码。

所有的 g m p 结构体对象都是分配在堆上的,而且在程序结束之前不会释放,但是它们的内存使用很稳定 (这里应该是指不会出现内存泄漏等问题), 正因为如此,runtime 在调度器的底层实现中可以避免写屏障。

getg() 和 getg().m.curg

调用 getg().m.curg 获取当前用户的 g

getg() 也可以返回当前的 g, 但是如果当前正在系统栈或信号栈上面执行,那么返回的是当前的 Mg0 或者 gsingnal, 这通常不是你想要的。

如果要判断当前执行的是 系统栈 还是 用户栈,可以使用 getg() == getg().m.curg 表达式。

每个存活的 G 都有一个关联的 用户栈 来执行用户代码,用户栈 刚开始很小 (例如 2KB),然后动态增长或收缩。

每个 M 都有一个关联的 系统栈 (也称为 g0 栈,因为这个栈也是通过 G 实现的),在 Unix 平台上,还有一个信号栈 (也称为 gsignal 栈)。 系统栈和信号栈不能增长,但是其本身足够运行任何 runtimecgo 代码 (在纯 Go 二进制中为 8K, 在 cgo 二进制中由系统分配)。

runtime 代码经常调用 systemstack, mcall, asmcgocall 暂时切换到系统栈去执行一些任务,这些任务不能被抢占、不能增长用户栈或者切换用户 goroutine 。 运行在系统栈上的代码隐式包含了不可抢占的语义,同时 GC 不会扫描系统栈,当一个 M 在系统栈上运行时,当前用户栈不会被运行。

nosplit 函数

大多数函数在开始执行时都会检查栈指针,并和当前 G 的栈关联绑定,如果栈需要增长,调用 morestack

函数可以通过标记注解为 //go:nosplit (在汇编中为 NOSPLIT) 来表示其不需要检查指针和绑定 G, 这有如下作用:

  • 函数必须运行在用户栈,但是不能调用栈增长,因为这可能会导致死锁
  • 函数必须不可被抢占
  • 函数可能没有关联有效的 G,例如函数运行在 runtime 启动前,或者其调用了 cgo 或信号处理

Splittable 函数确保栈上有一定的空间供 nosplit 函数运行,并且链接器检查 nosplit 函数调用的任何静态链都不能越界。

任何有 //go:nosplit 注解标记的函数应该在文档中说明其为什么是 nosplit 的。

错误处理和报告

用户代码中产生的错误如果可以被恢复,通常使用 panic 来抛出错误,然而在一些场景中,panic 可能会导致立刻生效的致命错误,例如在系统栈中调用 mallocgc。

大部分 runtime 错误是不可恢复的,这时应该使用 throw 打印堆栈跟踪信息并立刻结束进程,通常情况下,调用 throw 应该传入一个字符串常量以避免内存分配, 根据约定,错误的详细信息应该以 runtime: 字符串作为前缀,并在调用 throw 之前使用 print 或 println 打印出来。

对于用户代码导致的不可恢复的错误 (例如并发读写一个 map),使用 fatal

为了调试 runtime 错误,有两个比较实用的构建和运行选项 GOTRACEBACK=systemGOTRACEBACK=crash, panic 和 fatal 的输出描述来自 GOTRACEBACK, throw 的输出包含 runtime frames 和所有 goroutine 的元数据,等于加了选项 GOTRACEBACK=system 的效果,throw 是否导致程序崩溃取决于 GOTRACEBACK

同步

runtime 有多种同步机制,这些机制不仅语义上不同,而且和 goroutine 调度器以及操作系统调度器之间的交互也不一样。

最简单的是 mutex 互斥锁,提供了 lock 和 unlock 方法,主要用于短时间内保护一些共享数据 (互斥锁保护的临界区应尽可能地小),在 mutex 上阻塞时会直接阻塞整个 M, 不会和 Go 调度器进行交互。这意味着在 runtime 底层调用 mutex 是安全的,因为它会防止任何关联的 GP 被重新调度 (因为 M 被阻塞了)。

rwmutex 读写锁和 mutex 机制一样,这里不再赘述。

对于一次性通知,使用 note, 它提供了 notesleepnotewakeup 两个方法,和传统的 UNIX 的 sleep/wakeup 不同,note 不会产生竞态, 所以如果 notewakeup 发生了,notesleep 就会立即返回。note 在使用后可以通过 noteclear 重置,但是 noteclear 不能和 notesleep, notewakeup 产生竞争。 和 mutex 类似,在 note 上阻塞时会直接阻塞整个 M, 然而 note 提供了不同的方式来调用 sleep, notesleep 会防止任何关联的 G 和 P 被重新调度 (因为 M 被阻塞了), notesleep 的执行过程像阻塞在 系统调用 一样,允许 P 被重用以便运行另外的 G, 但是这依然比直接阻塞一个 G 效率要低,因为这需要耗费一个 M

如果需要直接和 goroutine 调度器进行交互,可以使用 goparkgoready, gopark 挂起当前的 goroutine (将其状态变为等待) 并将其从 调度器的运行队列中 移除, 然后调度另外一个 goroutine 到当前的 MP, goready 将一个挂起的 goroutine 的状态恢复到可运行并将其放入调度器的运行队列中。

总结如下:

Blocks Blocks Blocks
Interface G M P
(rw)mutex Y Y Y
note Y Y Y/N
park Y N N

原子性

runtime 使用 runtime/internal/atomic 包中自带的原子操作,这个包对应着 sync/atomic, 两者的区别在于由于历史原因导致的方法名差异, 以及 runtime 包有一些必需的额外方法。

一般情况下,我们对于 runtime 中的原子相关使用非常谨慎,并尽可能避免不必要的原子操作,如果一个变量已经被种同步机制所保护,那么对该变量的访问就不需要是原子操作了, 具体的原因如下:

  • 使用非原子操作和原子操作可以使代码可读性更好,对于一个变量的原子访问意味着该变量在别的地方可能存在并发操作
  • 非原子操作允许自动竞态检测,runtime 当前没有竞态检测器,原子访问会使竞态检测器忽略检测,非原子访问可以通过竞态检测器来检测是否会发生竞争
  • 非原子操作可以提高性能

对于任何共享变量的非原子操作,都应该在文档中说明变量是如何被保护的

常见的将原子操作和非原子操作混合在一起的场景:

  • 大部分都是读操作,写操作时变量被锁保护,在被锁保护的 临界区内,读操作不是必须为原子的,但是写操作必须是原子的,在被锁保护的 临界区外,读操作必须是原子的
  • 仅在 SWT 期间发生的读操作,且 STW 期间不会发生写操作,这时读操作不需要是原子的

虽然理论上是这样,但是 Go 内存模型给出的建议依然成立: “不要自作聪明” (这里应该是指编写并发程序时,应该严格遵守 Go 内存模型约束), runtime 的性能虽然很重要,但是健壮性更重要

堆外内存

一般情况下,runtime 会尝试使用常规的堆内存,然而在某些情况下 runtime 必须分配一些不被 GC 管理的堆外对象。如果这些堆外对象是内存管理的一部分, 或者调用方还未获取到一个 P (例如在调度器完成初始化之前),那这就是很有必要的。

申请堆外内存的方式有三种:

  • sysAlloc 直接从操作系统获取内存,申请的内存大小必须是系统内存页大小的整数倍,可以通过 sysFree 进行释放
  • persistentalloc 将多个小的内存申请合并到一个 sysAlloc 来避免内存碎片,但是通过 persistentalloc 申请的内存无法被释放
  • fixalloc 是一个 SLAB 风格的内存分配器,分配固定大小的内存,通过 fixalloc 分配的内存可以被释放,但是只可以被同等大小的 fixalloc 池复用,所以 fixalloc 只能被相同类型的对象复用

一般情况下,使用上述三种方法分配内存的类型都应该标记为 //go:notinheap

分配在堆外的内存不应该持有堆上的对象指针,除非遵守了下列规则:

  • 堆外内存持有的堆上对象的所有指针必须是 GC 的根对象,更详细地来说,所有指针必须可以通过一个全局变量访问到或者显式地添加 runtime.markroot 注解标记
  • 如果内存被重用了,堆指针在被标记为 GC 根对象并且对 GC 可见之前,必须初始化为零值,否则 GC 可能观察到过期的堆指针

零值初始化和归零

runtime 中有两种类型的零值初始化,具体取决于内存是否已经初始化为一个类型安全的状态。

如果内存不是一个类型安全的状态,意味着刚被分配并且第一次初始化使用,这时可能会包含一些不可预料的值 (类似 C 语言的中的变量未初始化), 所以必须使用 memclrNoHeapPointers 进行初始化或者只进行不包含指针的写操作,这样就不会触发写屏障。

内存可以调用 typedmemclrmemclrHasPointers 完成零值初始化并设置状态为类型安全,这样会触发写屏障。

Runtime 独有的编译器指令

除了 “go doc compile” 文档中标记的 “//go:” 之外,编译器仅在 runtime 包中支持一些额外的指令。

go:systemstack

go:systemstack 表明一个函数必须在系统栈上执行,这会通过一个特殊的函数预执行检查来动态验证。

go:nowritebarrier

go:nowritebarrier 告知编译器如果函数发生了写屏障,就触发一个错误 (这不会阻止写屏障的发生,只是一个简单的断言)。

通常在没有写屏障的理想情况下,应该使用 go:nowritebarrierrec. go:nowritebarrier, 但这不是必需的。

go:nowritebarrierrec and go:yeswritebarrierrec

go:nowritebarrierrec 告知编译器如果函数发生了递归调用,直到执行到有 go:yeswritebarrierrec 标记的函数为止,如果包含写屏障的话,就触发一个错误。

从逻辑上来说,编译器会在调用图上依次从每个有 go:nowritebarrierrec 标记的函数开始,如果其遇到了包含写屏障的函数,就触发一个错误, 直到遇到有 go:yeswritebarrierrec 标记的函数终止。

go:nowritebarrierrec 用来实现写屏障以避免无限循环。

上述两种指令在调度器中都有被使用,写屏障需要一个活跃的 P (getg().m.p != nil),但调度器代码经常在没有活跃的 P 的情况下运行,在这种情况下, go:nowritebarrierrec 会用在释放 P 的函数和没有 P 的函数上面,go:yeswritebarrierrec 会用到重新获取到 P 的代码上面。 因为这些都是函数级别的注解,所以释放 P 和获取 P 的功能代码必须被拆分为两个函数。

go:uintptrkeepalive

//go:uintptrkeepalive 指令必须跟在函数声明后面。

指令表明了函数的 uintptr 参数可能是一个指针并且在调用期间保持存活,即使从类型推断来看,调用期间根本用不到该参数。

指令和 //go:uintptrescapes 类似,但是不强制参数发生逃逸,由于栈增长无法辨识这些参数,因为该指令必须和 //go:nosplit 搭配使用 (在标记函数和调用传递过程中) 以防止栈增长。

从指针到 uintptr 的转换必须出现在函数的调用方参数列表中 (也就是调用方必须正确完成类型转换),该指令用于一些底层的系统调用的具体实现。

go:notinheap

go:notinheap 用于类型声明,表明一个类型不能被分配在 GC 堆上或栈上,尤其是指向该类型的指针在 runtime.inheap 指令检测中应该失败。

该类型可以被用于全局变量、堆外内存上的对象:

  • new(T), make([]T), append([]T, ...) 和隐式地将 T 分配到堆上是不允许的 (虽然隐式的分配在 runtime 中一直都不允许)
  • 一个指向普通类型的指针 (unsafe.Pointer 除外) 不能被转换为一个指向 go:notinheap 类型的指针,即使两者的底层类型相同
  • 任何包含 go:notinheap 的类型自身也是 go:notinheap 类型,如果结构体和数组包含 go:notinheap 元素,它们自身也是 go:notinheap 类型, Maps 和 channels 不允许有 go:notinheap 类型,简单地来说,任何隐式的 go:notinheap 类型都必须显式地标记为 go:notinheap
  • 指向 go:notinheap 类型的指针的写屏障可以忽略

最后说一下 go:notinheap 真正的好处:runtime 在底层内部数据结构中使用它来规避调度器和内存分配器的内存屏障,以达到避免非法检查和提高性能的目的, 这种机制是相当安全的并且不会降低 runtime 的可读性。

笔者寄语

本文翻译自官方标准库源文件 runtime/HACKING.md, 希望读者在读完本文后,能够理解 GMP 的基础概念和各类指令的作用,为进一步阅读标准库源代码打下坚实的基础。

扩展阅读

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。