背景:说实话,使用Golang来作为日常的cmdline程序开发也有一两年了,之前作为一名Ops来说,会使用Golang去开发一些常用的工具来实现生产环境的各种常规操作以及日常运维管理,而对于整个Golang语言内部的一些细节都不甚了解。但随着对Ops要求的提高,以及向SRE理念转型的需要,我们越来越需要深入理解一些内部底层的原理,这样在我们去管理的我们的Kubernetes集群,或者其他的一些内部系统时才能真正做到游刃有余。

在Golang中,一个对象最终是分配到还是呢,接下来我们就一起通过逃逸分析来一起学习学习。

概念介绍

逃逸分析

逃逸分析是编译器用来确定由程序创建的值所处位置的过程。具体来说,编译器执行静态代码分析,以确定是否可以将值放在构造函数的栈(帧)上,或者该值是否必须逃逸上。

所以,更通俗一点讲,逃逸分析就是确定一个对象是要放在还是上,一般遵循如下规则:

  • 1.是否有非局部调用(对象定义之外的调用).即:如果有可能被引用,那通常会被分配到堆上,否则就在栈上
  • 2.如果对象太大(即使没有被引用),无法放在栈区也是可能放到上的

总结起来就是: 如果在函数外部引用,必定在堆中分配;如果没有外部引用,优先在栈中分配;如果一个函数返回的是一个(局部)变量的地址,那么这个变量就发生逃逸

避免逃逸的好处:

  • 1.减少gc的压力,不逃逸的对象分配在栈上,当函数返回时就回收了资源,不需要gc标记清除
  • 2.逃逸分析完后可以确定哪些变量可以分配在栈上,栈的分配比堆快,性能好(系统开销少)
  • 3.减少动态分配所造成的内存碎片

如何进行逃逸分析

注意: Golang程序中是在编译阶段确定逃逸的,而非运行时,因此我们可以使用go build的相关工具来进行逃逸分析.

分析工具:

  • 1.通过编译工具查看详细的逃逸分析过程(go build -gcflags '-m -l' main.go)
  • 2.通过反编译命令查看go tool compile -S main.go

编译参数介绍(-gcflags):

  • -N: 禁止编译优化
  • -l: 禁止内联(可以有效减少程序大小)
  • -m: 逃逸分析(最多可重复四次)
  • -benchmem: 压测时打印内存分配统计

堆是除栈之外的第二个内存区域,用于存储值,全局变量、内存占用大的局部变量、发生了逃逸的局部变量存在的地方就是堆,这块的内存没有特定的结构,也没有固定大小,可以根据需要进行调整(但也造成管理成本),因此堆不像栈那样是自清理的,使用这个内存的成本更大(一般各个语言都会有自己的GC机制,在Golang中会使用三色标记法来进行堆内存的垃圾回收)。

首先,成本与垃圾收集器(GC)有关,垃圾收集器必须参与进来以保持该区域的清洁。当GC运行时,它将使用25%的可用CPU资源。此外,它可能会产生微秒级的“stop the world”延迟。拥有GC的好处是你不需要担心内存的管理问题,因为内存管理是相当复杂、也容易出错的。

堆上的值构成Go中的内存分配。这些分配对GC造成压力,因为堆中不再被指针引用的每个值都需要删除。需要检查和删除的值越多,GC每次运行时必须执行的工作就越多。因此,GC算法一直在努力在堆的大小分配和运行速度之间寻求平衡。

注意:堆是进程级别的

在程序中,每个函数块都会有自己的内存区域用来存自己的局部变量(内存占用少)、返回地址、返回值之类的数据,这一块内存区域有特定的结构和寻址方式,大小在编译时已经确定,寻址起来也十分迅速,开销很少。

这块内存地址称为栈是线程级别的,大小在创建的时候已经确定,所以当数据太大的时候,就会发生"stack overflow"

注意:在Golang程序中,函数都是运行在上的,在栈上声明临时变量分配内存,函数运行完成后回收该段栈空间,并且每个函数的栈空间都是独立的,其他代码不可访问的。但是在某些场景下,栈上的空间需要在该函数被释放后依旧能访问到(函数外调用),这时候就涉及到内存的逃逸了,而逃逸往往会对应对象的内存分配到堆上.

逃逸分析示例

1.示例-参数泄露

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 测试代码
$ cat taoyi.go
package main
import (
    _ "fmt"
)
// 定义一个简单的结构体
type user struct {
    name    string
    age     int
    webSite string
}
// 获取用户信息
func GetUserInfo(u *user) (*user) {
    return u
}
// 获取用户名称
func GetName(u *user) (string) {
    return u.name
}

func main() {
    // 初始化user结构体的指针对象
    user := &user{"BGBiao",18,"https://bgbiao.top"}
    GetUserInfo(user)
    GetName(user)
}

使用逃逸分析来进行内存分析

1
2
3
4
5
$ go build -gcflags '-m -m  -l' taoyi.go
# command-line-arguments
./taoyi.go:21:18: leaking param: u to result ~r1 level=0
./taoyi.go:25:14: leaking param: u to result ~r1 level=1
./taoyi.go:31:31: main &user literal does not escape

由上述输出的leaking param可以看到,在GetUserInfoGetName函数中的指针变量u是一个泄露参数,在两个函数中均没有对u进行变量操作,就直接返回了变量内容,因此最后的该变量user并没有发生逃逸,&user对象还是作用在了main()函数中。

2.示例-未知类型

这个时候,我们把上面的代码稍微改动一下:

1
2
3
4
5
6
7
....
....
func main() {
    user := &user{"BGBiao",18,"https://bgbiao.top"}
    fmt.Println(GetUserInfo(user))
    fmt.Println(GetName(user))
}

再次进行逃逸分析:

1
2
3
4
5
6
7
8
9
$ go build -gcflags '-m -m  -l' taoyi.go
# command-line-arguments
./taoyi.go:21:18: leaking param: u to result ~r1 level=0
./taoyi.go:25:14: leaking param: u to result ~r1 level=1
./taoyi.go:31:31: &user literal escapes to heap
./taoyi.go:32:16: main ... argument does not escape
./taoyi.go:32:28: GetUserInfo(user) escapes to heap
./taoyi.go:33:16: main ... argument does not escape
./taoyi.go:33:24: GetName(user) escapes to heap

由上可以发现我们的指针对象&user在该程序中发生了逃逸,具体是在GetUserInfo(user)GetName(user)发生了逃逸.

这是为什么呢?怎么加了个fmt.Println之后对象就发生了逃逸呢?

其实主要原因为fmt.Println函数的原因:

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

我们可以看到fmt.Println(a)函数中入参为interface{}类型,在编译阶段编译器无法确定其具体的类型。因此会产生逃逸,最终分配到堆上(最本质的原因是interface{}类型一般情况下底层会进行reflect,而使用的reflect.TypeOf(arg).Kind()获取接口类型对象的底层数据类型时发生了堆逃逸,最终就会反映为当入参是空接口类型时发生了逃逸)。

3.示例-指针

此时,我们再小改点代码:

1
2
3
4
5
6
7
8
9
// 返回结构体对象的指针,此时就会产生逃逸
func GetUserInfo(u user) (*user) {
    return &u
}

func main() {
    user := user{"BGBiao",18,"https://bgbiao.top"}
    GetUserInfo(user)
}

逃逸分析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
$ go build -gcflags '-m -m  -l' taoyi.go
# command-line-arguments
./taoyi.go:21:18: moved to heap: u

# 查看汇编代码(可以看到有个CALL	runtime.newobject(SB)的系统调用)
$ go tool compile -S taoyi.go | grep taoyi.go:21
	0x0000 00000 (taoyi.go:21)	TEXT	"".GetUserInfo(SB), ABIInternal, $40-48
	0x0000 00000 (taoyi.go:21)	MOVQ	(TLS), CX
	0x0009 00009 (taoyi.go:21)	CMPQ	SP, 16(CX)
	0x000d 00013 (taoyi.go:21)	JLS	147
	0x0013 00019 (taoyi.go:21)	SUBQ	$40, SP
	0x0017 00023 (taoyi.go:21)	MOVQ	BP, 32(SP)
	0x001c 00028 (taoyi.go:21)	LEAQ	32(SP), BP
	0x0021 00033 (taoyi.go:21)	FUNCDATA	$0, gclocals·fb57040982f53920ad6a8ad662a1594f(SB)
	0x0021 00033 (taoyi.go:21)	FUNCDATA	$1, gclocals·263043c8f03e3241528dfae4e2812ef4(SB)
	0x0021 00033 (taoyi.go:21)	FUNCDATA	$2, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
	0x0021 00033 (taoyi.go:21)	PCDATA	$0, $1
	0x0021 00033 (taoyi.go:21)	PCDATA	$1, $0
	0x0021 00033 (taoyi.go:21)	LEAQ	type."".user(SB), AX
	0x0028 00040 (taoyi.go:21)	PCDATA	$0, $0
	0x0028 00040 (taoyi.go:21)	MOVQ	AX, (SP)
	0x002c 00044 (taoyi.go:21)	CALL	runtime.newobject(SB)
	0x0031 00049 (taoyi.go:21)	PCDATA	$0, $1
	0x0031 00049 (taoyi.go:21)	MOVQ	8(SP), AX
	0x0036 00054 (taoyi.go:21)	PCDATA	$0, $-2
	0x0036 00054 (taoyi.go:21)	PCDATA	$1, $-2
	0x0036 00054 (taoyi.go:21)	CMPL	runtime.writeBarrier(SB), $0
	0x003d 00061 (taoyi.go:21)	JNE	104
	0x003f 00063 (taoyi.go:21)	MOVQ	"".u+48(SP), CX
	0x0044 00068 (taoyi.go:21)	MOVQ	CX, (AX)
	0x0047 00071 (taoyi.go:21)	MOVUPS	"".u+56(SP), X0
	0x004c 00076 (taoyi.go:21)	MOVUPS	X0, 8(AX)
	0x0050 00080 (taoyi.go:21)	MOVUPS	"".u+72(SP), X0
	0x0055 00085 (taoyi.go:21)	MOVUPS	X0, 24(AX)
	0x0068 00104 (taoyi.go:21)	PCDATA	$0, $-2
	0x0068 00104 (taoyi.go:21)	PCDATA	$1, $-2
	0x0068 00104 (taoyi.go:21)	MOVQ	AX, "".&u+24(SP)
	0x006d 00109 (taoyi.go:21)	LEAQ	type."".user(SB), CX
	0x0074 00116 (taoyi.go:21)	MOVQ	CX, (SP)
	0x0078 00120 (taoyi.go:21)	MOVQ	AX, 8(SP)
	0x007d 00125 (taoyi.go:21)	LEAQ	"".u+48(SP), CX
	0x0082 00130 (taoyi.go:21)	MOVQ	CX, 16(SP)
	0x0087 00135 (taoyi.go:21)	CALL	runtime.typedmemmove(SB)
	0x0091 00145 (taoyi.go:21)	JMP	89
	0x0093 00147 (taoyi.go:21)	NOP
	0x0093 00147 (taoyi.go:21)	PCDATA	$1, $-1
	0x0093 00147 (taoyi.go:21)	PCDATA	$0, $-1
	0x0093 00147 (taoyi.go:21)	CALL	runtime.morestack_noctxt(SB)
	0x0098 00152 (taoyi.go:21)	JMP	0

由以上输出可以看到在GetUserInfo(u user)函数中的对象u已经被移到上了,这是因为该函数返回的是指针对象,引用对象被返回到方法之外了(此时该引用对象可以在外部被调用和修改),因此编译器会把该对象分配到堆上(否则方法结束后,局部变量被回收岂不是很惨)。

4.示例-综合案例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ cat taoyi-2.go
package main

func main() {
    name := new(string)
    *name = "BGBiao"

}

$ go build -gcflags '-m -m  -l' taoyi-2.go
# command-line-arguments
./taoyi-demo.go:13:16: main new(string) does not escape

在上面第三个示例中我们提到,当返回对象是指针类型(引用对象)时,就会发现逃逸,但上面的示例其实告诉我们虽然*name是一个指针类型,但是并未发生逃逸,这是因为该引用类型未被外部使用.

但是又如第二个示例中所说,如果我们在上面的示例中增加fmt.Println(name)后,会发现该实例又会出现逃逸.

注意:虽然当使用fmt.Println的时候又会出现逃逸,但是当使用fmt.Println(*name)和fmt.Println(name),也是不同的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ cat demo1.go
package main
import ("fmt")
func main() {
    name := new(string)
    *name = "BGBiao"
    fmt.Println(*name)
}

$ go build -gcflags '-m -m  -l' taoyi-demo.go
# command-line-arguments
./taoyi-demo.go:13:16: main new(string) does not escape
./taoyi-demo.go:15:16: main ... argument does not escape
./taoyi-demo.go:15:17: *name escapes to heap

由上述输出可看到,当使用引用类型来获取底层的值时,在fmt.Println的入参处*name发生了逃逸.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ cat demo2.go
package main
import ("fmt")
func main() {
    name := new(string)
    *name = "BGBiao"
    fmt.Println(name)
}

$ go build -gcflags '-m -m  -l' taoyi-demo.go
# command-line-arguments
./taoyi-demo.go:13:16: new(string) escapes to heap
./taoyi-demo.go:15:16: main ... argument does not escape
./taoyi-demo.go:15:16: name escapes to heap

而这次我们使用fmt.Println(name)来输出底层值,就会发现变量name在初始化的时候就会出现逃逸new(string)

总结

通过上面的概念和实例分析,我们基本知道了逃逸分析的概念和规则,并且大概知道何时,那种对象会被分配到堆或栈内存中,在实际情况中可能情况会更加复杂,需要具体分析。

不过,有如下几点可能在我们实际使用过程中要注意下:

  • 静态分配到栈上,性能一定比动态分配到堆上好
  • 底层分配到堆,还是栈。实际上对你来说是透明的,不需要过度关心
  • 每个 Go 版本的逃逸分析都会有所不同(会改变,会优化)
  • 直接通过go build -gcflags '-m -l' 就可以看到逃逸分析的过程和结果
  • 到处都用指针传递并不一定是最好的,要用对
  • map & slice 初始化时,预估容量,避免由扩展导致的内存分配。但是如果太大(10000)也会逃逸,因为栈的空间是有限的

思考

函数传递指针真的比传值效率高吗?

我们知道传递指针可以减少底层值的拷贝,可以提高效率,但是如果拷贝的数据量小,由于指针传递会产生逃逸,可能会使用堆,也可能会增加GC的负担,所以传递指针不一定是高效的。

内存碎片化问题

实际项目基本都是通过 c := make([]int, 0, l) 来申请内存,长度都是不确定的,自然而然这些变量都会申请到堆上面了.

Golang使用的垃圾回收算法是『标记——清除』.

简单得说,就是程序要从操作系统申请一块比较大的内存,内存分成小块,通过链表链接。

每次程序申请内存,就从链表上面遍历每一小块,找到符合的就返回其地址,没有合适的就从操作系统再申请。如果申请内存次数较多,而且申请的大小不固定,就会引起内存碎片化的问题。

申请的堆内存并没有用完,但是用户申请的内存的时候却没有合适的空间提供。这样会遍历整个链表,还会继续向操作系统申请内存。这就能解释我一开始描述的问题,申请一块内存变成了慢语句。

知识星球

公众号