最近在研究性能优化的时候,看到了 golang runtime 包下的一个文档HACKING.md
觉得颇有意思,读完之后觉得对于 runtime 的理解更上一层,于是想着翻译一下。
本章内容会有一定深度,需要有一定基础的读者,限于篇幅在这里不可能完全展开各个细节。
这一篇文档面向的读者是 runtime 的开发者,所以有很多内容在我们普通使用中是接触不到的。
这篇文档是会被经常编辑的,并且随着时间推移目前的内容可能会过时。这篇文档旨在说明写 runtime 代码和普通的 go 代码有什么不同,所以关注于一些普遍的概念而不是一些细节的实现。
调度器结构
调度器管理三个在 runtime 中十分重要的类型:G
、M
和P
。哪怕你不写 scheduler 相关代码,你也应当要了解这些概念。
G、M 和 P
一个G
就是一个 goroutine,在 runtime 中通过类型g
来表示。当一个 goroutine 退出时,g
对象会被放到一个空闲的g
对象池中以用于后续的 goroutine 的使用(译者注:减少内存分配开销)。
一个M
就是一个系统的线程,系统线程可以执行用户的 go 代码、runtime 代码、系统调用或者空闲等待。在 runtime 中通过类型m
来表示。在同一时间,可能有任意数量的M
,因为任意数量的M
可能会阻塞在系统调用中。(译者注:当一个M
执行阻塞的系统调用时,会将M
和P
解绑,并创建出一个新的M
来执行P
上的其它G
。)
最后,一个P
代表了执行用户 go 代码所需要的资源,比如调度器状态、内存分配器状态等。在 runtime 中通过类型p
来表示。P
的数量精确地(exactly)等于GOMAXPROCS
。一个P
可以被理解为是操作系统调度器中的 CPU,p
类型可以被理解为是每个 CPU 的状态。在这里可以放一些需要高效共享但并不是针对每个P
(Per P
)或者每个M
(Per M
)的状态(译者注:意思是,可以放一些以P
级别共享的数据)。
调度器的工作是将一个G
(需要执行的代码)、一个M
(代码执行的地方)和一个P
(代码执行所需要的权限和资源)结合起来。当一个M
停止执行用户代码的时候(比如进入阻塞的系统调用的时候),就需要把它的P
归还到空闲的P
池中;为了继续执行用户的 go 代码(比如从阻塞的系统调用退出的时候),就需要从空闲的P
池中获取一个P
。
所有的g
、m
和p
对象都是分配在堆上且永不释放的,所以它们的内存使用是很稳定的。得益于此,runtime 可以在调度器实现中避免写屏障(译者注:垃圾回收时需要的一种屏障,会带来一些性能开销)。
getg()
和getg().m.curg
如果想要获取当前用户的g
,需要使用getg().m.curg
。
getg()
虽然会返回当前的g
,但是当正在系统栈或者signal
栈上执行的时候,会返回的是当前M
的g0
或者gsignal
,而这很可能不是你想要的。
如果要判断当前正在系统栈上执行还是用户栈上执行,可以使用getg() == getg().m.curg
。
栈
每个存活着的(non-dead)G
都会有一个相关联的用户栈,用户的代码就是在这个用户栈上执行的。用户栈一开始很小(比如 2K),并且动态地生长或者收缩。
每一个M
都有一个相关联的系统栈(也被称为g0
栈,因为这个栈也是通过g
实现的);如果是在 Unix 平台上,还会有一个 signal
栈(也被称为gsignal
栈)。系统栈和signal
栈不能生长,但是足够大到运行任何 runtime 和 cgo 的代码(在纯 go 二进制中为 8K,在 cgo 情况下由系统分配)。
runtime 代码经常通过调用systemstack
、mcall
或者asmcgocall
临时性的切换到系统栈去执行一些特殊的任务,比如:不能被抢占的、不应该扩张用户栈的和会切换用户 goroutine 的。在系统栈上运行的代码隐含了不可抢占的含义,同时垃圾回收器不会扫描系统栈。当一个M
在系统栈上运行时,当前的用户栈是没有被运行的。
nosplit 函数
大多数函数都以检查堆栈指针和当前 G 的堆栈边界的 prologue 开始,并在堆栈需要增长时调用 morestack。
可以使用//go:nosplit
(或者在汇编中使用NOSPLIT
)标记功能,以指示它们不应该具有此 prologue。这有几个用途:
必须在用户堆栈上运行的功能,但不能调用堆栈增长。例如因为这会导致死锁,或者因为它们在堆栈上有无类型的 words。
在进入时不可被抢占的功能。
可能没有有效 G 的功能。例如,runtime 初始化代码中的功能,或者可能从 C 代码进入的功能,例如 cgo 回调或信号处理程序。
可拆分函数确保堆栈上有一定数量的空间,以便在其中运行不可拆分函数,链接器检查任何静态链的不可拆分函数调用是否不超过此限制。
任何具有//go:nosplit
注释的函数都应在其文档注释中解释为什么是不可拆分的。
错误处理和上报
在用户代码中,有一些可以被合理地(reasonably)恢复的错误可以像往常一样使用panic
,但是有一些情况下,panic
可能导致立即的致命的错误,比如在系统栈中调用或者当执行mallocgc
时。
大部分的 runtime 的错误是不可恢复的,对于这些不可恢复的错误应该使用throw
,throw
会打印出traceback
并立即终止进程。throw
应当被传入一个字符串常量以避免在该情况下还需要为 string 分配内存。根据约定,更多的信息应当在throw
之前使用print
或者println
打印出来,并且应当以runtime.
开头。
对于不可恢复的错误,如果用户代码有可能导致故障(例如并发 map 写入),请使用 fatal。
为了进行 runtime 的错误调试,可以使用GOTRACEBACK=system
或GOTRACEBACK=crash
运行。panic
和fatal
的输出由GOTRACEBACK
描述。throw
的输出始终包括 runtime stack、元数据和所有 goroutines,无论GOTRACEBACK
是什么(即与GOTRACEBACK=system
等效)。是否让throw
崩溃仍然受GOTRACEBACK
控制。
同步
runtime 中有多种同步机制,这些同步机制不仅是语义上不同,和 go 调度器以及操作系统调度器之间的交互也是不一样的。
最简单的就是mutex
,可以使用lock
和unlock
来操作。这种方法主要用来短期(长期的话性能差)地保护一些共享的数据。在mutex
上阻塞会直接阻塞整个M
,而不会和 go 的调度器进行交互。因此,在 runtime 中的最底层使用 mutex
是安全的,因为它还会阻止相关联的G
和P
被重新调度(M
都阻塞了,无法执行调度了)。rwmutex
也是类似的。
如果是要进行一次性的通知,可以使用note
。note
提供了notesleep
和notewakeup
。不像传统的 UNIX 的sleep/wakeup
,note
是无竞争的(race-free),所以如果notewakeup
已经发生了,那么notesleep
将会立即返回。note
可以在使用后通过noteclear
来重置,但是要注意noteclear
和notesleep
、notewakeup
不能发生竞争。类似mutex
,阻塞在note
上会阻塞整个M
。然而,note
提供了不同的方式来调用sleep
:notesleep
会阻止相关联的G
和P
被重新调度;notetsleepg
的表现却像一个阻塞的系统调用一样,允许P
被重用去运行另一个G
。尽管如此,这仍然比直接阻塞一个G
要低效,因为这需要消耗一个M
。
如果需要直接和 go 调度器交互,可以使用gopark
和goready
。gopark
挂起当前的 goroutine——把它变成waiting
状态,并从调度器的运行队列中移除——然后调度另一个 goroutine 到当前的M
或者P
。goready
将一个被挂起的 goroutine 恢复到runnable
状态并将它放到运行队列中。
总结起来如下表:
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 中 atomic 的使用非常谨慎,并且尽可能避免不需要的原子操作。如果对于一个变量的访问已经被另一种同步机制所保护,那么这个已经被保护的访问一般就不需要是原子的。这么做主要有以下原因:
- 合理地使用非原子和原子操作使得代码更加清晰可读,对于一个变量的原子操作意味着在另一处可能会有并发的对于这个变量的操作。
- 非原子的操作允许自动的竞争检测。runtime 本身目前并没有一个竞争检测器,但是未来可能会有。原子操作会使得竞争检测器忽视掉这个检测,但是非原子的操作可以通过竞争检测器来验证你的假设(是否会发生竞争)。
- 非原子的操作可以提高性能。
当然,所有对于一个共享变量的非原子的操作都应当在文档中注明该操作是如何被保护的。
有一些比较普遍的将原子操作和非原子操作混合在一起的场景有:
- 大部分操作都是读,且写操作被锁保护的变量。在锁保护的范围内,读操作没必要是原子的,但是写操作必须是原子的。在锁保护的范围外,读操作必须是原子的。
- 仅仅在 STW 期间发生的读操作,且 STW 期间不会有写操作。那么这个时候,读操作不需要是原子的。
话虽如此,Go Memory Model
给出的建议仍然成立Don't be [too] clever
。runtime 的性能固然重要,但是鲁棒性(robustness)却更加重要。
堆外内存(Unmanaged memory)
一般情况下,runtime 会尝试使用普通的方法来申请内存(堆上内存,gc 管理的),然而在某些情况 runtime 必须申请一些不被 gc 所管理的堆外内存(unmanaged memory)。这是很必要的,因为有可能该片内存就是内存管理器自身,或者说调用者没有一个P
(译者注:比如在调度器初始化之前,是不存在P
的)。
有三种方式可以申请堆外内存:
sysAlloc
直接从操作系统获取内存,申请的内存必须是系统页表长度的整数倍。可以通过sysFree
来释放。persistentalloc
将多个小的内存申请合并在一起为一个大的sysAlloc
以避免内存碎片(fragmentation)。然而,顾名思义,通过persistentalloc
申请的内存是无法被释放的。fixalloc
是一个SLAB
风格的内存分配器,分配固定大小的内存。通过fixalloc
分配的对象可以被释放,但是内存仅可以被相同的fixalloc
池所重用。所以fixalloc
适合用于相同类型的对象。
一般来说,使用任何这些分配的类型应通过嵌入runtime/internal/sys.NotInHeap
来标记为非堆上类型。
在堆外内存所分配的对象不应该包含堆上的指针对象,除非同时遵守了以下的规则:
- 所有在堆外内存指向堆上的指针都必须是垃圾回收的根(garbage collection roots)。也就是说,所有指针必须可以通过一个全局变量所访问到,或者显式地使用
runtime.markroot
来标记。 - 如果内存被重用了,堆上的指针在被标记为 GC 根并且对 GC 可见前必须 以 0 初始化(zero-initialized,见后文)。不然的话,GC 可能会观察到过期的(stale)堆指针。可以参见下文
Zero-initialization versus zeroing
.
Zero-initialization versus zeroing
在 runtime 中有两种类型的零初始化,取决于内存是否已经初始化为了一个类型安全的状态。
如果内存不在一个类型安全的状态,意思是可能由于刚被分配,并且第一次初始化使用,会含有一些垃圾值(译者注:这个概念在日常的 Go 代码中是遇不到的,如果学过 C 语言的同学应该能理解什么意思),那么这片内存必须使用memclrNoHeapPointers
进行zero-initialized
或者无指针的写。这不会触发写屏障(译者注:写屏障是 GC 中的一个概念)。
内存可以通过typedmemclr
或者memclrHasPointers
来写入零值,设置为类型安全的状态。这会触发写屏障。
Runtime-only 编译指令(compiler directives)
除了go doc compile
中注明的//go:
编译指令外,编译器在 runtime 包中支持了额外的一些指令。
go:systemstack
go:systemstack
表明一个函数必须在系统栈上运行,这个会通过一个特殊的函数前引(prologue)动态地验证。
go:nowritebarrier
go:nowritebarrier
告知编译器如果以下函数包含了写屏障,触发一个错误(这不会阻止写屏障的生成,只是单纯一个假设)。
一般情况下你应该使用go:nowritebarrierrec
。go:nowritebarrier
当且仅当“最好不要”写屏障,但是非正确性必须的情况下使用。
go:nowritebarrierrec 与 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 参数可能是已转换为 uintptr 的指针值,并且在整个调用期间必须保持活动状态,即使从类型本身看在调用期间对象不再需要。
该指令类似于 //go:uintptrescapes,但不强制逃逸参数。由于堆栈增长不理解这些参数,此指令必须与 //go:nosplit 一起使用(在标记函数中以及所有传递参数的函数中),以防止堆栈增长。
从指针到 uintptr 的转换必须出现在此函数的任何调用参数列表中。此指令用于某些低级系统调用实现。