在GO语言中,一个变量只有两种归宿:要么在stack里,要么在heap里。到底放在哪儿,那是编译器的事儿。那么问题来了:

    既然是编译器的事儿,那我们还需要关心变量的归宿问题吗?

我们需要关心变量的归宿吗?

要回答这个问题,首先我们需要了解变量存放在stack与存放在heap上到底有什么不同。一个重要的不同就是再分配memory时的开销。由于heap是由garbage collection (GC)“接管”,在每一个GC cycle时,GC都会:
– 扫描所有对象
– 标记所有在heap中的对象
– 找出那些不会再被使用的对象并在后台清除它们

由于清除那些不会再被使用的对象需要一系列的操作(对memory span的重新整理),这一些列操作的执行就会产生(程序运行的)延迟时间(latency)。这种延迟又被我们戏称为“stop-the-world”现象。一般来说,存放在heap中的对象越多,延迟就会越高,程序运行的流畅程度就会越差。这里需要指出的是,GC绝对是个好东西。它让我们不需要像在写C代码的时候那样战战兢兢地使用mallocfree来手动分配和释放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

发表评论

电子邮件地址不会被公开。 必填项已用*标注