Go随笔 | 编译技巧和协程堆栈
交叉编译
交叉编译主要是两个编译环境参数 $GOOS 和 $GOARCH 的设定。$GOOS代表编译的目标系统,$GOARCH代表编译的处理器体系结构。
$GOOS可选值如下:
|
|
$GOARCH可选值如下:
|
|
选择性编译
虽然Go可以跨平台编译,但却无法解决所有系统的差异性;尤其直接调用操作系统函数的时候。相同功能编写类似xxx_windows.go xxx.Linux.go
文件,根据操作系统编译对应源文件。而不是在文件中用if else
规划执行路径;要实现选择性编译需要在文件顶部增加构建标记:
|
|
此标记必须出现在文件顶部,仅由空行或其他注释行开头。也就是必须在Package 语句前。
此标记后接约束参数,格式为 // +build A,B !C,D 逗号为且,空格为或,!为非
。代表编译此文件需符合 (A且B) 或 ((非C)且D) 。A和C的可选参数可参见本文上面的 $GOOS参数,B和D的可选参数可参见$GOARCH 。比如:
|
|
1.17以及后续版本有了新写法
很不幸,上面+build的写法在1.17后续版本中会被改进,以后推荐用新版本书写。
|
|
新版本go在编译时候只需要新写法,当前新老写法可以同时存在,但是只会采用新写法编译。老版本不识别的情况下,只能提供+build的写法。
编译选项
排除调试信息 -ldflags="-s -w"
Go 编译器默认编译出来的程序会带有符号表和调试信息,一般来说 release 版本可以去除调试信息以减小二进制体积。
|
|
- -s:忽略符号表和调试信息。
- -w:忽略DWARFv3调试信息,使用该选项后将无法使用gdb进行调试。
逃逸分析 -gcflags=-m
C 语言用 malloc
和 free
手动在堆上分配和回收内存。Go 语言中,堆内存是通过垃圾回收机制自动管理的,无需开发者指定。那么,Go 编译器怎么知道某个变量需要分配在栈上,还是堆上呢?编译器决定内存分配位置的方式,就称之为逃逸分析(escape analysis)。逃逸分析由编译器完成,作用于编译阶段。
编译时可以借助选项 -gcflags=-m
,查看变量逃逸的情况:
|
|
利用逃逸分析提升性能–传值vs传指针
传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。
一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。
参考阅读:
https://blog.csdn.net/qq_33690342/article/details/113685490
upx压缩可执行程序
upx 是一个常用的压缩动态库和可执行文件的工具,通常可减少 50-70% 的体积。
upx 有很多参数,最重要的则是压缩率,1-9
,1
代表最低压缩率,9
代表最高压缩率。
$ go build -o server main.go && upx -9 server
汇编分析
要想深入研究编程语言的性能,查看汇编代码是最好的办法,他能让你很直观的了解到,这样写代码CPU究竟是如何运行的。这方面的知识参考下面文章。
参考:
https://zhuanlan.zhihu.com/p/364071110
Go汇编代码特点
要了解 Go 的汇编器最重要的是要知道 Go 的汇编器不是对底层机器的直接表示,即 Go 的汇编器没有直接使用目标机器的汇编指令。Go 汇编器所用的指令,一部分与目标机器的指令一一对应,而另外一部分则不是。这是因为编译器套件不需要汇编器直接参与常规的编译过程。
相反,编译器使用了一种半抽象的指令集,并且部分指令是在代码生成后才被选择的。汇编器基于这种半抽象的形式工作,所以虽然你看到的是一条 MOV 指令,但是工具链针对对这条指令实际生成可能完全不是一个移动指令,也许会是清除或者加载。也有可能精确的对应目标平台上同名的指令。概括来说,特定于机器的指令会以他们的本尊出现, 然而对于一些通用的操作,如内存的移动以及子程序的调用以及返回通常都做了抽象。细节因架构不同而不一样,我们对这样的不精确性表示歉意,情况并不明确。
汇编器程序的工作是对这样半抽象指令集进行解析并将其转变为可以输入到链接器的指令。
如何输出 Go 汇编
对于写好的 go 源码,生成对应的 Go 汇编,大概有下面几种
- 方法1:先使用
go build -gcflags "-N -l" main.go
生成对应的可执行二进制文件 再使用go tool objdump -s "main." main
反编译获取对应的汇编
反编译时"main."
表示只输出 main 包中相关的汇编"main.main"
则表示只输出 main 包中 main 方法相关的汇编
- 方法2:使用
go tool compile -S -N -l main.go
这种方式直接输出汇编 - 方法3:使用
go build -gcflags="-N -l -S" main.go
直接输出汇编。注意:在使用这些命令时,加上对应的 flag,否则某些逻辑会被编译器优化掉,而看不到对应完整的汇编代码 -l 禁止内联 -N 编译时禁止优化 -S 输出汇编代码
更多参考:
https://blog.csdn.net/weixin_40486544/article/details/108392947
https://zhuanlan.zhihu.com/p/56750445
https://zhuanlan.zhihu.com/p/509151846
https://www.jianshu.com/p/d4bad7822cfe
https://blog.csdn.net/panda_8/article/details/106722195 Plan9汇编手册
编译器指示
编译器接受注释形式的指示。比如我们常见的//go:xxx
的形式出现在方法前面上方。为了将其与非指示注释区分开,编译器指示要求在注释开头和指示名称之间不需要空格。但是由于它们是注释,故而不了解指示约定或特定指示的工具可以像其他注释一样跳过指示。
go:nosplit
go:nosplit
作用是跳过栈溢出检测。
什么是栈溢出?一个goroutine的初始栈大小是有限制的,并且比较小,所以才可以支持并发很多的goroutine,并且高效调度。实际上每个新的goroutine会被runtime分配初始化2KB大小的栈空间。但它的大小并不是一直保持不变的,随着一个goroutine进行工作的过程中,可能会超出最初分配的栈空间的限制,也就是可能栈溢出。
那这个时候怎么办呢?为防止这种情况发生,runtime确保goroutine在不够用的时候,会创建一个相当于原来两倍大小的新栈,并将原来栈的上下文拷贝到新栈上,这个过程称为栈分裂(stack-split),这样使得goroutine栈能够动态调整大小。那么必然需要有一个检测的机制,来保证可以及时地知道栈不够用了,然后再去增长。
实际上编译器是通过每一个函数的开头和结束位置插入指令防止goroutine爆栈。
如果我们确定一定不会爆栈的函数,可以用go:nosplit
来提示编译器跳过这个机制,不要再这些函数的开头和结束部分插入这些检查指令。这样做不执行栈溢出检查,虽然可以提高性能,但同时使用不当也有可能发生stack overflow
而导致编译失败。
go:inline 和 go:noinline
inline
是编译期将函数调用处替换为被调用函数主体的一种编译优化手段,go:noinline
意思就是不要内联。当然编译器也会自我评判是否要内联。
- 优势
- 减少函数调用开销 提高执行速度
- 替换后更大函数体为其他编译优化提供可能
- 消除分支改善空间局部性和指令顺序性
- 缺点
- 代码复制带来的空间增长
- 大量重复代码会降低缓存命中率
内联是把双刃剑,在我们实际使用过程,你需要谨慎考虑做好平衡。go:noinline
编译器指示为我们做平衡提供了一种手段。
go:noescape
go:noescape
指示后面必须跟没有主体的函数声明(意味着该函数具有非Go编写的实现),它指定函数不允许作为参数传递的任何指针逃逸到堆中或函数返回值中。编译器在对调用该函数的Go代码进行逃逸分析时,可以使用此信息。
啥是逃逸?逃逸分析属于编译器优化的一种方式,Go内存也是分为堆和栈,相比C、C++在栈还是堆上分配内存是程序员手动控制的,而在Go中,如果一个值超过了函数调用的生命周期,编译器会自动将其从函数栈转移到堆中。这种行为被称为逃逸。
阻止了变量逃逸到堆上,最显而易见的好处是GC压力小了。
但缺点是:这么做意味着绕过了编译器的逃逸分析,无论如何都不会出现逃逸,函数返回则其相关的资源也一并销毁,使用不当运行时很可能导致严重后果。
go:norace
go:norace
表示禁止进行竞态检测。它指定竞态检测器必须忽略函数的内存访问。除了节约了点编译时间没发现啥其他好处。
go:linkname
go:linkname
是初看go源码常见的一个编译器指示,因为有时候你跟着跟着就发现函数只有声明没有函数体,也没有汇编实现。
go:nowritebarrier
go:nowritebarrier
告诉编译器如果跟着的函数包含写屏障则触发一个错误,但并不会阻止写屏障的生成。
go:nowritebarrierrec 和 go:yeswritebarrierrec
这对编译器指示蛮有意思的。主要出现在调度器代码中。go:nowritebarrierrec
告诉编译器当前函数及其调用的函数(允许递归)直到发现go:yeswritebarrierrec
为止,若期间遇到写屏障则触发一个错误。
这对编译器指示都是在调度器中使用。写屏障需要一个活跃的P,但是调度器中的相关代码可能不需要一个活跃的P的情况下运行。此时,go:nowritebarrierrec
用在不需要P的函数上,而go:yeswritebarrierrec
用在重新获取P的函数上。
go:systemstack
go:systemstack
表示函数必须在系统栈上运行。
go:noinheap
go:noinheap
适用于类型声明,表示一个类型必须不能分配到GC堆上。好处是runtime在底层结构中使用它来避免调度器和内存分配中的写屏障以避免非法检查或提高性能。
内联优化
参考阅读:
https://zhuanlan.zhihu.com/p/575337464
https://mp.weixin.qq.com/s/0X4lasAf5Sbt_tromlqwIQ
(完)
- 原文作者: 闪电侠
- 原文链接:https://chende.ren/2021/08/31125739-go-build.html
- 版权声明:本作品采用 开放的「署名 4.0 国际 (CC BY 4.0)」创作共享协议 进行许可