阅读go源码,你需要了解这几个编译器指示
长安城里的一切都在无可避免的走向庸俗。
谈到编译器指示,我们在平时工作中几乎不会使用,除非你觉得你的代码瓶颈出现在编译期,不过了解掌握编译器指示对于我们阅读golang源码还是挺有帮助的。
什么是编译器指示?
编译器接受注释形式的指示。比如我们常见的//go:xxx
的形式出现在方法前面上方。为了将其与非指示注释区分开,编译器指示要求在注释开头和指示名称之间不需要空格。但是由于它们是注释,故而不了解指示约定或特定指示的工具可以像其他注释一样跳过指示。其大体分为两大类:
以 // line
或/ * line
开头的行指示
行指示有如下几种形式:
//line :line
//line :line:col
//line filename:line
//line filename:line:col
/*line :line*/
/*line :line:col*/
/*line filename:line*/
/*line filename:line:col*/
为了被识别为行指示,注释必须以// line
或/ * line
开头,后跟一个空格,并且必须至少包含一个冒号。行指示是历史上的特例,主要出现在机器生成的代码中,以便编译器和调试器将原始输入中的位置报告给生成器。故而这个不是我们今天的重点。
以 //go:name
形式的指示
这种形式的编译器指示都必须放在自己的行中,注释前只能有空格和制表符。每个指示都紧随其后的Go代码,该代码通常必须是一个声明。我们今天主要来认识几个常见的这种形式的编译器指示
编译器指示分类
//go:noescape
//go:noescape
指示后面必须跟没有主体的函数声明(意味着该函数具有非Go编写的实现),它指定函数不允许作为参数传递的任何指针逃逸到堆中或函数返回值中。编译器在对调用该函数的Go代码进行逃逸分析时,可以使用此信息。
啥是逃逸?
逃逸分析属于编译器优化的一种方式,Go内存也是分为堆和栈,相比C、C++在栈还是堆上分配内存是程序员手动控制的,而在Go中,如果一个值超过了函数调用的生命周期,编译器会自动将其从函数栈转移到堆中。这种行为被称为逃逸。
阻止了变量逃逸到堆上,最显而易见的好处是GC压力小了。
但缺点是:这么做意味着绕过了编译器的逃逸分析,无论如何都不会出现逃逸,函数返回则其相关的资源也一并销毁,使用不当运行时很可能导致严重后果。
//go:linkname
//go:linkname
是初看go源码常见的一个编译器指示,因为有时候你跟着跟着就发现函数只有声明没有函数体,也没有汇编实现。
//go:linkname localname importpath.name
该编译器指示作用是使用importpath.name
作为源码中声明为localname
的变量或函数的目标文件符号名称。但这样就破坏了类型系统和模块化,因此只有引用了unsafe
包才可以使用。这么解释可能有点儿绕,简单来说就是 我们importpath.name
来调用时实际执行的是localname 但前提引用了unsafe
举个栗子:sync.Mutex
进行Lock
操作时如果goroutine
抢占锁失败会调用runtime_SemacquireMutex(&m.sema, queueLifo, 1)
来阻塞等待。
func runtime_SemacquireMutex(s *uint32, lifo bool, skipframes int)
实际函数实现在runtime/sema.go
import "unsafe"
//go:linkname sync_runtime_SemacquireMutex sync.runtime_SemacquireMutex
func sync_runtime_SemacquireMutex(addr *uint32, lifo bool, skipframes int) {
semacquire1(addr, lifo, semaBlockProfile|semaMutexProfile, skipframes)
}
//go:nowritebarrier
//go:nowritebarrier
告诉编译器如果跟着的函数包含写屏障则触发一个错误,但并不会阻止写屏障的生成。
//go:nowritebarrierrec和//go:yeswritebarrierrec
这对编译器指示蛮有意思的。主要出现在调度器代码中。//go:nowritebarrierrec
告诉编译器当前函数及其调用的函数(允许递归)直到发现//go:yeswritebarrierrec
为止,若期间遇到写屏障则触发一个错误。
这对编译器指示都是在调度器中使用。写屏障需要一个活跃的P,但是调度器中的相关代码可能不需要一个活跃的P的情况下运行。此时,//go:nowritebarrierrec
用在不需要P的函数上,而//go:yeswritebarrierrec
用在重新获取P的函数上。
例如:runtime.main
运行时调用sysmon
运行不需要P
// Always runs without a P, so write barriers are not allowed.
//go:nowritebarrierrec
func sysmon() {
... ...
}
//go:systemstack
//go:systemstack
表示函数必须在系统栈上运行。
如 :分配npages页的手动管理的一个span
//go:systemstack
func (h *mheap) allocManual(npages uintptr, typ spanAllocType) *mspan {
if !typ.manual() {
throw("manual span allocation called with non-manually-managed type")
}
return h.allocSpan(npages, typ, 0)
}
//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发送的代码
// entry point for c <- x from compiled code
//go:nosplit
func chansend1(c *hchan, elem unsafe.Pointer) {
chansend(c, elem, true, getcallerpc())
}
//go:norace
//go:norace
表示禁止进行竞态检测。它指定竞态检测器必须忽略函数的内存访问。除了节约了点编译时间没发现啥其他好处。
如果阅读过程中发现本文存疑或错误的地方,可以关注公众号留言。如果觉得还可以 帮忙点个在看😁