当前位置: 首页 > 科技观察

Golang运行时的一些八卦

时间:2023-03-12 00:10:55 科技观察

最近在研究性能优化的时候,看到golang运行时包下的一个文件HACKING.md,觉得挺有意思的。看完之后感觉自己对runtime的理解有所提高,于是想翻译一下。本章内容会有一定的深度,需要有一定基础的读者阅读。限于篇幅,这里无法详尽展开。本文档的读者是运行时开发人员,所以有很多内容是我们正常使用之外的。本文档经常被编辑,当前内容可能会随着时间的推移而过时。本文档旨在说明编写运行时代码与普通go代码之间的区别,因此它侧重于一些通用概念而不是一些详细的实现。调度器结构调度器管理三种在运行时很重要的类型:G、M、P。即使不写调度器相关的代码,也应该了解这些概念。G、M、P一个G是一个goroutine,在runtime中用类型g表示。当一个goroutine退出时,g对象会被放入一个空闲的g对象池中,供后续goroutine使用(译者注:减少内存分配开销)。一个M是一个系统线程,系统线程可以执行用户的go代码、runtime代码、系统调用或者waitidle。在运行时由类型m表示。同时,可能有任意数量的M,因为任意数量的M都可能阻塞在系统调用中。(译者注:当一个M执行阻塞系统调用时,会解开M和P的绑定,并创建一个新的M去执行P上的其他G。)最后,一个P代表用户go代码的执行。所需资源,如调度器状态、内存分配器状态等。在运行时用类型p表示。P的数量恰好等于GOMAXPROCS。一个P可以理解为操作系统调度器中的CPU,p类型可以理解为各个CPU的状态。这里可以放一些需要高效共享但不是每个P(PerP)或每个M(PerM)的状态(译者注:意思是,你可以放一些P级共享的数据)。调度器的工作就是把一个G(需要执行的代码)、一个M(代码执行的地方)、一个P(代码执行所需要的权限和资源)结合起来。当M停止执行用户代码时(例如进入阻塞的系统调用时),它需要将其P返回到空闲P池;为了继续执行用户的go代码(比如从阻塞的系统调用时间退出),需要从空闲P池中获取一个P。g、m、p对象都分配在堆上,永不释放,所以它们的内存使用非常稳定。得益于此,运行时可以避免调度器实现中的写屏障(译者注:垃圾回收需要的屏障,会带来一些性能开销)。用户栈和系统栈每个存活(非死)的G都会有一个关联的用户栈,用户代码就是在这个用户栈上执行的。用户堆栈开始时很小(例如2K),然后会动态增长和收缩。每个M都有一个关联的系统栈(也叫g0栈,因为这个栈也是g实现的);如果是在Unix平台上,也会有信号栈(也叫gsignalstack)。系统堆栈和信号堆栈不能增长,但足够大以运行任何运行时和cgo代码(纯go二进制中为8K,由系统分配以防cgo)。运行时代码经常会通过调用systemstack、mcall或asmcgocall来临时切换到系统栈来执行一些特殊的任务,比如:那些不能被抢占的,那些不应该扩展用户栈的,那些会切换用户goroutines的。在系统栈上运行的代码是隐式不可抢占的,垃圾收集器不扫描系统栈。当M在系统堆栈上运行时,当前用户堆栈未运行。getg()和getg().m.curg如果要获取当前用户的g,需要使用getg().m.curg。虽然getg()会返回当前的g,但是当它在系统栈或者信号栈上执行的时候,会返回当前M的g0或者gsignal,这很可能不是你想要的。如果要判断当前是在系统栈还是用户栈执行,可以使用getg()==getg().m.curg。错误处理和报告在用户代码中,有一些错误可以通过像往常一样使用panic合理地恢复,但在某些情况下panic可能会立即导致致命错误,例如调用或执行mallocgc时。大多数运行时错误是不可恢复的。对于这些不可恢复的错误,应该使用throw。throw将打印回溯并立即终止进程。throw应该传递一个字符串常量以避免在这种情况下需要为字符串分配内存。按照惯例,更多的信息应该在throw之前使用print或者println打印出来,并且应该从runtime开始。对于运行时的错误调试,有一个很实用的方法就是设置GOTRACEBACK=system或者GOTRACEBACK=crash。同步运行时有多种同步机制。这些同步机制不仅在语义上不同,而且与go调度器和操作系统调度器之间的交互也不同。最简单的就是mutex,可以用lock和unlock来操作。该方法主要用于短期保护一些共享数据(长期性能不佳)。阻塞在一个互斥量上会直接阻塞整个M而无需与go调度器进行交互。因此,在运行时在最底层使用互斥量是安全的,因为它也会阻止关联的G和P被重新调度(M被阻塞,无法执行调度)。rwmutex也类似。如果要进行一次性通知,可以使用note。note提供notesleep和notewakeup。与传统的UNIX睡眠/唤醒不同,note是无竞争的,因此如果notewakeup已经发生,notesleep将立即返回。note可以在使用后通过noteclear重置,但注意noteclear无法与notesleep和notewakeup竞争。和互斥量一样,阻塞在一个note上会阻塞整个M。但是note提供了不同的调用sleep的方法:notesleep防止关联的G和P被重新调度;notesleepg的行为类似于阻塞系统调用,允许P被重用以运行另一个G。不过,这比直接阻塞G效率低,因为那会消耗M。如果您需要直接与go调度程序交互,您可以使用gopark和goready。gopark挂起当前的goroutine——将其变为等待状态并将其从调度程序的运行队列中移除——然后将另一个goroutine调度到当前的M或P。goready将挂起的goroutine恢复为可运行状态并将其放入运行队列。总结如下表:Atomicruntime在runtime/internal/atomic中使用了自己的一些原子操作。这个对应sync/atomic,只是因为历史原因方法名称有些不同,还有一些额外的运行时需要的方法。总的来说,我们在运行时对原子的使用是非常谨慎的,尽量避免不必要的原子操作。如果对变量的访问已经受到另一个同步机制的保护,则受保护的访问通常不需要是原子的。这样做的主要原因如下:合理使用非原子操作和原子操作使得代码更加清晰可读。对一个变量的原子操作意味着在另一个地方可能有对这个变量的并发操作。非原子操作允许自动竞争检测。运行时本身目前没有竞争检测器,但将来可能会有。原子操作会导致竞态检测器忽略这个检测,但是非原子操作可以通过竞态检测器验证你的假设(是否会发生竞态)。非原子操作可以提高性能。当然,共享变量上的所有非原子操作都应该记录操作是如何受到保护的。原子操作和非原子操作混合的一些常见场景是:大多数操作是读取,而写入是对受锁保护的变量。在锁保护的范围内,读操作不必是原子的,但写操作必须是原子的。在锁保护范围之外,读操作必须是原子的。STW期间只发生读操作,STW期间不会有写操作。那么这个时候读操作就不需要是原子的了。话虽如此,GoMemoryModel给出的建议仍然适用,不要[too]聪明。运行时的性能固然重要,但健壮性(robustness)更为重要。非托管内存(Unmanagedmemory)一般情况下,运行时会尝试使用普通方法申请内存(堆上的内存,由gc管理),但在某些情况下,运行时必须申请一些不受gc管理的非托管内存(非托管内存)。这是必须的,因为有可能这块内存就是内存管理器本身,或者调用者没有P(译者注:比如在调度器初始化之前,没有P)。申请堆外内存有三种方式:sysAlloc直接从操作系统获取内存,申请的内存必须是系统页表长度的整数倍。它可以由sysFree发布。persistentalloc将多个小内存申请合并为一个大的sysAlloc以避免碎片。但是,顾名思义,persistentalloc申请的内存是无法释放的。fixalloc是一种SLAB风格的内存分配器,用于分配固定大小的内存。通过fixalloc分配的对象可以被释放,但内存只能由同一个fixalloc池重用。所以fixalloc适用于同一类型的对象。一般来说,所有使用上述三种方法分配的内存类型都应该标记为//go:notinheap(见下文)。在堆外内存中分配的对象不应包含堆上的指针对象,除非遵循以下规则:从堆外内存到堆的所有指针必须是垃圾收集根。也就是说,所有指针都必须可以通过全局变量访问,或者使用runtime.markroot进行显式标记。如果内存被重用,堆上的指针必须在被标记为GC根并对GC可见之前进行零初始化(见下文)。否则,GC可能会观察到陈旧的堆指针。请参阅下面的零初始化与归零。零初始化与归零在运行时有两种类型的零初始化,具体取决于内存是否已初始化为类型安全状态。如果内存不处于类型安全状态,则意味着它可能包含一些垃圾值,因为它刚刚被第一次分配和初始化。体验过C语言的同学应该能明白什么意思),那么这块内存一定要使用memclrNoHeapPointers进行零初始化或者无指针写入。这样就不会触发writebarrier(译者注:Writebarrier是GC中的一个概念)。可以通过typedmemclr或memclrHasPointers将内存写入零值,将其设置为类型安全状态。这会触发写屏障。Runtime-onlycompilerdirectives(编译器指令)除了godoccompile中提到的//go:编译指令外,编译器还支持runtime包中的一些附加指令。go:systemstackgo:systemstack表示一个函数必须运行在系统栈上,由一个特殊的函数序言动态验证。go:nowritebarriergo:nowritebarrier告诉编译器如果下面的函数包含写屏障就抛出一个错误(这并不能阻止写屏障的产生,这纯粹是一种假设)。通常你应该使用go:nowritebarrierrec。go:nowritebarrier当且仅当写屏障“最好不是”但不是正确性所必需时使用。go:nowritebarrierrec和go:yeswritebarrierrecgo:nowritebarrierrec告诉编译器如果以下函数及其调用(递归向下)直到go:yeswritebarrierrec包含写屏障,则触发错误。从逻辑上讲,编译器将从生成的调用图上的每个go:nowritebarrierrec函数开始,直到遇到go:yeswritebarrierrec函数(或结束)。如果遇到包含写屏障的函数,则会引发错误。go:nowritebarrierrec主要用于自己实现写屏障,避免死循环。两个编译指示都用于调度程序。写屏障需要一个活动的P(getg().m.p!=nil),但是与调度程序相关的代码可以在没有活动的P的情况下运行。在这种情况下,go:nowritebarrierrec将用于运行一些释放的函数PornoP,go:yeswritebarrierrec将用于重新获取代码上的P。因为这些是函数级别的注释,所以释放P和获取P的代码必须拆分为两个函数。go:notinheapgo:notinheap适用于类型声明,表示一个类型一定不能分配到GC堆上。特别是,指向此类型的指针应该始终无法通过runtime.inheap检查。此类型可用于全局变量、堆栈上的变量或堆外内存中的对象(例如由sysAlloc、persistentalloc、fixalloc或其他手动管理的跨度分配)。特别是:new(T)、make([]T)、append([]T,...)和T的隐式堆分配是不允许的(尽管隐式分配从来都是不允许的)。指向普通类型(unsafe.Pointer除外)的指针不能转换为指向go:notinheap类型的指针,即使它们具有相同的底层类型。任何包含go:notinheap类型的类型本身都是go:notinheap类型。如果结构体和数组包含go:notinheap元素,则它们本身就是go:notinheap类型。map和channel不允许有go:notinheap类型。为了使事情更清楚,任何隐式go:notinheap类型都应该显式标记为go:notinheap。可以忽略类型为go:notinheap的指针的写屏障。最后一点是go:notinheap类型的真正好处。运行时在后台使用它来避免调度程序和内存分配器中的内存障碍,以避免非法检查或只是为了提高性能。这种方法相当安全,不会降低运行时的可读性。