引子

近日在网上看到有个go代码,比较有意思,说在第14行注释前和注释后,最后的打印结果是不同的:

package main

import (
    "fmt"
)

func main(){
    s := []byte("")

    s1 := append(s, 'a')
    s2 := append(s, 'b')

    // 如果释放此行,打印的结果是 a b,否则打印的结果是b b
    //fmt.Println(s1, "===", s2)
    
    fmt.Println(string(s1), string(s2))
}

验证一下

我用go编译跑了一下,无论是否注释,都是输出”a b”, 而不是文中提到的”b b”.

那么是咋回事呢?我看了下我的go版本是:

go version go1.12.7 linux/amd64

而文中是go1.9. 幸好我还有go1.10的版本:

go version go1.10.4 linux/amd64

看下它的表现,果然结果是不同的,那就可以用go1.10和go1.12做个比较,看下是哪里的不同。

按照文中所述,输出”a b”和”b b”原因是在栈上分配和堆上分配和策略不同所致。 如果14行被注释,那么s将被分配在栈上,而由于是[]byte(""),是将空字符串转成byte slice,在内部执行的是runtime.stringtoslicebyte, 默认分配了32B size的cap;而释放那行,s将逃逸至堆,从堆上分配时,slice的cap为0。由于初始cap不同,造成了结果不同。 至于slice和append的实现,文中说的很明白,这里不赘述了。

咋回事

我们先来看下旧版的go,为啥注释前后输出不同呢?

为了更好地说明,我们为其加些调试打印,一窥究竟:

package main

import (
    "fmt"
)

func main(){
    s := []byte("")
    println(s) // 调试

    s1 := append(s, 'a')
    println(s1) // 调试
    s2 := append(s, 'b')
    println(s2) // 调试

    // 如果释放此行,打印的结果是 a b,否则打印的结果是b b
    //fmt.Println(s1, "===", s2)
    
    fmt.Println(string(s1), string(s2))
}

go 1.10

注释掉

  • 逃逸分析

    [legacy@localhost tmp]$ go build -gcflags '-m'
    # _/home/legacy/tmp
    ./main.go:18:23: string(s1) escapes to heap
    ./main.go:18:23: string(s1) escapes to heap
    ./main.go:18:35: string(s2) escapes to heap
    ./main.go:18:35: string(s2) escapes to heap
    ./main.go:8:16: main ([]byte)("") does not escape
    ./main.go:18:16: main ... argument does not escape
    
  • 输出

    [0/32]0xc420045f00
    [1/32]0xc420045f00
    [1/32]0xc420045f00
    b b
    

可以看到,注释那行后,[]byte("")没有逃逸,并且一开始就有了32的cap。之后的append因为有空间,所以仍旧在原slice上操作,b会把a覆盖掉,所以输出b b.

释放掉

  • 逃逸分析

    [legacy@localhost tmp]$ go build -gcflags '-m'
    # _/home/legacy/tmp
    ./main.go:17:16: s1 escapes to heap
    ./main.go:8:16: ([]byte)("") escapes to heap
    ./main.go:17:21: "===" escapes to heap
    ./main.go:17:21: s2 escapes to heap
    ./main.go:18:23: string(s1) escapes to heap
    ./main.go:18:23: string(s1) escapes to heap
    ./main.go:18:35: string(s2) escapes to heap
    ./main.go:18:35: string(s2) escapes to heap
    ./main.go:17:16: main ... argument does not escape
    ./main.go:18:16: main ... argument does not escape
    
  • 输出

    [0/0]0x543f18
    [1/8]0xc420014068
    [1/8]0xc420014080
    [97] === [98]
    a b
    

释放注释后,[]byte("")逃逸至堆上,并且分配的cap为0的sclie,之后的append均会重新生成一个slice,可以看到s1和s2所代表的slice内部的data地址是不同的,所以打印时,各取各的。

我们再来看为什么高版本的go1.12,注释以后,还是输出a b

go 1.12

注释掉

  • 逃逸分析

    [vagrant@localhost 3]$ go build -gcflags '-m'
    # escape_analyze/3
    ./main.go:18:16: inlining call to fmt.Println
    /tmp/go-build106864236/b001/_gomod_.go:6:6: can inline init.0
    ./main.go:18:23: string(s1) escapes to heap
    ./main.go:18:23: string(s1) escapes to heap
    ./main.go:18:35: string(s2) escapes to heap
    ./main.go:18:35: string(s2) escapes to heap
    ./main.go:18:16: io.Writer(os.Stdout) escapes to heap
    ./main.go:8:16: main ([]byte)("") does not escape
    ./main.go:18:16: main []interface {} literal does not escape
    <autogenerated>:1: os.(*File).close .this does not escape
    
  • 输出

    [0/0]0xc000034728
    [1/8]0xc0000140a8
    [1/8]0xc0000140c0
    a b
    

从逃逸分析上看,[]byte("")是在栈上,但是分配时,不像低版本golang,高版本分配了cap为0的slice,之后的append会创建新的slice,所以输出是a b.

后记

不同golang版本的runtime.stringtoslicebyte的实现是不同的,可以查看源码得到。

实际上s:=[]byte("")这样的写法不是很好,应该是

s:=[]byte{}
// or
var s []byte

更多的是append的用法不合理,在同一个slice引用上append多次,结果取决于该slice的cap有多大。 一般写程序会避免这种写法,当然作为研究slice和逃逸分析的例子,还是不错的。