HACKING.md

这是作者对runtime设计的一个阐述,翻译内容是这样的

这是一份原型文档,虽然在当下可能已经有点过时了.目的是讲述go程序运行原理,与我们写的go代码有什么不同.着重讲述一些大概的概念而不是详细的细节.

调度器结构

调度器管理了三种贯穿整个运行环境的资源:Gs,Ms和Ps.即时不需要操作运行环境也是需要了解的.

Gs,Ms,Ps

一个”G”就可以简单的代表一个goroutine,有类型g表示,当一个goroutine退出,g对象就会被放在空闲g列表(池),之后被其他goroutine复用.

一个”M”代表一个可以真正执行用户代码,运行时代码以,系统调用,也可以空闲.由结构体m表示.在某一时刻可能有任意多的m,因为同时间可能发生任意多的系统调用

最后,一个”P”代表执行用户代码所需要的资源,比如调度器和内存分配状态,由结构体p表示.数量可以由GOMAXPROCS决定.一个P就像操作系统的CPU,p的内容就像每个CPU的状态.为了调度效率,将共享状态放在p中比放在每个m中或者每个g里面要好.

调度的工作是匹配G(要执行的代码),M(执行时机)和P(执行的资源和环境).当M停止执行用户代码的时候(比如发生系统调用的时候),将P放回P池,在恢复执行之前,再到P池里面取出一个P.

所有的g,m,p都是在堆里分配的,并且是不释放的资源,所以他们是稳定存在的,不需要运行时通过write barrier.

用户栈和系统栈

每个没有进入到死亡状态的G都有一个用户栈,是Go代码运行的地方.用户栈在开始分配的时候很小,在运行过程中动态增长或缩减

每个M都有一个系统栈(也就是M的”g0”栈,因为它是固定的),并且在Unix平台中有一个信号栈(gsignal).系统栈和信号栈不会增长,但是足以执行运行时代码和cgo代码(纯go二进制文件是8k;cgo由系统分配)

运行时经常会调用systemstack,mcall,或者asmcgocall临时地切换到系统栈取执行那些无法增长栈空间或者且切换用户goroutine的非抢占任务.在系统栈上运行的代码一定是非抢占式的,而且gc无法扫描系统栈.在系统栈上运行的时候,当前的用户栈也不是用来执行代码的.

getg()getg().m.curg

获取当前的用户g,用 getg().m.curg.getg()也返回当前的g,但是在系统栈或者信号栈执行代码的时候,这个方法返回的是当前m的”g0”或者”gsignal”.

所以可以用getg() == getg().m.curg判断当前是在用户栈上运行还是系统栈上运行

错误处理反馈

一般情况下,用户可以捕捉到panic进行处理(recover).但是,有一些情况,比如在系统栈上调用,或者在mallocgc时候调用的panic是灾难性的,无法恢复.

大部分在运行时抛出的错误是无法recover的.比如,用throw会立即抛出错误路径幷终止进程.一般情况下,throw应该传递一个常量string参数,避免不安全的分配.最终,在throw之前会打印出以”runtime”开头的错误信息.

为了runtime错误进行debug,可以加参数 GOTRACEBACK=system或者GOTRACEBACK=crash.

同步

运行时有很多不同的同步机制.这与语境上有关,比如goroutine调度上,和系统调度.

最简单的是 mutex, 只要使用lockunlock就行了.这个用在短时间内保护共享结构.在mutex的是阻塞会直接阻塞m,与Go调度没什么关系.这意味着它是安全的也是runtime中最低级别的,因为阻碍了相关的G和P进行重调度.rwmutex也是一样的.

在一次性的通知情形下,使用note,提供了 notesleepnotewakeup两个方法.与传统UNIX系统中sleep/wakeup不同.note是一种无静态的调用,在调用notesleepnotewakeup之后立刻返回.note可以在用户调用noteclear之后重置,这也决定了note在sleep或者wakeup的时候不能有竟态.notemutex一样,阻塞m,但是它进入睡眠状态的方式是不一样的:notesleep也阻碍了相关G和P的重调度,但是notetsleepg就像系统调用的阻塞,不影响在P上运行另一个G.当然,这仍然比阻塞G更低效.

goparkgoready.gopark直接操作goroutine,将当前G放到等待队列幷从M/P就绪队列中移除,然后运行另一个就绪G,goready将一个停下的G重新放入就绪队列等待运行

总结一下,阻塞关系就是:

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

Atomics

运行时有自己的atomic包 runtime/internal/atomic,与sync/atomic对应,但是应为历史遗留问题,函数有不同的名称,还有一下附加的功能

一般而言,我们在运行时要慎重使用atomics,最好避免使用atomics操作.如果一个变量有可能被其他的同步机制锁保护,这个被保护的变量一般也不需要是原子的.这其中有个方面的理由:

  1. 使用non-atomic或者atomic会使得相应代码self-documenting.因为atomic的介入往往意味着有会有线程问题.

  2. non-atomic操作允许自动竟态检测,运行时虽然不会惊醒多线程静态检测,但是在不久的将来会.而atomic操作不允许竟态检测.而non-atomic允许你自定义的进行静态检测.

  3. non-atomic可以提升性能

当然,任何对共享变量的non-atomic操作都应该描述清楚如何进行保护

有些模式混用了atomic和non-atomic:

  • Read-mostly 变量在更新的时候进行保护,而在锁定的时候,读操作不需要是原子的.在锁定区域外,读操作是原子的

  • 在STW的读,因为在STW的时候不会发生写操作

也就是说,就像 Go Memory Model的观点,”Don’t be [too] clever”.这是在runtime里的情况,在其他地方也一样.

Unmanaged memory(非托管内存?)

通常,runtime尝试使用正常的堆内存分配.但是,有时runtime必须在无法gc的地方(堆之外)进行分配,也就是unmanaged memory.这是必要的,如果对象是memory manager自己的或者调用方可能没有P.

有三种Unmanaged memory 分配机制

  • sysAlloc直接从操作系统中获取内存.内存可能来自各个分页,但是通过sysFree可以全部释放

  • fragmentation从小页中获取内存,联合成sysAlloc一样的,这样避免造成太多的内存碎片,但是正如它的名字,这些内存没有办法释放

  • fixalloc是SLAB算法分配内存的,它分配固定大小的内存,可以释放,但是这些内存只能由同一个fixalloc池复用.所以这些内存只能被同一种类型数据复用.

通常,使用以上方法分配内存的地方都会注释编译标记//go:notinheap.

在Unmanaged memory分配的对象,除了以下情况不许序包含堆指针.

  1. 任何Unmanaged memory里包含的堆内指针必须明确加入gc根(源)runtime.markroot

  2. 如果内存被复用,他们被标记为GC roots钱必须是零初始化的

Zero-initialization versus zeroing

在运行时有两种类型的零化,取决于内存是否已经初始化成类型安全的状态.

如果内存不是类型安全的,意味着它可能包含”garbage”,因为它刚被分配并且正在首次初始化,这种情况,必须使用zero-initialized,也就是memclrNoHeapPointers或者非指针写.这种情况下不会有写屏障write barriers.

如果内存已经是类型安全的并且简单的设置为零值了,那么必须使用typedmemclr或者memclrHasPointers,.这种方式有write barriers.

Runtime-only compiler directives

除了”//go:”除了用在文档之外,还可以直接对编译进行干预

go:systemstack

go:systemstack表示函数必须运行在系统栈,这会在函数的开始进行动态检查

go:nowritebarrier

go:nowritebarrier让函数在出现write barriers的时候报错(无法阻止write barriers的生成,只是简单的插入)

通常的使用场景是,最好不要write barriers,但是不是必要的go:nowritebarrierrec. go:nowritebarrier

go:nowritebarrierrec and go:yeswritebarrierrec

go:nowritebarrierrec 让编译器在函数及其递归调用的的函数中在write barrier的时候抛出错误.相应的 go:yeswritebarrierrec保证编译器进行write barrier

逻辑上,编译器floods每个一go:nowritebarrierrec 开始的函数调用图,当遇到一个包含write barrier 的函数的时候.在go:yeswritebarrierrec是没有flood的

go:nowritebarrierrec用在write barrier的实现上,防止循环初始化

两种方式都是直接在调度器使用,write barrier需要一个有效P(getg().m.p != nil)而调度器代码经常不在一个有效P上运行.在这种情况,go:nowritebarrierrec使用在P的释放函数上,或者在没有P的时候,go:yeswritebarrierrec运行在重新获取P的函数上.因为有函数层级概念,释放和获取P要分成2个函数

go:notinheap

go:notinheap运用在类型声明,这意味着一个类型绝不会在GC’d(不GC?)堆里分配.所以,这种类型的指针总是会在runtime.inheap的检查中失败.这种类型可以是全局变量,栈变量或者非托管内存对象( sysAlloc, persistentalloc,fixalloc分配).

  1. new(T), make([]T), append([]T, ...) 以及隐藏类型(泛型)分配是不允许的

  2. 正常类型的指针(不是unsafe.Pointer)不能被转换成一个go:notinheap 类型,即时它们有相同的底层

  3. 任何包含 go:notinheap类型的类型也是go:notinheap的,数组和结构体的元素是go:notinheap的,那么也是.map和channel不允许有go:notinheap.为了明确任何隐藏go:notinheap类型必须声明

  4. go:notinheap的指针上的Write barriers将被忽略

最后,go:notinheap真正的用处.运行时在低层级的内部结构中使用,以避免调度器和内存分配器上的memory barriers ,因为在这里他们是无效的,这种方式的安全的,也不会影响可读性.