这篇文章的目的是阐明在Go运行时编程与编写普通Go的不同之处。它侧重于普遍的概念,而不是特定接口的细节。
Scheduler structures(调度程序结构)
调度程序管理运行时使用的三种资源:Gs、Ms和Ps。即使您不使用调度程序,理解这些资源也很重要。
Gs, Ms, Ps
G 简单来说就是 goroutine.它由类型g表示。当一个goroutine退出时,它的g对象被返回到一个空闲g的池中,以后可以被其他goroutine重用。
一个“M”是一个操作系统线程,它可以执行用户Go代码,运行时代码、系统调用或空闲。 它是由类型 m表示. 一次可以有任意数量的Ms,因为任意数量的线程可能在系统调用中被阻塞。
最后,“P”表示执行用户Go代码所需的资源,如调度程序和内存分配器状态。它由类型 p表示。这里可以有 GOMAXPROCS 个Ps. P可以被认为是OS调度器中的一个CPU,而“P”类型的内容则类似于每个CPU的状态。 这是放置需要进行分片以提高效率的状态的好地方, but doesn`t need to be per-thread or
per-goroutine.
调度器的工作是匹配G(要执行的代码)、M(要执行代码的位置)和P(执行代码的权限和资源)。当M停止执行用户Go代码时,例如通过输入一个系统调用,它将它的P返回到空闲P池。为了恢复执行用户Go代码,例如从系统调用返回时,它必须从空闲池获取一个P。
所有 g 、 m 和 p 对象都是堆分配的,但从不释放,所以它们的内存保持类型稳定。因此,runtime可以避免在调度器的深度出现写障碍。
User stacks and system stacks(用户堆栈和系统堆栈)
每个存活的G都有一个与之相关联的用户堆栈,这是用户Go代码执行的地方。用户堆栈开始很小(例如,2K),并动态增长或收缩。
每个M都有一个与之关联的系统堆栈(也称为M的“g0”堆栈,因为它是作为存根G实现的),在Unix平台上,还有一个信号堆栈(也称为M的“gsignal”堆栈)。系统和信号堆栈不能增长,但足够大,可以执行运行时和cgo代码(8K纯Go二进制;系统分配的acgo二进制)。
运行时代码经常临时切换到使用systemstack, mcall ,或 asmcgocall 的系统堆栈,以执行任务,必须不被抢占,必须不增长用户堆栈,或切换用户goroutines。运行在系统堆栈上的代码是隐式不可抢占的,垃圾收集器不会扫描系统堆栈。在系统堆栈上运行时,当前用户堆栈不用于执行。
getg() and getg().m.curg
获取当前用户 g ,使用 getg().m.curg。
getg()单独返回当前g,但当在系统或信号堆栈上执行时,将分别返回当前M的g0或gsignal。这通常不是你想要的。
要确定您是在用户堆栈上运行还是在系统堆栈上运行,使用 getg() == getg().m.curg。
Error handling and reporting(错误处理和报告)
在用户代码中可以合理恢复的错误应该像往常一样使用panic。然而,在某些情况下,panic 会立即导致致命错误,例如在系统堆栈上调用时或在mallocgc期间调用时。
运行时中的大多数错误是不可恢复的。对于这些情况,使用throw,它转储回溯并立即终止进程。一般来说,throw 应该传递一个字符串常量,以避免在危险的情况下进行分配。按照惯例,在 throw 之前使用 print或 println 打印额外的详细信息,消息前缀为runtime: 。
对于运行时错误调试,使用 GOTRACEBACK=system 或 GOTRACEBACK=crash 运行是有用的。
Synchronization(同步)
runtime 具有多个同步机制。它们在语义上存在差异,特别是它们是与goroutine调度程序交互还是与OS调度程序交互。
最简单的是mutex,它使用lock和unlock操作。这应该用于在短时间内保护共享结构。在互斥锁上的阻塞直接阻塞M,不与Go调度程序交互。这意味着从运行时的最低级别使用它是安全的,但还可以防止任何相关的G和P被重新调度。rwmutex是相似的。
对于一次性通知,使用note,它提供notesleep和notewakeup。与传统的UNIX sleep / wakeup不同,note是没有竞争的,所以如果notwakeup已经发生,notesleep立即返回。使用noteclear后,note可以被重置,它不能与sleep或wake - up竞争。像互斥,阻止注意块M .然而,有不同的方法可以睡在一个“注意”:notesleep还能防止任何相关的延期G和P,而notetsleepg就像一个阻塞的系统调用,允许重用运行另一个P G .这仍然是低效率比直接阻断G,因为它消耗了一个M。
要直接与goroutine调度程序交互,使用gopark和 goready。gopark将当前goroutine置于等待状态,并将其从调度程序的运行队列中删除,然后在当前的M/P上调度另一个goroutine。goready将一个停放的goroutine恢复到“可运行”状态,并将其添加到运行队列中。
总之,
<table>
<tr><th></th><th colspan="3">Blocks</th></tr>
<tr><th>Interface</th><th>G</th><th>M</th><th>P</th></tr>
<tr><td>(rw)mutex</td><td>Y</td><td>Y</td><td>Y</td></tr>
<tr><td>note</td><td>Y</td><td>Y</td><td>Y/N</td></tr>
<tr><td>park</td><td>Y</td><td>N</td><td>N</td></tr>
</table>
Atomics(原子)
运行时在“runtime/internal/atomic”中使用自己的atomics包。这对应于sync/atomic,但由于历史原因,函数有不同的名称,运行时还需要一些额外的函数。
一般来说,我们会认真考虑运行时原子的使用,并尽量避免不必要的原子操作。如果对变量的访问有时受到另一种同步机制的保护,那么已经保护的访问通常不需要是原子的。这有几个原因:
- 在适当的地方使用非原子访问或原子访问使代码更加自文档化。对变量的原子访问意味着存在其他地方可以并发地访问该变量。
- 非原子访问允许自动竞态检测。运行时目前没有race检测器,但将来可能会有。原子访问会击败竞争检测器,而非原子访问则允许竞争检测器检查您的假设。
3.非原子访问可以提高性能。
当然,任何对共享变量的非原子访问都应该记录下来,以说明如何保护这种访问。
混合原子访问和非原子访问的一些常见模式是:
读取——主要是由锁保护更新的变量。在锁定区域内,读操作不需要是原子的,但写操作需要。在锁定区域之外,读操作需要是原子的。
只在STW期间发生的读操作(在STW期间不可以发生写操作)不需要是原子的。
也就是说,Go内存模型的建议是:不要太聪明。运行时的性能很重要,但它的健壮性更重要。
Unmanaged memory(非托管内存)
通常,运行时尝试使用常规堆分配。然而,在某些情况下,运行时必须在垃圾收集堆之外的*非托管内存中分配对象。如果对象是内存管理器本身的一部分,或者必须在调用者可能没有P的情况下分配它们,那么这是必要的。
有三种分配非托管内存的机制:
sysAlloc直接从操作系统获得内存。它的大小是系统页面大小的好几倍,但是可以通过sysFree释放。
persistentalloc将多个较小的分配组合到一个sysAlloc中,以避免碎片。然而,没有办法释放persistentalloced对象(因此得名)。
fixalloc是一个板书样式的分配器,它分配固定大小的对象。可以释放fixalloced对象,但此内存只能由相同的fixalloc池重用,因此它只能被相同类型的对象重用。
通常,使用这些类型分配的类型应该标记为//go:notinheap (见下文)。
在非托管内存中分配的对象必须不包含堆指针,除非也遵守以下规则:
从非托管内存到堆的任何指针都必须是垃圾收集根。更具体地说,任何指针要么必须可以通过全局变量访问,要么必须作为显式的垃圾收集根添加到
runtime.markroot中。如果内存被重用,堆指针在成为可见的GC根之前必须进行零初始化。否则,GC可能会观察到陈旧的堆指针。参见“零初始化与零化”。
Zero-initialization versus zeroing(零初始化和归零)
在运行时有两种类型的归零,这取决于内存是否已经初始化为类型安全状态。
如果内存不是类型安全状态,这意味着它可能包含“垃圾”,因为它是刚刚分配的,它是初始化为第一次使用,那么它必须使用memclrnoheappointer或非指针写进行零初始化。它不执行写屏障。
如果内存已经处于类型安全状态,并且只是被设置为0值,那么必须使用typedmemclr 或 memclrhaspointer进行常规写操作。它执行写屏障。
Runtime-only compiler directives(只在运行时的编译器指令)
除了//go:指令外,编译器只在运行时支持其他指令。
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 .在这种情况下,运行”:nowritebarrierrec”用在函数释放p或运行没有和“go:yeswritebarrierrec”时使用代码获得积极的p .因为这些函数层次注释,代码版本或获得p可能需要跨越两个函数。
go:notinheap
go:notinheap适用于类型声明。它指出永远不能从GC d堆中分配类型。具体地说,指向这种类型的指针必须总是在“运行时”失败。inheap”检查。该类型可用于全局变量、堆栈变量或非托管内存中的对象(例如,通过sysAlloc、persistentalloc、fixalloc分配,或从手动管理的span)。具体地说:
new(T),make([]T),append([]T, ...)and implicit heap allocation of T are disallowed. (Though implicit allocations are disallowed in the runtime anyway.)指向普通类型(
unsafety . pointer除外)的指针不能被转换为指向go:notinheap类型的指针,即使它们具有相同的基础类型。任何包含
go:notinheap类型的类型本身就是go:notinheap。如果结构体和数组的元素是“go:notinheap”,则它们是“go:notinheap”。地图和通道go:notinheap类型是不允许的。为了保持内容的显式性,任何类型声明中隐含的类型为go:notinheap的类型也必须显式地标记为go:notinheap。在指向
go:notinheap类型的指针上的写障碍可以省略。
最后一点是go:notinheap的真正好处。运行时将其用于低级内部结构,以避免在调度程序和内存分配器中存在非法或低效的内存障碍。这种机制相当安全,而且不会影响运行时的可读性。