Go的编译运行和项目管理|青训营笔记

148 阅读6分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第5篇笔记

1.go的编译运行

1.编译 go build

编译目标go文件生成可执行文件到当前目录(go是跨平台语言,支持多种操作系统,可执行文件格式基于操作系统不同而不同)

go build example/01-hello/main.go

2.编译并运行 go run

go run example/01-hello/main.go

2.项目管理

go module

go module 是Go语言从 1.11 版本之后官方推出的版本管理工具,并且从 Go1.13 版本开始,go module 成为了Go语言默认的依赖管理工具。

常用的go mod命令如下表所示:

命令作用
go mod download下载依赖包到本地(默认为 GOPATH/pkg/mod 目录)
go mod edit编辑 go.mod 文件
go mod graph打印模块依赖图
go mod init初始化当前文件夹,创建 go.mod 文件
go mod tidy增加缺少的包,删除无用的包
go mod vendor将依赖复制到 vendor 目录下
go mod verify校验依赖
go mod why解释为什么需要依赖

go.mod 提供了 module、require、replace 和 exclude 四个命令:

  • module 语句指定包的名字(路径);
  • require 语句指定的依赖项模块;
  • replace 语句可以替换依赖项模块;
  • exclude 语句可以忽略依赖项模块。

go的逃逸分析

如果可以,Go编译器会尽可能将变量分配到到栈上。但是,当编译器无法证明函数返回后,该变量没有被引用,那么编译器就必须在堆上分配该变量,以此避免悬挂指针(dangling pointer)。另外,如果局部变量非常大,也会将其分配在堆上。

那么,Go是如何确定的呢?答案就是:逃逸分析。编译器通过逃逸分析技术去选择堆或者栈,逃逸分析的基本思想如下:检查变量的生命周期是否是完全可知的,如果通过检查,则可以在栈上分配。否则,就是所谓的逃逸,必须在堆上进行分配。

Go语言虽然没有明确说明逃逸分析规则,但是有以下几点准则,是可以参考的。

  • 逃逸分析是在编译器完成的,这是不同于jvm的运行时逃逸分析;
  • 如果变量在函数外部没有引用,则优先放到栈中;
  • 如果变量在函数外部存在引用,则必定放在堆中;

我们可通过go build -gcflags '-m -l'命令来查看逃逸分析结果,其中-m 打印逃逸分析信息,-l禁止内联优化。下面,我们通过一些案例,来熟悉一些常见的逃逸情况。

情况一:变量类型不确定

package main
​
import "fmt"func main() {
    a := 666
    fmt.Println(a)
}

逃逸分析结果如下

$ go build -gcflags '-m -l' main.go
# command-line-arguments
./main.go:7:13: ... argument does not escape
./main.go:7:13: a escapes to heap

可以看到,分析结果告诉我们变量a逃逸到了堆上。但是,我们并没有外部引用啊,为啥也会有逃逸呢?为了看到更多细节,可以在语句中再添加一个-m参数。得到信息如下

$ go build -gcflags '-m -m -l' main.go
# command-line-arguments
./main.go:7:13: a escapes to heap:
./main.go:7:13:   flow: {storage for ... argument} = &{storage for a}:
./main.go:7:13:     from a (spill) at ./main.go:7:13
./main.go:7:13:     from ... argument (slice-literal-element) at ./main.go:7:13
./main.go:7:13:   flow: {heap} = {storage for ... argument}:
./main.go:7:13:     from ... argument (spill) at ./main.go:7:13
./main.go:7:13:     from fmt.Println(... argument...) (call parameter) at ./main.go:7:13
./main.go:7:13: ... argument does not escape
./main.go:7:13: a escapes to heap

a逃逸是因为它被传入了fmt.Println的参数中,这个方法参数自己发生了逃逸。

func Println(a ...interface{}) (n int, err error)

因为fmt.Println的函数参数为interface类型,编译期不能确定其参数的具体类型,所以将其分配于堆上。

情况二:暴露给外部指针

package main
​
func foo() *int {
    a := 666
    return &a
}
​
func main() {
    _ = foo()
}

逃逸分析如下,变量a发生了逃逸。

$ go build -gcflags '-m -m -l' main.go
# command-line-arguments
./main.go:4:2: a escapes to heap:
./main.go:4:2:   flow: ~r0 = &a:
./main.go:4:2:     from &a (address-of) at ./main.go:5:9
./main.go:4:2:     from return &a (return) at ./main.go:5:2
./main.go:4:2: moved to heap: a

这种情况直接满足我们上述中的原则:变量在函数外部存在引用。这个很好理解,因为当函数执行完毕,对应的栈帧就被销毁,但是引用已经被返回到函数之外。如果这时外部从引用地址取值,虽然地址还在,但是这块内存已经被释放回收了,这就是非法内存,问题可就大了。所以,很明显,这种情况必须分配到堆上。

情况三:变量所占内存较大

func foo() {
    s := make([]int, 10000, 10000)
    for i := 0; i < len(s); i++ {
        s[i] = i
    }
}
​
func main() {
    foo()
}

逃逸分析结果

$ go build -gcflags '-m -m -l' main.go
# command-line-arguments
./main.go:4:11: make([]int, 10000, 10000) escapes to heap:
./main.go:4:11:   flow: {heap} = &{storage for make([]int, 10000, 10000)}:
./main.go:4:11:     from make([]int, 10000, 10000) (too large for stack) at ./main.go:4:11
./main.go:4:11: make([]int, 10000, 10000) escapes to heap

可以看到,当我们创建了一个容量为10000的int类型的底层数组对象时,由于对象过大,它也会被分配到堆上。这里我们不禁要想一个问题,为啥大对象需要分配到堆上。

这里需要注意,在上文中没有说明的是:在Go中,执行用户代码的goroutine是一种用户态线程,其调用栈内存被称为用户栈,它其实也是从堆区分配的,但是我们仍然可以将其看作和系统栈一样的内存空间,它的分配和释放是通过编译器完成的。与其相对应的是系统栈,它的分配和释放是操作系统完成的。在GMP模型中,一个M对应一个系统栈(也称为M的g0栈),M上的多个goroutine会共享该系统栈。

不同平台上的系统栈最大限制不同。

$ ulimit -s
8192

以x86_64架构为例,它的系统栈大小最大可为8Mb。我们常说的goroutine初始大小为2kb,其实说的是用户栈,它的最小和最大可以在runtime/stack.go中找到,分别是2KB和1GB。

// The minimum size of stack used by Go code
_StackMin = 2048
...
var maxstacksize uintptr = 1 << 20 // enough until runtime.main sets it for real

而堆则会大很多,从1.11之后,Go采用了稀疏的内存布局,在Linux的x86-64架构上运行时,整个堆区最大可以管理到256TB的内存。所以,为了不造成栈溢出和频繁的扩缩容,大的对象分配在堆上更加合理。那么,多大的对象会被分配到堆上呢。

通过测试,小菜刀发现该大小为64KB(这在Go内存分配中是属于大对象的范围:>32kb),即s :=make([]int, n, n)中,一旦n达到8192,就一定会逃逸。注意,网上有人通过fmt.Println(unsafe.Sizeof(s))得到s的大小为24字节,就误以为只需分配24个字节的内存,这是错误的,因为实际还有底层数组的内存需要分配。

情况四:变量大小不确定

我们将情况三种的示例,简单更改一下。

package main
​
func foo() {
    n := 1
    s := make([]int, n)
    for i := 0; i < len(s); i++ {
        s[i] = i
    }
}
​
func main() {
    foo()
}

得到逃逸分析结果如下

$ go build -gcflags '-m -m -l' main.go
# command-line-arguments
./main.go:5:11: make([]int, n) escapes to heap:
./main.go:5:11:   flow: {heap} = &{storage for make([]int, n)}:
./main.go:5:11:     from make([]int, n) (non-constant size) at ./main.go:5:11
./main.go:5:11: make([]int, n) escapes to heap

这次,我们在make方法中,没有直接指定大小,而是填入了变量n,这时Go逃逸分析也会将其分配到堆区去。可见,为了保证内存的绝对安全,Go的编译器可能会将一些变量不合时宜地分配到堆上,但是因为这些对象最终也会被垃圾收集器处理,所以也能接受。

总结

本文只列举了逃逸分析的部分例子,实际的情况还有很多,理解思想最重要。这里就不过多列举了。

既然Go的堆栈分配对于开发者来说是透明的,编译器已经通过逃逸分析为对象选择好了分配方式。那么我们还可以从中获益什么?

答案是肯定的,理解逃逸分析一定能帮助我们写出更好的程序。知道变量分配在栈堆之上的差别,那么我们就要尽量写出分配在栈上的代码,堆上的变量变少了,可以减轻内存分配的开销,减小gc的压力,提高程序的运行速度。

所以,你会发现有些Go上线项目,它们在函数传参的时候,并没有传递结构体指针,而是直接传递的结构体。这个做法,虽然它需要值拷贝,但是这是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。当然该做法不是绝对的,如果结构体较大,传递指针将更合适。

因此,从GC的角度来看,指针传递是个双刃剑,需要谨慎使用,否则线上调优解决GC延时可能会让你崩溃