Golang 内存调优 - 逃逸分析

1,880 阅读17分钟

堆与栈

什么是堆栈?

在计算机领域中,堆栈是非常重要的概念,数据结构中有堆栈,内存分配中也有堆栈,两者在定义和用途上虽不同,但也有些许关联,比如内存分配中栈的压栈和出栈操作,类似于数据结构中的栈的操作方式。

数据结构中的堆栈

数据结构是将数据按序排列的一种方式,在数据结构的堆栈是两种数据结构:堆与栈,定义如何将数据按序进行存储和取出,是软件程序组织编排数据的手段。需要将内存分配中的堆栈区别开,本文也主要探究内存分配中的堆栈。

内存分配中的堆栈

软件程序在运行过程中,必不可少的会使用变量、函数(也是一种变量)和数据,变量和数据在内存中存储的位置可分为:栈区和堆区,一般由 C 或者 C++ 编译的程序占用的内存可分为:

  • 栈区(Stack)
  • 堆区(Heap)
  • 全局区(静态区 Static)
  • 常量区
  • 程序代码区

软件程序中的数据和变量都会被分配到程序所在的虚拟内存空间中,内存空间包含两个重要区域:栈区(Stack)和堆区(Heap), 下面针对 Golang 语言分配的栈空间和堆空间进行探讨。

栈(Stack)

每个函数都有自己独立的栈空间,函数的调用参数、返回值以及局部变量大都被分配到该函数的栈空间中, 这部分内存由编译器进行管理,编译时确定分配内存的大小。栈空间有特定的结构和寻址方式,所以寻址十分迅速、开销小,只需要2条 CPU 指令,即压栈出栈 PUSH 和 RELEASE,由于函数栈内存的大小在编译时确定, 所以当局部变量数据太大就会发生栈溢出(Stack Overflow)。当函数执行完毕后, 函数的栈空间被回收, 无需手动去释放。

区别于堆空间,通过 malloc 出来的内存,函数执行完毕后需要“手动”释放,“手动”释放在有垃圾回收的语言中,表现为垃圾回收系统,比如 Golang 语言的 GC 系统,GC 系统通过标记等手段,识别出需要回收的空间。

堆(Heap)

堆空间没有特定的结构,也没有固定的大小,可以动态进行分配和调整,所以内存占用较大的局部变量会放在堆空间上,在编译时不知道该分配多少大小的变量,在运行时也会分配到堆上,在堆上分配内存开销比在栈上大,而且堆上分配的内存需要手动释放,对于 Golang 这种有 GC 机制的语言, 也会增加 GC 压力, 也容易造成内存碎片。

题外话:

之前开发 iOS 时,系统的 OC (Objective-C)语言,之前采用 MRC(手动管理内存)的方式管理内存,标记变量采用引用计数的方式,变量被引用一次则引用计数加 1,减少引用一次则引用计数减一,直到引用计数减到 0,标记释放该变量的内存,+1 和 -1 操作必须配对,否则导致内存泄露,各种野指针 Crash。后来 OC && Swift 逐步的采用 ARC (自动引用计数)管理方式,即由系统去管理变量的引用计数,无需开发者去手动操作 +1 和 -1 操作,极大的提高了代码质量和降低了内存泄露的风险, 但同时也增加了系统开销。

Golang 语言也是同样的机制,由 GC 系统去管理堆上的内存,只是 GC 算法不同,采用三色标记法,GC 的好处在于不用手动去管理堆上的内存,但是同时增加了系统的开销,可能会产生微秒级 STW。

Note:栈是线程级别的,堆是进程级别的。

内存逃逸

什么是内存逃逸?

一言以蔽之, 本该分配到函数栈空间的变量,被分配到了堆空间,称为内存逃逸;过多的内存逃逸会导致 GC 压力变大,堆空间内存碎片化。

内存逃逸策略

在 Golang 语言中,变量不能显示地指定分配到栈空间还是堆空间,比如:newmake 等关键字,不能确定分配在栈空间还是堆空间,那我们怎么知道变量是分配到栈上还是堆上呢?从下面官方的回复可知:官方让开发者无需关心变量是分配在栈上还是堆上,但是为了写出高质量代码和定位问题,还是有必要了解 Golang 底层内存管理的逻辑。

引用自官方回复:golang.org/doc/faq#sta…

How do I know whether a variable is allocated on the heap or the stack?

From a correctness standpoint, you don't need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.

The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function's stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack.

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.

大致的意思表明一个原则:只要局部变量不能证明在函数结束后不能被引用,那么就分配到堆上。换句话说,如果局部变量被其他函数所捕获,那么就被分配到堆上。

由上可知,根据栈空间的特性可以知道,函数栈帧的大小是有限的且在编译时就已经确定,如果在编译时无法确定变量大小或者变量过大,在 runtime 运行时分配到堆上。

逃逸分析(Escape analysis)

在程序编译阶段根据程序代码中的数据流,对代码中哪些变量需要在栈空间上分配,哪些变量需要在堆空间上分配,进行静态分析。一个理想的逃逸分析算法,能将开发者认为需要分配在栈空间上的变量尽可能保留在栈空间上,尽可能少的“逃逸”到堆空间上。理想过于丰满,现实却很骨感,语言情况不同,语言版本不同,逃逸算法的精确度以及实际优化情况也不尽相同。对于开发者而言,只需掌握逃逸分析工具以及逃逸分析的目标即可。

Golang 语言的逃逸分析算法有两个版本,go1.13.6 darwin/amd64 之后的版本由 Matthew Dempsky 这位老哥重写了,在源码的 src/cmd/compile/internal/gc/escape.go 文件中,并有详整的注释。感兴趣可以拜读,虽只有一千多行代码,且有完整的算法说明和代码注释,但依然“打脑壳”。

源文件注释了 Golang 语言逃逸分析算法的原理,是内存逃逸的指导思想,有两个基本的不变性:

The two key invariants we have to ensure are:

(1) pointers to stack objects cannot be stored in the heap

(2) pointers to a stack object cannot outlive that object (e.g., because the declaring function returned and destroyed the object's stack frame, or its space is reused across loop iterations for logically distinct variables).

  • 指向栈对象的指针不能存储在堆中
  • 指向栈对象的指针不能超过该栈对象的存活期(即指针不能在栈对象被销毁后依旧存活)

逃逸分析算法的大致原理和过程注释中也有说明, 大致步骤:

  1. Golang 编译器解析 Golang 源文件后获取抽象语法树(AST)。
  2. 构建有向加权图, 遍历该有向加权图寻找可能违反上述两个不变性的赋值路径, 如果一个变量的地址是储存在堆或者其他可能会超过它存活期的地方, 则该变量就会被标记为需要在堆上分配。
  3. 分析函数之间的数据时,逃逸分析算法记录每个函数的数据流等手段,具体算法可移步至源码。

我们虽无法通过 newmake 或者字面量的方式显示地指定变量的分配位置,但可以通过逃逸分析了解变量被分配到栈空间还是堆空间,可以针对性地提高热点接口的响应、优化内存提高 GC 效率避免OOM 等。日常开发中也无需详细了解逃逸分析算法的工作原理,只需配合使用 Golang 的工具链,即可使用逃逸分析算法进行逃逸分析。

分析工具

使用编译工具

通过编译工具查看详细的逃逸分析过程,命令: go build -gcflags '-m -l' xxxx.go

编译参数(-gcflags):

  • -N: 禁止编译优化
  • -l: 禁止内联
  • -m: 逃逸分析
  • -benchmem: 压测时打印内存分配统计

例如: main 函数内声明局部变量 badBoy,局部变量未被外部捕获,理论上会在函数栈上分配。

package main

type Person struct {
    Name   string
    EnName string
}

func main() {
    badBoy := &Person{
        Name: "法外狂徒张三",
        EnName: "ZhangSan",
    }
    _ = badBoy
}

输入命令: go build -gcflags '-m -l' main.go,传入 -l 关闭 inline,屏蔽掉 inline 对最终生成代码的影响。

从 Terminal 的输出可以很轻易的分辨出变量是否逃逸到堆上,有个地方需要注意, 输出显示 main.go 源文件的 11 行 &Person 类型的变量没有发生逃逸, 其实 struct 类型在编译之前会压缩成一行: badBoy := &Person{Name: "法外狂徒张三", EnName: "ZhangSan"}

// ============= Command && Output ==================
Command :
go build -gcflags '-m -l' main.go

Output :
# command-line-arguments
./main.go:11:11: main &Person literal does not escape

使用汇编

通过反编译命令查看 go tool compile -S xxxx.go,可更加底层更加准确地判断对象是否逃逸

例如: 和上面的代码一样, 只是增加一行 fmt.Println

package main

import "fmt"

type Person struct {
    Name   string
    EnName string
}

func main() {
    badBoy := &Person{
        Name:   "法外狂徒张三",
        EnName: "ZhangSan",
    }
    fmt.Println(badBoy)
}

命令行: go tool compile -S main.go | grep xxxx

查看某一行的汇编,能直接定位到源代码某一行反编译后的汇编代码,有针对性的定位对象是否逃逸

// ============= Command && Output ==================
Command: 
go tool compile -S main.go | grep main.go:15

Output:                                                                                                                    czp-bytedance@MacBook-Pro
0x0071 00113 (main.go:15)       PCDATA  $0, $2
0x0071 00113 (main.go:15)       PCDATA  , 
0x0071 00113 (main.go:15)       XORPS   X0, X0
0x0074 00116 (main.go:15)       MOVUPS  X0, ""..autotmp_14+64(SP)
0x0079 00121 (main.go:15)       PCDATA  $0, $3
0x0079 00121 (main.go:15)       LEAQ    type.*"".Person(SB), AX
0x0080 00128 (main.go:15)       PCDATA  $0, $2
0x0080 00128 (main.go:15)       MOVQ    AX, ""..autotmp_14+64(SP)
0x0085 00133 (main.go:15)       PCDATA  $0, $0
0x0085 00133 (main.go:15)       MOVQ    DI, ""..autotmp_14+72(SP)
0x00e5 00229 (main.go:15)       MOVQ    DX, DI

命令行: go tool compile -S main.go | grep new

在反编译的汇编代码中查找 new 关键字, 可以看到汇编中出现 runtime.newobject, 说明在运行时在堆上动态申请的变量,一般情况下, 我们采用编译工具就能清晰的看出哪些变量发生了逃逸, 汇编更加底层。

// ============= Command && Output ==================
Command: 
go tool compile -S main.go | grep new
 
Output: 
0x002c 00044 (main.go:13)       CALL    runtime.newobject(SB)
rel 45+4 t=8 runtime.newobject+0

Note: 上述两段示例代码,逻辑上没有任何不同,只是增加了 fmt.Println 打印的代码,为何局部变量就发生逃逸了呢?下面就来看看有我们日常开发中,常见的发生逃逸的场景。

逃逸场景

1. 函数返回局部变量指针

返回局部变量的指针, 在函数之外引用该局部变量的地址, 根据上面的分析内存逃逸策略,两个不变性之一“在编译时能发现,指针不能在栈对象被销毁后依旧存活”    当外部没有引用局部变量地址时, 局部变量不会发生逃逸。

例如:buildPerson 函数返回局部变量的指针,在 main 函数中引用局部变量指针发生内存逃逸。

package main

type Person struct {
    Name   string
    EnName string
}

func buildPerson(name, enName string) *Person {
    return &Person{
        Name:   name,
        EnName: enName,
    }
}

func main() {
    badBoy := buildPerson("法外狂徒张三", "ZhangSan")
    badBoy.EnName = "ZhangSan"
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go
 
Output: 
# command-line-arguments
./main.go:8:18: leaking param: name
./main.go:8:24: leaking param: enName
./main.go:11:3: &Person literal escapes to heap // 发生逃逸,第 11 行其实是第 9 行

2. 动态反射 interface{} 变量

通过使用汇编工具,反编译后可以看到 fmt.Println 变量后,变量发生了逃逸,突然想到之前看的一篇文章(用 Go struct 不能犯的一个低级错误!),文章中提到系统函数,比如:fmt.Println 之类的底层系统函数,实现逻辑会基于 interface{} 做反射,通过 reflect.TypeOf(arg).Kind() 获取接口对象的底层数据类型,创建具体类型对象时,会发生内存逃逸。由于 interface{} 的变量,编译时无法确定变量类型以及申请空间大小,所以不能在栈空间上申请内存,需要在 runtime 时动态申请,理所应当地发生内存逃逸。

例如:fmt.Println 打印局部变量,发生内存逃逸。

package main

import "fmt"

type Person struct {
    Name   string
    EnName string
}

func main() {
    badBoy := &Person{
        Name:   "法外狂徒张三",
        EnName: "ZhangSan",
    }
    fmt.Println(badBoy)
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go
 
Output: 
# command-line-arguments
./main.go:13:11: &Person literal escapes to heap
./main.go:15:13: main ... argument does not escape
./main.go:15:13: badBoy escapes to heap

3. 申请栈空间过大

栈空间大小是有限的,如果编译时发现局部变量申请的空间过大,则会发生内存逃逸,在堆空间上给大变量分配内存。

例如:main 函数中申请大内存变量,发生内存逃逸。

package main

func main() {
    num := make([]int, 0, 10000)
    _ = num
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go
 
Output: 
# command-line-arguments
./main.go:4:13: make([]int, 0, 10000) escapes to heap

经过测试,num := make([]int, 0, 8192) 时刚好发生内存逃逸。在 64 位机上 int 类型为 8B,即 8192 * 8B = 64KB

package main

func main() {
    num := make([]int, 0, 8191)
    _ = num
}

# command-line-arguments
./main.go:4:13: main make([]int, 0, 8191) does not escape

// ===========================================
package main

func main() {
    num := make([]int, 0, 8192)
    _ = num
}

# command-line-arguments
./main.go:4:13: make([]int, 0, 8192) escapes to heap

再进一步测试, slice, map 这类可扩容的类型和 int 之类的基础类型是否一致呢?多大的容量会发生逃逸呢?

例如:可扩容类型变量,Slice 指定 8192Byte(64KB)的预估容量(Cap),刚好发生内存逃逸;基础类型变量,10MB 大小发生内存逃逸。

package main

type Person struct {
    Name   [4]byte
    EnName [4]byte
}

func main() {
    // 8B * 8192 = 64KB
    testSlice := make([]int, 0, 8192)
    testPersonSlice := make([]Person, 0, 8192)
    _ = testSlice
    _ = testPersonSlice

    // 10M
    var testNum [1024*1024*1.25]int
    _ = testNum

    var testNum2 [1024*1024*1.25 + 1]int
    _ = testNum2
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go
 
Output: 
# command-line-arguments
./main.go:19:6: moved to heap: testNum2
./main.go:10:19: make([]int, 0, 8192) escapes to heap
./main.go:11:25: make([]Person, 0, 8192) escapes to heap

由上可知,申请大局部变量时,基础类型变量如 int 最大可在栈上申请 <10MB,slice 之类可扩容的类型,可在栈上申请 <64KB 的内存空间。这种分配策略,猜测可能跟 Go 内存管理,mspan 的页大小为 8KB 有关系。

Note:在上述测试中,slice 的元素是基础变量,如果 slice 的元素是对象指针又是如何进行内存分配呢?

4. 切片变量自身和元素的逃逸

  1. 未指定 slice 的 length 和 cap 时,slice 自身未发生逃逸,slice 的元素发生了逃逸。因为 slice 会动态扩容,编译器不知道容量大小,无法提前在栈空间分配内存,扩容后 slice 的元素可能会被分配到堆空间,所以 slice 容器自身也不能被分配到栈空间。
package main

type Person struct {
    Name   string
    EnName string
}

func main() {
    var personList []*Person

    p1 := &Person{Name: "test1"}
    p2 := &Person{Name: "test2"}

    personList = append(personList, p1)
    personList = append(personList, p2)
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:11:22: &Person literal escapes to heap
./main.go:12:22: &Person literal escapes to heap
  1. 只指定 slice 的长度即 array, 数组本身和元素均在栈上分配内存,均未发生逃逸
package main

type Person struct {
    Name   string
    EnName string
}

func main() {
    var personList [3]*Person

    p1 := &Person{Name: "test1"}
    p2 := &Person{Name: "test2"}
    p3 := &Person{Name: "test3"}

    personList[0] = p1
    personList[1] = p2
    personList[2] = p3
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:11:22: main &Person literal does not escape
./main.go:12:22: main &Person literal does not escape
./main.go:13:22: main &Person literal does not escape

5. 闭包捕获变量

闭包其本质就是函数,当捕获其他函数的局部变量后,该局部变量会发生内存逃逸,因为闭包有可能是延迟函数,会晚于当前函数执行完毕,如果当前函数内的局部变量分配在栈空间, 那么闭包在执行时就无法找到该变量,出现野指针,所以当闭包捕获局部变量时,该局部变量一定会发生内存逃逸。

例如: 闭包捕获局部变量指针,该指针被分配到堆空间,基于不变性第二点,所指对象也被分配到堆空间。

package main

type Person struct {
    Name   string
    EnName string
}

func getPersonNameFunc(p *Person) func() string {
    return func() string {
        return p.Name
    }
}

func main() {
    badBoy := &Person{Name: "法外狂徒张三", EnName: "ZhangSan"}
    goodBoy := &Person{Name: "李四", EnName: "LiSi"}

    defer func() {
        _ = badBoy
    }()

    getPersonNameFunc(goodBoy)
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:8:24: leaking param: p
./main.go:9:9: func literal escapes to heap
./main.go:16:44: main &Person literal does not escape
./main.go:17:45: &Person literal escapes to heap
./main.go:19:8: main func literal does not escape

从上可以看出,goodBoy 被闭包捕获,badBoy 被 defer 声明的闭包捕获,但是 goodBoy 发生了内存逃逸, badBoy 并没有发生内存逃逸,这是因为捕获 badBoy 的闭包是 defer 声明的,该闭包在函数 return 后执行,即该闭包执行完毕,函数才真正执行完毕, 所以执行 defer 闭包时,不会释放函数栈上的内存,所以编译时在静态分析数据流后,badBoy 被分配到栈上。

6. 发送指针或带有指针的值到 channel 中

Golang 中经典的设计:不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存。

从而诞生了 channel 管道设计,实现 goroutine 之间共享内存数据,所以往 channel 中发送内存数据时,编译器不知道哪个 goroutine 何时会从 channel 中读出数据,所以不会分配到 goroutine 的栈空间,再者栈空间是 goroutine 的独立空间,共享内存数据也应该分配到堆空间才能实现共享。

例如:发送值对象到 channel 会拷贝,不会发生内存逃逸;发送对象指针到 channel 被捕获,发生内存逃逸,指针所指对象在堆空间分配。

package main

type Person struct {
    Name   string
    EnName string
}

func main() {
    badBoy := Person{Name: "法外狂徒张三", EnName: "ZhangSan"}
    goodBoy := &Person{Name: "李四", EnName: "LiSi"}

    // 发送值对象到 channel
    valueChannel := make(chan Person, 1)
    valueChannel <- badBoy

    // 发送对象指针到 channel
    pointerChannel := make(chan *Person, 1)
    pointerChannel <- goodBoy

    go func() {
        _, _ = <-valueChannel, <-pointerChannel
    }()
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:10:45: &Person literal escapes to heap
./main.go:20:5: func literal escapes to heap

思考:

  • 发送 slice 到 channel,会发生内存逃逸吗?
  • 发送 array 到 channel,会发生内存逃逸吗?
  • 发送 slice 到 channel,元素为值对象时,容器自身发生了内存逃逸,由于元素是值对象,发生了拷贝,原来的局部变量未发生内存逃逸;元素为指针对象时,容器自身和元素均发生内存逃逸。
package main

type Person struct {
    Name   string
    EnName string
}

func main() {
    badBoy := Person{Name: "张三", EnName: "ZhangSan"}
    goodBoy := &Person{Name: "李四", EnName: "LiSi"}

    // 发送值对象 slice 到 channel
    valueChannel := make(chan []Person, 1)
    valueChannel <- []Person{badBoy}

    // 发送对象指针 slice 到 channel
    pointerChannel := make(chan []*Person, 1)
    pointerChannel <- []*Person{goodBoy}

    go func() {
        _, _ = <-valueChannel, <-pointerChannel
    }()

    _, _ = <-valueChannel, <-pointerChannel
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:10:45: &Person literal escapes to heap
./main.go:14:26: []Person literal escapes to heap
./main.go:18:29: []*Person literal escapes to heap
./main.go:20:5: func literal escapes to heap
  • 发送 array 到 channel,元素为值对象时,容器和元素均未发生逃逸;元素为指针对象时,容器自身和元素均发生内存逃逸。
package main

type Person struct {
    Name   string
    EnName string
}

func main() {
    badBoy := Person{Name: "张三", EnName: "ZhangSan"}
    goodBoy := &Person{Name: "李四", EnName: "LiSi"}

    // 发送值对象 array 到 channel
    valueChannel := make(chan [1]Person, 1)
    valueChannel <- [1]Person{badBoy}

    // 发送对象指针 array 到 channel
    pointerChannel := make(chan [1]*Person, 1)
    pointerChannel <- [1]*Person{goodBoy}

    go func() {
        _, _ = <-valueChannel, <-pointerChannel
    }()

    _, _ = <-valueChannel, <-pointerChannel
}

// ============= Command && Output ==================
Command: 
go build -gcflags '-m -l' main.go

Output: 
# command-line-arguments
./main.go:10:45: &Person literal escapes to heap
./main.go:20:5: func literal escapes to heap

番外篇:当 channel element 大于 8192 Byte 时提示,不能大于 64KB。

// 发送值对象到 channel
valueChannel := make(chan [10086]Person, 1)
valueChannel <- [10086]Person{badBoy}

// 发送对象指针到 channel
pointerChannel := make(chan [10086]*Person, 1)
pointerChannel <- [10086]*Person{goodBoy}

# command-line-arguments
./main.go:13:22: channel element type too large (>64kB)
./main.go:17:24: channel element type too large (>64kB)

逃逸分析的作用

  1. 通过逃逸分析能确定哪些变量分配到栈空间,哪些分配到堆空间,对空间需要 GC 系统回收资源,GC 系统会有微秒级的 STW,降低 GC 的压力能提高系统的运行效率。
  2. 栈空间的分配比堆空间更快性能更好,对于热点数据分配到栈上能提高接口的响应。
  3. 栈空间分配的内存,在函数执行完毕后由系统回收资源,不需要 GC 系统参与,也不需要 GC 标记清除,可降低内存的占用,减少 RSS (常驻内存资源集),降低系统发生 OOM 的风险。

逃逸分析的总结

  • 栈空间分配内存比堆空间分配内存有更高的效率,不同版本的 Golang 版本优化不同,本文基于 go1.13.6 darwin/amd64 进行探究。
  • 逃逸分析目的是决定内存分配地址是栈空间还是堆空间,对于开发者不能显示通过 newmake 或者 literal 的方式指定分配空间。
  • 逃逸分析在编译阶段完成,可通过编译工具或者反编译生成汇编代码进行分析,前者方便快捷,后者准确。
  • 对于大局部变量、大内存, 使用容器类型:slice、map、 array 最好指明长度、分配到栈上性能最佳,针对明确会逃逸到堆上的变量,应该了解是否会被捕获发生循环引用导致无法 GC 的问题。
  • Golang 系统提供的函数,底层方法在 runtime 时 reflect 类型和生成对象,会发生内存逃逸,所以业务代码中尽量少使用反射,一方面提高代码可读性,另一方面把“逃逸”机会留给底层方法。
  • 日常开发中,无论分配到栈空间还是堆空间,不用过度的关心,只需了解常见的逃逸场景,遇到 OOM 时有思路去排查再优化即可。

项目实践

问题背景

本次探索源于 Sup 服务内存逐步升高,偶发性 OOM,隔几天就需要原地升级,稳定性隐患较大,@凌硕豪 在群里抛出担忧,大家开始讨论出现的原因。

image.png image.png image.png

从上可以分析出:

  1. 地址 node 没有成环,如果成环,接口 15s 超时而且内存应该直接暴涨,不是缓慢的上升。
  2. GC 系统正常工作,没有压力。
  3. 可能是 RSS 导致的,一直有块内存在堆空间无法得到释放,而且越来越多。

基于上述三点,猜测问题所在:堆空间内存一直在分配,不知道被谁引用着,导致无法释放

那么就有两个问题:

  1. 局部变量为何被分配到堆空间?
  2. 什么地方一直引用着该局部变量,导致无法 GC?

通过 pprof 工具观察并定位到代码位置,通过以上分析得出 deepcopy.Copy 方法会导致内存逃逸。 image.png

从代码逻辑看,deepcopy.Copy 在 runtime 时会进行反射操作,导致 cacheObject 发生内存逃逸,cacheObject 变量分配到堆空间,但是逃逸之后也会被 GC,到底在哪里被引用了呢?

原来在缓存框架中,创建缓存时,会被捕获且一直引用着,如下: image.png

解决方案

  1. @凌硕豪 去掉了 framework cache 中对 country 数据的租户隔离,避免 cache 占用和租户数正相关。
  2. @王军 去掉了多余的一次 deepcopy 库的深拷贝。

解决结果

sup 内存基本稳定在 35%,pprof heap 中也没有 country cache 的占用,目前占用较多的是灰度 feature。

image.png

总结

逃逸分析对于日常开发而言不可或缺,对于提高代码质量、降低接口延时、提高系统稳定性都有非常大的帮助,有时犹如一把利器割开束缚的口子,困难就迎刃而解。

欢迎大家斧正!

加入我们

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

扫码发现职位&投递简历

官网投递:job.toutiao.com/s/FyL7DRg