hello world

stay foolish, stay hungry

golang语言机制之逃逸分析

Golang 中逃逸是只栈空间的变量逃逸到了堆空间,逃逸分析是编译器通过静态代码分析决定程序中变量存储位置的过程。代码中没有任何的关键词或者函数可以决定变量放置在栈空间还是堆空间,只能让编译器通过代码来决定变量值的存储位置。

堆(Heaps)

堆是内存中除了栈之外用来存储变量值的区域,堆不能像栈一样自己释放空间,所以使用这块区域会比使用栈有更大的开销。其中开销主要用来进行垃圾回收(GC),当进行垃圾回收时,会消耗 25% 的 CPU,并且很可能会造成微秒级的「stop the world」延迟。而 GC 的好处是不需要再手动来分配和释放内存。Golang 中一部分变量值分配在堆上,而不在使用的变量值都需要清理掉,堆上的数据过多会给 GC 造成压力。

共享栈(Sharing Stacks)

Golang 不允许 goroutine 访问其他 goroutine 的栈空间,这是因为 goroutine 的栈空间增长或收缩时,栈空间会填充进新的内容。

程序1展示了栈被替换好几次的例子。

// 程序1
// All material is licensed under the Apache License Version 2.0, January 2004
// http://www.apache.org/licenses/LICENSE-2.0

// Sample program to show how stacks grow/change.
package main

// Number of elements to grow each stack frame.
// Run with 10 and then with 1024
const size = 1024

// main is the entry point for the application.
func main() {
    s := "HELLO"
    stackCopy(&s, 0, [size]int{})
}

// stackCopy recursively runs increasing the size
// of the stack.
func stackCopy(s *string, c int, a [size]int) {
    println(c, s, *s)

    c++
    if c == 10 {
        return
    }

    stackCopy(s, c, a)
}

输出结果的第 2 和第 6 行,会看到 main 函数栈的 s 变量地址值改变了两次。

0 0xc00006ff68 HELLO
1 0xc00006ff68 HELLO
2 0xc00007ff68 HELLO
3 0xc00007ff68 HELLO
4 0xc00007ff68 HELLO
5 0xc00007ff68 HELLO
6 0xc00011ff68 HELLO
7 0xc00011ff68 HELLO
8 0xc00011ff68 HELLO
9 0xc00011ff68 HELLO

逃逸机制(Escape Mechanics)

如果变量值需要在函数栈帧外访问,都会将该变量值重新分配到堆上,这就是逃逸分析算法要做的事情,确保对任何变量值的访问始终是准确、一致和高效的。

让我们通过程序 2 来了解逃逸分析。

// 程序2
package main

type user struct {
    name  string
    email string
}

func main() {
    u1 := createUserV1()
    u2 := createUserV2()

    println("u1", &u1, "u2", &u2)
}

//go:noinline
func createUserV1() user {
    u := user{
        name:  "Bill",
        email: "bill@ardanlabs.com",
    }

    println("V1", &u)
    return u
}

//go:noinline
func createUserV2() *user {
    u := user{
        name:  "Bill",
        email: "bill@ardanlabs.com",
    }

    println("V2", &u)
    return &u
}

程序 2 中使用了 [//go:noinline](//go:noinline) 指令来阻止编译器使用内联代码优化,内联代码优化会将函数调用变成内联代码。程序 2 中有两个版本的 createUserXX 函数,createUserV1() 返回的是 user 变量的副本。

func createUserV1() user {
    u := user{
        name:  "Bill",
        email: "bill@ardanlabs.com",
    }

    println("V1", &u)
    return u
}

createUserV1() 函数返回之后,栈空间应该是图 1 这样的:

图1

createUserV2() 函数返回的是 user 变量的指针,所以调用方收到的是 user 变量地址的副本:

func createUserV2() *user {
    u := user{
        name:  "Bill",
        email: "bill@ardanlabs.com",
    }

    println("V2", &u)
    return &u
}

按《Golang 语言机制之栈与指针》一文,createUserV2() 函数返回后栈空间应该如图 2 所示:

图2

但是图 2 所示的栈空间有一个严重的问题,main 函数栈帧 u2 指向了无效的内存空间,这段地址空间在下一次函数调用时可能会被重新初始化。 这种情况下,编译器认为在 createUserV2 函数栈帧中构造 user 是不安全的,因此会改为在堆中构造 user,在程序 2 的执行到第 28 行开始构造 user 时就会在堆上构造。

Golang 语言机制之栈与指针一文指出,函数只能直接访问自己栈帧内的内存空间,或通过指针间接访问栈帧外的内存空间,所以访问逃逸到堆上的变量值也需要通过指针来间接访问。所以执行完 createUserV2 函数后栈空间应该开起来如图 3:

图3

createUserV2 函数栈帧的变量 u 的值存储在堆上而不是在栈上,所以访问变量 u 的值也需要通过指针来间接访问。

可读性(Readability)

createUserV2 函数先构建了 user 变量,然后再通过 & 操作获取变量地址并返回。如果直接构造成指针,如程序 3 所示。

// 程序 3
func createUserV2() *user {
    u := &user{
        name:  "Bill",
        email: "bill@ardanlabs.com",
    }

    println("V2", u)
    return u
}

如果我们只关注 return,程序 3 return u 告诉我们要返回给调用者的是 u 的副本,程序 2 return &u 告诉我们返回给调用者的是 u 的地址值,并且变量 u 已经逃逸到堆上。所以,读代码时要记住,指针是为了共享变量,& 操作符对应的单词是「sharing」,这样写有助于提高代码的可读性。

来看一下程序 4 的例子。

// 程序 4
var u *user
err := json.Unmarshal([]byte(r), &u)
return u, err

第二行的 json.Unmarshal 第二个参数必须是指针类型,所以需要传递 &u 作为参数。这段代码第一行创建了 user 的指针类型并初始化零值,第二行通过 u 的地址调用 json.Unmarshal,第三行和调用者共享 u 的副本。

这段代码并不直观,可以稍作修改,得到程序5.

// 程序 5
var u user
err := json.Unmarshal([]byte(r), &u)
return &u, err

程序 5 的第一行创建了 user 变量并初始化零值,第二行通过 u 的地址调用 json.Unmarshal,第三行和调用者共享 u,比程序 4 更直观,返回 &u 说明 u 会逃逸到堆上。

如果需要和调用者共享变量值时,在构造值的时候使用值语义,利用 & 操作符的可读性来明确值是被共享的。

编译器日志(Compiler Reporting)

在构建时,可以通过编译器日志(Compiler Reporting)来查看编译器的逃逸分析,在 go build 指令后添加 -gcflags 指令和 -m 参数,就可以看到编译器日志:

$ go build -gcflags "-m -m"
./main.go:16: cannot inline createUserV1: marked go:noinline
./main.go:27: cannot inline createUserV2: marked go:noinline
./main.go:8: cannot inline main: non-leaf function
./main.go:22: createUserV1 &u does not escape
./main.go:34: &u escapes to heap
./main.go:34:   from ~r0 (return) at ./main.go:34
./main.go:31: moved to heap: u
./main.go:33: createUserV2 &u does not escape
./main.go:12: main &u1 does not escape
./main.go:12: main &u2 does not escape

实际上可以使用 4 个 -m,但是超过 2 个控制台信息就很多,所以这里使用了 2 个 -m

可以看到日志里有了逃逸分析的日志。

./main.go:22: createUserV1 &u does not escape

通过日志,我们可以知道程序 2 第 22 行调用 println 函数时没有发生逃逸。

./main.go:34: &u escapes to heap
./main.go:34:   from ~r0 (return) at ./main.go:34
./main.go:31: moved to heap: u

这几行是说,第 31 行创建的 u 变量,因为第 34 行的 return 语句发生了逃逸。

变量逃逸情况总结

总的来说,如果出现了以下三种情况,则必然发生逃逸:

  • 函数中new或字面量创建出的变量,将其指针作为函数返回值,则该变量一定发生逃逸(构造函数返回的指针变量一定逃逸)
  • 被已经逃逸的变量引用的指针,一定发生逃逸
  • 被指针类型的slice、map和chan引用的指针,一定发生逃逸

而在以下两种情况下,则不会发生逃逸的情况:

  • 指针被未发生逃逸的变量引用
  • 仅仅在函数内对变量做取址操作,而未将指针传出

总结

变量是否逃逸是有变量的分享方式决定的,只有当一个变量被共享了(通过变量的地址的方式共享),变量才会逃逸到堆上。变量逃逸到堆上会增加 GC 的压力,而通过变量副本的方式需要存储和维护不同的副本,每种方式都有好处和开销,关键的时要正确、一致且平衡的使用每种语义。

参考资料

  1. https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-escape-analysis.html