在GO语言中,一个变量只有两种归宿:要么在stack里,要么在heap里。到底放在哪儿,那是编译器的事儿。那么问题来了:
既然是编译器的事儿,那我们还需要关心变量的归宿问题吗?
我们需要关心变量的归宿吗?
要回答这个问题,首先我们需要了解变量存放在stack与存放在heap上到底有什么不同。一个重要的不同就是再分配memory时的开销。由于heap是由garbage collection (GC)“接管”,在每一个GC cycle时,GC都会:
– 扫描所有对象
– 标记所有在heap中的对象
– 找出那些不会再被使用的对象并在后台清除它们
由于清除那些不会再被使用的对象需要一系列的操作(对memory span的重新整理),这一些列操作的执行就会产生(程序运行的)延迟时间(latency)。这种延迟又被我们戏称为“stop-the-world”现象。一般来说,存放在heap中的对象越多,延迟就会越高,程序运行的流畅程度就会越差。这里需要指出的是,GC绝对是个好东西。它让我们不需要像在写C代码的时候那样战战兢兢地使用malloc
和free
来手动分配和释放memory。从而提升了memory的管理效率、使用的安全性和代码数据的一致性。大部分情况下,存放在heap的对象太多导致性能下降都是因为我们使用指针不当造成的,所以我们不应该让GC去背这个黑锅。
所以,我们当然应该关心一个变量的归宿。更确切的说,我们应该关心变量是否会被存放在heap上。Go的编译器会运用escape analysis(EA)来对变量进行分析,并决定是否将其放到heap上面。通常来说,在以下两种情况下,编译器会决定将变量存放到heap上:
– 方法返回了一个指针(变量的地址)而不是值的拷贝
– 变量值的大小(在编译时)未可知
尽管如此,在编写代码的时候,我们还是不能100%预测变量的归宿。Go Team在Golang的FAQ上也表明了这一观点:
In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack.
EA是编译器优化代码的一个重要步骤。EA需要证明这个变量不会被继续引用或篡改。如果EA不能完全确认这点,那么它就会(保守地)将变量存放到heap上。正是由于这一点,预测归宿才有了其不确定性。EA之所以采用这种“保守”的处理方式,是为了遵循code integrity第一性原则。这点我十分认同,因为我觉得代码的正确性远比性能要重要。
接下来,我用简单的例子来向大家展示一下上面说到的两种变量会被存放到heap上的情况。
情况1 – 方法返归了指针
例子(1)
package main
type hero struct {
name string
superpower string
}
//go:noinline
func createSuperMan() hero {
h := hero {
name: "Superman",
superpower: "X-ray vision",
}
println("Superman ", &h)
return h
}
//go:noinline
func createTheFlash() *hero {
h := hero {
name: "The Flash",
superpower: "Super speed",
}
println("The Flash ", &h)
return &h
}
func main() {
h1 := createSuperMan()
// h will escape to heap, after createTheFlash returns its value, no matter if h2 is declared or not
h2 := createTheFlash()
println("Superman ", &h1, "The Flash", &h2)
}
例子(1)中,我们有一个hero类型,每一个hero都有它的名字(name)和超能力(superpower)。我们构造了两个方法来创建超人(createSuperMan)和 闪电侠(createTheFlash)。我们可以看到创建两个hero时,我们都会先创建一个变量h。不同的是,创建超人时,方法返回了h的值拷贝;而创建闪电侠时,我们返归了h的地址。这里我们使用了noinline
的标注。这个标注会阻止编译器将方法强行inline, 这样会让我们解读EA分析变得更简单些。
让我们启用gcflags
来看一下EA的分析结果:
结果(1)
$ go run -gcflags "-m -m" escape_analysis.go
./escape_analysis.go:9:6: cannot inline createSuperMan: marked go:noinline
./escape_analysis.go:19:6: cannot inline createTheFlash: marked go:noinline
./escape_analysis.go:32:6: cannot inline main: function too complex: cost 133 exceeds budget 80
./escape_analysis.go:14:23: createSuperMan &h does not escape
./escape_analysis.go:25:9: &h escapes to heap
./escape_analysis.go:25:9: from ~r0 (return) at ./escape_analysis.go:25:2
./escape_analysis.go:20:2: moved to heap: h
./escape_analysis.go:24:24: createTheFlash &h does not escape
./escape_analysis.go:38:23: main &h1 does not escape
./escape_analysis.go:38:41: main &h2 does not escape
Superman 0xc000046710
The Flash 0xc000074000
Superman 0xc000046768 The Flash 0xc000046760
在结果(1)中,我们看到,h被放到了heap上:
./escape_analysis.go:25:9: &h escapes to heap
./escape_analysis.go:25:9: from ~r0 (return) at ./escape_analysis.go:25:2
./escape_analysis.go:20:2: moved to heap: h
由于方法返回的是h的指针,在方法返回后,指针指向了一个invalid stack memory block, 而在下一次方法被调用时,memory会被重新frame并重新初始化,这样有可能带来数据读取不一致性问题。EA识别到这种潜在的风险,认为在stack中构建h是不安全的,因此编译器最终决定将h转移到heap上去。由于创建超人时(createSuperMan)并不存在这种风险,所以我们并没有看到转移操作提示。
情况2 – 变量值大小未可知
例子(2)
package main
import (
"flag"
)
type hero struct {
name string
superpower string
}
...
// go:noinline
func createSomeHeros(n *int) []hero {
heros := make([]hero, *n, *n)
for i := 0; i < *n; i ++ {
var h hero
if i <= (*n / 2) {
h = hero {
name: "Superman",
superpower: "X-ray vision",
}
} else {
h = hero {
name: "The Flash",
superpower: "Super speed",
}
}
heros = append(heros, h)
}
println(&heros)
return heros
}
...
func main() {
h1 := createSuperMan()
// h will escape to heap, after createTheFlash returns its value, no matter if h2 is declared or not
h2 := createTheFlash()
println("Superman ", &h1, "The Flash", &h2)
// add n heros where n is specified by users at runtime
n := flag.Int("n", 0, "specify num of heros")
flag.Parse()
hs := createServerHeros(n)
println("Heros ", *n, &hs)
}
例子(2)中,我们加入了新的方法(createSomeHeros)用来一次性创建n个hero。当n是偶数时,这个方法会创建一半的超人和一半的闪电侠,而当n是奇数时,超人的数量会比闪电侠多一个。其中,n是用户在runtime时通过flag指定的,并且通过指针传入方法。
让我们同样启用gcflags
来看一下EA的分析结果:
结果(2)
$ go run -gcflags "-m -m" escape_analysis.go -n=9
./escape_analysis.go:13:6: cannot inline createSuperMan: marked go:noinline
./escape_analysis.go:23:6: cannot inline createTheFlash: marked go:noinline
./escape_analysis.go:33:6: cannot inline createSomeHeros: unhandled op FOR
./escape_analysis.go:61:6: cannot inline main: function too complex: cost 341 exceeds budget 80
./escape_analysis.go:69:15: inlining call to flag.Int func(string, int, string) *int { return flag.CommandLine.Int(flag.name, flag.value, flag.usage) }
./escape_analysis.go:70:12: inlining call to flag.Parse func() { flag.CommandLine.Parse(os.Args[int(1):]) }
./escape_analysis.go:18:23: createSuperMan &h does not escape
./escape_analysis.go:29:9: &h escapes to heap
./escape_analysis.go:29:9: from ~r0 (return) at ./escape_analysis.go:29:2
./escape_analysis.go:24:2: moved to heap: h
./escape_analysis.go:28:24: createTheFlash &h does not escape
./escape_analysis.go:34:15: make([]hero, *n, *n) escapes to heap
./escape_analysis.go:34:15: from make([]hero, *n, *n) (non-constant size) at ./escape_analysis.go:34:15
./escape_analysis.go:33:22: createSomeHeros n does not escape
./escape_analysis.go:53:10: createSomeHeros &heros does not escape
./escape_analysis.go:67:23: main &h1 does not escape
./escape_analysis.go:67:41: main &h2 does not escape
./escape_analysis.go:72:24: main &hs does not escape
Superman 0xc000082ed0
The Flash 0xc000094020
Superman 0xc000082f68 The Flash 0xc000082f48
0xc000082ed8 5 4
Heros 9 0xc000082f50
在结果(2)中,我们可以看到,除了第一种情况下的h被放到了heap上,创建的hero slice也被放到了heap上:
./escape_analysis.go:34:15: make([]hero, *n, *n) escapes to heap
./escape_analysis.go:34:15: from make([]hero, *n, *n) (non-constant size) at ./escape_analysis.go:34:15
这是因为要创建的hero slice的大小并不是一个常量,也就时说在编译时,编译器无法确切了解slice的大小。由于担心slice会过大stack容纳不小,编译器又一次“保守地”将这个变量放到了容量更大的heap上。
总结一下
通过上面的例子我们可以观察到,在方法返回指针及变量大小在编译时未可知的情况下,编译器会将变量放到heap上去,来保证镊取他们的时候,值总是正确的。尽管编译器这样“竭尽全力”地去保障data integrity会带来一些性能损耗,但在我看来,这都是值得的。因为保证代码运行的正确性比性能要来得重要。
本文完整的示例代码可以从这里下载。
Reference
1. Language Mechanics On Stacks And Pointers. By William Kennedy