专栏名称: 狗厂
目录
相关文章推荐
康石石  ·  我跨专业拿到了世界第一的offer!!! ·  昨天  
康石石  ·  伦时fashion ... ·  2 天前  
51好读  ›  专栏  ›  狗厂

Go 内存逃逸详细分析

狗厂  · 掘金  ·  · 2018-05-11 06:04

正文

Slice 怪异现象分析实例

原贴地址:https://gocn.io/question/1852

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))
}

诡异的现象:如果有行 14 的代码,则行 15 打印的结果为 a b , 否则打印的结果为 b b ,本文分析的go版本:

$ go version
go version go1.9.2 darwin/amd64

初步分析

首先我们分析在没有行14的情况下,为什么打印的结果是 b b ,这个问题相对比较简单,只要熟悉 slice 的实现原理,简单分析一下 append 的实现原理即可得出结论。

slice 结构分析

如果熟悉 slice 的原理可以跳过该章节。

首先对于 slice 结构进行一个简单的了解 结构定义 slice 对应的 runtime 包的相关源码参见: https://golang.org/src/runtime/slice.go

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

var slice []int 定义的变量内部结构如下:

slice.array = nil
slice.len = 0
slice.cap = 0

如果我们声明了一下变量 slice := []int{} slice := make([]int, 0) 的内部结构如下:

slice.array = 0xxxxxxxx  // 分配了地址
slice.len = 0
slice.cap = 18208800

如果使用 make([]byte, 5) 定义的话,结构如下图:

如果使用 s := s[2:4] ,则结构如下图:

通过分析 slice 的反射de 实现: Go Slices: usage and internals ,也能够在程序中进行分析。 slice 反射中对应的结构体

// slice 对应的结构体
type SliceHeader struct {
        Data uintptr
        Len  int
        Cap  int
}

// string 对应结构体
type StringHeader struct {
        Data uintptr
        Len  int
}

下面的函数可以直接获取 slice 的底层指针:

func bytePointer(b []byte) unsafe.Pointer {
   // slice 的指针本质是*reflect.SliceHeader
  p := (*reflect.SliceHeader)(unsafe.Pointer(&b))
  return unsafe.Pointer(p.Data)
}

append 原理实现

Append 的实现伪代码 ,代码默认已经支持了 slice nil 的情况

func Append(slice, data []byte) []byte {
    l := len(slice)
    if l + len(data) > cap(slice) {  // reallocate
        // Allocate double what's needed, for future growth.
        newSlice := make([]byte, (l+len(data))*2)
        // The copy function is predeclared and works for any slice type.
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:l+len(data)]
    copy(slice[l:], data)
    return slice
}

append 函数原型如下,其中 T 为通用类型。

func append(s []T, x ...T) []T

展开分析

为了方便程序分析的,我们在程序中添加打印信息,代码和结果如下:

package main

import (
    "fmt"
)

func main() {
    s := []byte("")
    println(s) // 添加用于打印信息, println() print() 为go内置函数,直接输出到 stderr 无缓存

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

    // fmt.Println(s1, "===", s2)
    fmt.Println(string(s1), string(s2))
}

运行程序结果如下:

$ go run q.go
[0/32]0xc420045ef8
b b

结果运行后 s := []byte("") 初始化以后结构内部如下:

s.len = 0 
s.cap = 32
s.ptr = 0xc420045ef8

我们分析以下两行代码调用会发生什么:

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

s1 := append(s, 'a') 代码调用分析:

// slice = s  data = `a`   slice.len = 0 slice.cap = 32      
func Append(slice, data []byte) []byte {
    l := len(slice) // l = 0

    // l = 0 len(data) = 1  cap(slice) = 32   1 + 1 > 32 false
    if l + len(data) > cap(slice) { 
        newSlice := make([]byte, (l+len(data))*2)
        copy(newSlice, slice)
        slice = newSlice
    }
    // l = 0 len(data) = 1
    slice = slice[0:l+len(data)] // slice = slice[0:1]
    copy(slice[l:], data)  // 调用变成: copy(slice[0:], 'a') 
    return slice // 由于未涉及到重分配,因此返回的还是原来的 slice 对象
}

s2 := append(s, 'b') 的分析完全一样。

简化 apend 函数的处理路径,在没有进行 slice 重新分配内存情况下,直接进行展开分析:

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

等价于

s1 := copy(s[0:], 'a')
s2 := copy(s[0:], 'b') // 直接覆盖了上的赋值

基于上述分析,能够很好地解释代码输出 b b 的情况。但是如何避免出现这种类型的情况呢?问题出现在这条语句上

s := []byte("")

语句执行后 s.len = 0 s.cap = 32 ,导致了 append 的工作不能够正常工作,那么正常如何使用?只要将 s.len = s.cap = 0 则会导致 slice append 中重新进行分配则可以避免这种情况的发生。

正确的写法应该为:

func main() {
    // Notice []byte("") ->  []byte{}    或者  var s []byte
    s := []byte{}  

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

    // fmt.Println(s1, "===", s2)
    fmt.Println(string(s1), string(s2))
}

由此也可以看出一个良好的编程习惯是可以规避很多莫名其妙的问题排查。

深入分析

那么既然 bug 出现在了 s := []byte("") 这句话中,那么这条语句为什么会导致 s.cap = 32 呢?这条语句背后隐藏的逻辑是什么呢?

s := []byte("") 等价于以下代码:

// 初始化字符串
str := ""

// 将字符串转换成 []byte
s := []byte(str)

在go语言中 s := []byte(str) 的底层其实是调用了 stringtoslicebyte 实现的,该函数位于 go 的 runtime 包中。

const tmpStringBufSize = 32

type tmpBuf [tmpStringBufSize]byte

func stringtoslicebyte(buf *tmpBuf, s string) []byte {
    var b []byte
    // 如果字符串 s 的长度内部长度不超过 32, 那么就直接分配一个 32 直接的大小
    if buf != nil && len(s) <= len(buf) { 
        *buf = tmpBuf{}
        b = buf[:len(s)]
    } else {
        b = rawbyteslice(len(s))
    }
    copy(b, s)
    return b
}

如果字符串的大小没有超过 32 长度的大小,则默认分配一个 32 长度的 buf,这也是我们上面分析 s.cap = 32 的由来。

到此为止,我们仍然没有分析问题中 fmt.Println(s1, "===", s2) 这句打印注释掉就能够正常工作的原因?那么最终到底是什么样的情况呢?

最终分析

最后我们来启用魔法的开关 fmt.Println(s1, "===", s2) , 来进行最后谜底的揭晓:

package main

import (
    "fmt"
)

func main() {
    s := []byte("")
    println(s) // 添加用于打印信息

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

    fmt.Println(s1, "===", s2)
    fmt.Println(string(s1), string(s2))
}
$ go run q.go
[0/0]0x115b820   # 需要注意 s.len = 0 s.cap = 0
[97] === [98]    # 取消了打印的注释
a b              # 打印一切正常
$ go run -gcflags '-S -S' q.go
....
    0x0032 00050 (q.go:8)   MOVQ    $0, (SP)
    0x003a 00058 (q.go:8)   MOVQ    $0, 8(SP)
    0x0043 00067 (q.go:8)   MOVQ    $0, 16(SP)
    0x004c 00076 (q.go:8)   PCDATA  $0, $0
    0x004c 00076 (q.go:8)   CALL    runtime.stringtoslicebyte(SB)
    0x0051 00081 (q.go:8)   MOVQ    32(SP), AX
    0x0056 00086 (q.go:8)   MOVQ    AX, "".s.len+96(SP)
    0x005b 00091 (q.go:8)   MOVQ    40(SP), CX
    0x0060 00096 (q.go:8)   MOVQ    CX, "".s.cap+104(SP)
    0x0065 00101 (q.go:8)   MOVQ    24(SP), DX
    0x006a 00106 (q.go:8)   MOVQ    DX, "".s.ptr+136(SP)

....

通过分析发现底层调用的仍然是 runtime.stringtoslicebyte() , 但是行为却发生了变化 s.len = s.cap = 0 ,很显然由于 fmt.Println(s1, "===", s2) 行的出现导致了 s := []byte("") 内存分配的情况发生了变化。

我们可以通过 go build 提供的内存分配工具进行分析:

$ go build -gcflags "-m -m" q.go
# command-line-arguments
./q.go:7:6: cannot inline main: non-leaf function
./q.go:14:13: s1 escapes to heap
./q.go:14:13:   from ... argument (arg to ...) at ./q.go:14:13
./q.go:14:13:   from *(... argument) (indirection) at ./q.go:14:13
./q.go:14:13:   from ... argument (passed to call[argument content escapes]) at ./q.go:14:13
./q.go:8:13: ([]byte)("") escapes to heap
./q.go:8:13:    from s (assigned) at ./q.go:8:4
./q.go:8:13:    from s1 (assigned) at ./q.go:11:5
./q.go:8:13:    from s1 (interface-converted) at ./q.go:14:13
./q.go:8:13:    from ... argument (arg to ...) at ./q.go:14:13
./q.go:8:13:    from *(... argument) (indirection) at ./q.go:14:13
./q.go:8:13:    from ... argument (passed to call[argument content escapes]) at ./q.go:14:13

以上输出中的 s1 escapes to heap ([]byte)("") escapes to heap 表明,由于 fmt.Println(s1, "===", s2) 代码的引入导致了变量分配模型的变化。简单点讲就是从栈中逃逸到了堆上。内存逃逸的分析我们会在后面的章节详细介绍。问题到此,大概的思路已经有了,但是我们如何通过代码层面进行验证呢? 通过搜索 go 源码实现调用的函数 runtime.stringtoslicebyte 的地方进行入手。通过搜索发现调用的文件在 cmd/compile/internal/gc/walk.go

关于 string到[]byte 分析调用的代码如下

    case OSTRARRAYBYTE:
        a := nodnil()  // 分配到堆上的的默认行为

        if n.Esc == EscNone {
            // Create temporary buffer for slice on stack.
            t := types.NewArray(types.Types[TUINT8], tmpstringbufsize)

            a = nod(OADDR, temp(t), nil)  // 分配在栈上,大小为32
        }

        n = mkcall("stringtoslicebyte", n.Type, init, a, conv(n.Left, types.Types[TSTRING]))

OSTRARRAYBYTE 定义

OSTRARRAYBYTE    // Type(Left) (Type is []byte, Left is a string)

上述代码中的 n.Esc == EscNone 条件分析则表明了发生内存逃逸和不发生内存逃逸的情况下,初始化的方式是不同的。 EscNone 的定义

EscNone           // Does not escape to heap, result, or parameters.

通过以上分析,我们总算找到了魔法的最终谜底。 以上分析的go语言版本基于 1.9.2,不同的go语言的内存分配机制可能不同,具体可以参见我同事更加详细的分析 Go中string转[]byte的陷阱.md

Go 内存管理

Go 语言能够自动进行内存管理,避免了 C 语言中的内存自己管理的麻烦,但是同时对于代码的内存管理和回收细节进行了封装,也潜在增加了系统调试和优化的难度。同时,内存自动管理也是一项非常困难的事情,比如函数的多层调用、闭包调用、结构体或者管道的多次赋值、切片和MAP、CGO调用等多种情况综合下,往往会导致自动管理优化机制失效,退化成原始的管理状态;go 中的内存回收(GC)策略也在不断地优化过程。Golang 从第一个版本以来,GC 一直是大家诟病最多的,但是每一个版本的发布基本都伴随着 GC 的改进。下面列出一些比较重要的改动。

  • v1.1 STW
  • v1.3 Mark STW, Sweep 并行
  • v1.5 三色标记法
  • v1.8 hybrid write barrier

预热基础知识: How do I know whether a variable is allocated on the heap or the stack?

逃逸分析-Escape Analysis

更深入和细致的了解建议阅读 William Kennedy 的 4 篇 Post

go 没有像 C 语言那样提供精确的堆与栈分配控制,由于提供了内存自动管理的功能,很大程度上模糊了堆与栈的界限。例如以下代码:

package main

func main() {
    str := GetString()
    _ = str
}

func GetString() *string {
    var s string
    s = "hello"
    return &s
}

行 10 中的变量 s = "hello" 尽管声明在了 GetString() 函数内,但是在 main 函数中却仍然能够访问到返回的变量;这种在函数内定义的局部变量,能够突破自身的范围被外部访问的行为称作逃逸,也即通过逃逸将变量分配到堆上,能够跨边界进行数据共享。

Escape Analysis 技术就是为该场景而存在的;通过 Escape Analysis 技术,编译器会在编译阶段对代码做了分析,当发现当前作用域的变量没有跨出函数范围,则会自动分配在 stack 上,反之则分配在 heap 上。 go 的内存回收针对的也是堆上的对象。go 语言中 Escape Analysis 还未看到官方 spec 的文档,因此很多特性需要进行代码尝试和分析才能得出结论,而且 go Escape Analysis 的实现还存在很多 不完善的地方

stack allocation is cheap and heap allocation is expensive .

Go 语言逃逸分析实现

更多内存建议阅读 Allocation efficiency in high-performance Go services

2.go

package main

import "fmt"

func main() {
        x := 42
        fmt.Println(x)
}

go build 工具中的 flag -gcflags '-m' 可以用来分析内存逃逸的情况汇总,最多可以提供 4 个 "-m", m 越多则表示分析的程度越详细,一般情况下我们可以采用两个 m 分析。

$ go build -gcflags '-m -l' 2.go
# command-line-arguments
./2.go:7:13: x escapes to heap
./2.go:7:13: main ... argument does not escape

# -l disable inline, 也可以调用的函数前添加注释 
$ go build -gcflags '-m -m -l' 2.go
# command-line-arguments
./2.go:7:13: x escapes to heap
./2.go:7:13:    from ... argument (arg to ...) at ./2.go:7:13
./2.go:7:13:    from *(... argument) (indirection) at ./2.go:7:13
./2.go:7:13:    from ... argument (passed to call[argument content escapes]) at ./2.go:7:13
./2.go:7:13: main ... argument does not escape

上例中的 x escapes to heap 则表明了变量 x 变量逃逸到了堆(heap)上。其中 -l 表示不启用 inline 模式调用,否则会使得分析更加复杂,也可以在函数上方添加注释 //go:noinline 禁止函数 inline调用。至于调用 fmt.Println() 为什么会导致 x escapes to heap ,可以参考 Issue #19720 Issue #8618 ,对于上述 fmt.Println() 的行为我们可以通过以下代码进行简单模拟测试,效果基本一样:







请到「今天看啥」查看全文