谈到编译器指示,我们在平时工作中几乎不会使用,除非你觉得你的代码瓶颈出现在编译期,不过了解掌握编译器指示对于我们阅读 golang 源码还是挺有帮助的。

什么是编译器指示?

编译器接受注释形式的指示。比如我们常见的 //go:xxx 的形式出现在方法前面上方。为了将其与非指示注释区分开,编译器指示要求在注释开头和指示名称之间不需要空格。但是由于它们是注释,故而不了解指示约定或特定指示的工具可以像其他注释一样跳过指示。其大体分为两大类:

  1. // line/ * line 开头的行指示

行指示有如下几种形式:

  1. `//line :line
  2. //line :line:col
  3. //line filename:line
  4. //line filename:line:col
  5. /*line :line*/
  6. /*line :line:col*/
  7. /*line filename:line*/
  8. /*line filename:line:col*/`

为了被识别为行指示,注释必须以 // line/ * line 开头,后跟一个空格,并且必须至少包含一个冒号。行指示是历史上的特例,主要出现在机器生成的代码中,以便编译器和调试器将原始输入中的位置报告给生成器。故而这个不是我们今天的重点。

  1. //go:name 形式的指示

这种形式的编译器指示都必须放在自己的行中,注释前只能有空格和制表符。每个指示都紧随其后的 Go 代码,该代码通常必须是一个声明。我们今天主要来认识几个常见的这种形式的编译器指示

编译器指示分类

//go:noescape

//go:noescape 指示后面必须跟没有主体的函数声明 (意味着该函数具有非 Go 编写的实现),它指定函数不允许作为参数传递的任何指针逃逸到堆中或函数返回值中。编译器在对调用该函数的 Go 代码进行逃逸分析时,可以使用此信息。

啥是逃逸?

逃逸分析属于编译器优化的一种方式,Go 内存也是分为堆和栈,相比 C、C++ 在栈还是堆上分配内存是程序员手动控制的,而在 Go 中,如果一个值超过了函数调用的生命周期,编译器会自动将其从函数栈转移到堆中。这种行为被称为逃逸。

阻止了变量逃逸到堆上,最显而易见的好处是 GC 压力小了。

但缺点是:这么做意味着绕过了编译器的逃逸分析,无论如何都不会出现逃逸,函数返回则其相关的资源也一并销毁,使用不当运行时很可能导致严重后果。

//go:linkname

//go:linkname 是初看 go 源码常见的一个编译器指示,因为有时候你跟着跟着就发现函数只有声明没有函数体,也没有汇编实现。

该编译器指示作用是使用 importpath.name 作为源码中声明为 localname 的变量或函数的目标文件符号名称。但这样就破坏了类型系统和模块化,因此只有引用了 unsafe 包才可以使用。这么解释可能有点儿绕,简单来说就是 我们 importpath.name 来调用时实际执行的是 localname 但前提引用了 unsafe

举个栗子: sync.Mutex 进行 Lock 操作时如果 goroutine 抢占锁失败会调用 runtime_SemacquireMutex(&m.sema, queueLifo, 1) 来阻塞等待。

  1. `func runtime_SemacquireMutex(s *uint32, lifo bool, skipframes int)
  2. `

实际函数实现在 runtime/sema.go

  1. `import "unsafe"
  2. //go:linkname sync_runtime_SemacquireMutex sync.runtime_SemacquireMutex
  3. func sync_runtime_SemacquireMutex(addr *uint32, lifo bool, skipframes int) {
  4. semacquire1(addr, lifo, semaBlockProfile|semaMutexProfile, skipframes)
  5. }
  6. `

//go:nowritebarrier

//go:nowritebarrier 告诉编译器如果跟着的函数包含写屏障则触发一个错误,但并不会阻止写屏障的生成。

//go:nowritebarrierrec 和 //go:yeswritebarrierrec

这对编译器指示蛮有意思的。主要出现在调度器代码中。 //go:nowritebarrierrec 告诉编译器当前函数及其调用的函数(允许递归)直到发现 //go:yeswritebarrierrec 为止,若期间遇到写屏障则触发一个错误。

这对编译器指示都是在调度器中使用。写屏障需要一个活跃的 P,但是调度器中的相关代码可能不需要一个活跃的 P 的情况下运行。此时, //go:nowritebarrierrec 用在不需要 P 的函数上,而 //go:yeswritebarrierrec 用在重新获取 P 的函数上。

例如: runtime.main 运行时调用 sysmon 运行不需要 P

//go:systemstack

//go:systemstack 表示函数必须在系统栈上运行。

如 : 分配 npages 页的手动管理的一个 span

  1. `//go:systemstack
  2. func (h *mheap) allocManual(npages uintptr, typ spanAllocType) *mspan {
  3. if !typ.manual() {
  4. throw("manual span allocation called with non-manually-managed type")
  5. }
  6. return h.allocSpan(npages, typ, 0)
  7. }
  8. `

//go:noinheap

//go:noinheap 适用于类型声明,表示一个类型必须不能分配到 GC 堆上。好处是 runtime 在底层结构中使用它来避免调度器和内存分配中的写屏障以避免非法检查或提高性能。

//go:noinline

inline 是编译期将函数调用处替换为被调用函数主体的一种编译优化手段, //go:noinline 意思就是不要内联。

  • 优势

  • 减少函数调用开销 提高执行速度

  • 替换后更大函数体为其他编译优化提供可能

  • 消除分支改善空间局部性和指令顺序性

  • 缺点

  • 代码复制带来的空间增长

  • 大量重复代码会降低缓存命中率

内联是把双刃剑,在我们实际使用过程,你需要谨慎考虑做好平衡。 //go:noinline 编译器指示为我们做平衡提供了一种手段。

//go:nosplit

//go:nosplit 作用是跳过栈溢出检测。

什么是栈溢出?

一个 goroutine 的初始栈大小是有限制的,并且比较小,所以才可以支持并发很多的 goroutine,并且高效调度。实际上每个新的 goroutine 会被 runtime 分配初始化 2KB 大小的栈空间。但它的大小并不是一直保持不变的,随着一个 goroutine 进行工作的过程中,可能会超出最初分配的栈空间的限制,也就是可能栈溢出。

那这个时候怎么办呢?为防止这种情况发生,runtime 确保 goroutine 在不够用的时候,会创建一个相当于原来两倍大小的新栈,并将原来栈的上下文拷贝到新栈上,这个过程称为栈分裂(stack-split),这样使得 goroutine 栈能够动态调整大小。那么必然需要有一个检测的机制,来保证可以及时地知道栈不够用了,然后再去增长。

实际上编译器是通过每一个函数的开头和结束位置插入指令防止 goroutine 爆栈

而我们确定一定不会爆栈的函数,可以用 //go:nosplit 来提示编译器跳过这个机制,不要再这些函数的开头和结束部分插入这些检查指令。

这样做不执行栈溢出检查,虽然可以提高性能,但同时使用不当也有可能发生 stack overflow 而导致编译失败。

栗子:

channel 发送的代码

  1. `// entry point for c <- x from compiled code
  2. //go:nosplit
  3. func chansend1(c *hchan, elem unsafe.Pointer) {
  4. chansend(c, elem, true, getcallerpc())
  5. }
  6. `

//go:norace

//go:norace 表示禁止进行竞态检测。它指定竞态检测器必须忽略函数的内存访问。除了节约了点编译时间没发现啥其他好处。
https://www.tuicool.com/articles/32uqArI