Memory Leak

Conception

内存溢出

指申请内存时,没有足够内存空间

出现原因:

  • 内存值设定过小

  • 内存中加载数据量过于庞大

  • 太多内存分配后没有回收,出现内存泄漏

内存泄漏 Memory Leak

强引用所指向的对象不会被回收,可能导致内存泄漏,虚拟机宁愿抛出OOM也不会去回收他指向的对象. 指申请了内存,但没有释放,导致这些内存无法被使用,内存空间浪费的情况。

Go 虽然有 GC 来回收,但是还是会出现内存泄漏的问题。

在Go中发现内存泄露有2种方法,一个是通用的监控工具,另一个是go pprof:

  1. 监控工具:固定周期对进程的内存占用情况进行采样,数据可视化后,根据内存占用走势(持续上升),很容易发现是否发生内存泄露。
  2. go pprof:适合没有监控工具的情况,使用Go提供的pprof工具判断是否发生内存泄露。

内存逃逸

内存分配有两种方式 : 堆分配 和 栈分配

内存逃逸 :Go中程序变量会携带一组校验数据,用来证明它的整个生命周期在程序运行时是否完全可知。如果变量通过了这些校验,它就可以在栈上分配,反之就可以说它逃逸了,这时就必须在堆上分配。

这样做虽然浪费堆空间,但是有效避免了悬挂指针的出现,并且由于GC的存在也不会出现内存泄漏,权衡之下也是一种合理的做法。

出现内存逃逸的情况:

  • 指针逃逸

    在方法内把局部变量指针返回时,会出现内存逃逸。因为局部变量原本应该在栈上分配,并且在栈中回收,但是由于在返回时被外部引用,因此该变量的生命周期大于栈,这时就会发生内存溢出。

  • 栈空间不足逃逸
    当栈空间不足时会分配到堆上。发送指针或是带有指针的值到channel中。因为在代码编译的时候,是没有办法知道是哪个goroutine会在channel上接收数据,所以编译器没办法知道变量什么时候才会被释放。

  • 切片中存储指针或是带有指针的值

    因为尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。

  • slice数组扩容可能导致内存逃逸

    如果在程序运行时扩容,就会在堆上分配

  • interface类型

    因为interface类型可以代表任意类型,编译器不知道参数会是什么类型,只有运行时才知道,因此只能分配到堆上。

指针传递比值传递更高效,但是在Go中并非如此,如果指针传递出现内存逃逸将内存分配到堆上后续就有会GC操作,消耗比值传递更大。

Memory Leaking Scenarios

当使用支持自动垃圾回收的语言编程时,通常我们不需要关心内存泄漏问题,因为运行时会定期收集未使用的内存。但是,我们确实需要注意一些可能导致内存泄漏或实际内存泄漏的场景

由子字符串引起的内存泄漏类型

Go 规范没有指定子字符串表达式中涉及的结果字符串和基字符串是否应共享相同的底层内存块来管理两个字符串的底层字节序列。标准的 Go 编译器/运行时确实允许它们共享相同的底层内存块。不管是从内存角度还是 CPU 这都是一个很好的设计。但有时它可能会导致内存泄漏。

例如,在调用以下示例中的 demo 函数后,将有大约 1M 字节的内存泄漏(有点),直到包级变量 s0 在其他地方再次修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var s0 string // a package-level variable

// A demo purpose function.
func f(s1 string) {
s0 = s1[:50]
// Now, s0 shares the same underlying memory block
// with s1. Although s1 is not alive now, but s0
// is still alive, so the memory block they share
// couldn't be collected, though there are only 50
// bytes used in the block and all other bytes in
// the block become unavailable.
}

func demo() {
s := createStringWithLengthOnHeap(1 << 20) // 1M bytes
f(s)
}

为了避免这种内存泄漏,我们可以将子字符串转换为一个 []byte 值,然后将 []byte 该值转换回 string 

1
2
3
func f(s1 string) {
s0 = string([]byte(s1[:50]))
}

上述避免内存泄漏的方法的缺点是在转换过程中发生了两个50字节的重复项,其中一个是不必要的。可以利用标准 Go 编译器所做的优化之一来避免不必要的重复,只需花费一个小的额外成本来浪费一个字节的内存。

1
2
3
func f(s1 string) {
s0 = (" " + s1[:50])[1:]
}

上述方式的缺点是编译器优化以后可能变得无效,并且优化可能无法从其他编译器获得。

避免内存泄漏的第三种方法是利用自 Go 1.10 以来支持的方法 strings.Builder 。

1
2
3
4
5
6
7
8
import "strings"

func f(s1 string) {
var b strings.Builder
b.Grow(50)
b.WriteString(s1[:50])
s0 = b.String()
}

第三种方式的缺点是它有点冗长(与前两种方式相比)。一个好消息是,从 Go 1.12 开始,我们可以像 strings 在标准包中一样 1 使用 count 参数调用 Repeat 函数来克隆字符串。从 Go 1.12 开始,底层 strings.Repeat 实现将使用 strings.Builder ,以避免不必要的重复。

从 Go 1.18 开始, strings 标准包中添加了一个 Clone 函数。它成为完成这项工作的最佳方式。

子切片导致的内存泄漏类型

与子字符串类似,子切片也可能导致内存泄漏。在下面的代码中,调用函数后 g ,托管元素 s1 的内存块占用的大部分内存将丢失(如果没有更多值指向这个内存块)。

1
2
3
4
5
6
var s0 []int

func g(s1 []int) {
// Assume the length of s1 is much larger than 30.
s0 = s1[len(s1)-30:]
}

如果我们想避免内存泄漏的类型,我们必须复制 的 30 个元素,这样s0的存活就不会阻止s1的内存块被回收了。

1
2
3
4
5
6
7
func g(s1 []int) {
s0 = make([]int, 30)
copy(s0, s1[len(s1)-30:])
// Now, the memory block hosting the elements
// of s1 can be collected if no other values
// are referencing the memory block.
}

未重置丢失的切片元素的指针

在下面的代码中,调用函数后 h ,分配给 slice 的第一个和最后一个元素的内存块 s 将丢失。

1
2
3
4
5
6
func h() []*int {
s := []*int{new(int), new(int), new(int), new(int)}
// do something with s ...

return s[1:3:3]
}

只要返回的切片仍然存活,它就会阻止s的任何元素被回收,这就会导致被s的第一个和最后一个元素指向的内存块无法被回收。

如果想要避免这种似内存泄漏,必须重置丢失的元素中的指针。

1
2
3
4
5
6
7
8
func h() []*int {
s := []*int{new(int), new(int), new(int), new(int)}
// do something with s ...

// Reset pointer values.
s[0], s[len(s)-1] = nil, nil
return s[1:3:3]
}

经常需要在切片元素删除操作中重置一些旧切片元素的指针。

由挂起的线程导致的内存泄漏

有时,Go 程序中的某些 goroutines 可能会永远处于阻塞状态。这样的goroutines称为挂起goroutines。Go 运行时不会杀死挂起的 goroutine,因此为挂起的 goroutines 分配的资源(以及引用的内存块)永远不会被垃圾回收。

Go 运行时不会杀死挂起的 goroutines 有两个原因。一个是有时 Go 运行时很难判断阻塞的 goroutine 是否会永远被阻塞。另一种是有时我们会故意让goroutine被挂起。例如,有时我们可能会让 Go 程序的主 goroutine 挂起以避免程序退出

应该避免代码设计上的逻辑错误造成的挂起的线程。

没有停止不再使用的 time.Ticker

当一个time.Timer值不再使用。它在一段时间后会被垃圾回收。但是对于time.Tikcer不是这样的。当不再使用其时,我们应该stop这个time.Ticker

不合理地使用Finalizers

例如,调用并退出以下函数后,分配的 x y 内存块在以后的垃圾回收中不保证被垃圾回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func memoryLeaking() {
type T struct {
v [1<<20]int
t *T
}

var finalizer = func(t *T) {
fmt.Println("finalizer called")
}

var x, y T

// The SetFinalizer call makes x escape to heap.
runtime.SetFinalizer(&x, finalizer)

// The following line forms a cyclic reference
// group with two members, x and y.
// This causes x and y are not collectable.
x.t, y.t = &y, &x // y also escapes to heap.
}

避免为循环引用组中的值设置终结器。不应该使用终结器作为对象析构函数。

Defer函数调用

一个非常大的defer调用栈也可能消耗很多内存,未执行的defer调用可能会阻止一些资源被及时释放。例如,如果有许多文件需要在对以下函数的一次调用中被处理,大量文件句柄将在函数退出前无法被释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func writeManyFiles(files []File) error {
for _, file := range files {
f, err := os.Open(file.path)
if err != nil {
return err
}
defer f.Close()

_, err = f.WriteString(file.content)
if err != nil {
return err
}

err = f.Sync()
if err != nil {
return err
}
}

return nil
}

如果在调用以下函数时需要处理许多文件,则在函数退出之前不会释放大量文件处理程序。对于这种情况,可以使用一个匿名函数来包裹defer调用,这样 defer 函数调用就能被更早的执行。比如,以上函数可以被重写提升为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func writeManyFiles(files []File) error {
for _, file := range files {
if err := func() error {
f, err := os.Open(file.path)
if err != nil {
return err
}
// The close method will be called at
// the end of the current loop step.
defer f.Close()

_, err = f.WriteString(file.content)
if err != nil {
return err
}

return f.Sync()
}(); err != nil {
return err
}
}

return nil
}

总结

简单归纳一下,还是“临时性”内存泄露和“永久性”内存泄露:

  • 临时性泄露,指的是该释放的内存资源没有及时释放,对应的内存资源仍然有机会在更晚些时候被释放,即便如此在内存资源紧张情况下,也会是个问题。这类主要是string、slice底层buffer的错误共享,导致无用数据对象无法及时释放,或者defer函数导致的资源没有及时释放。
  • 永久性泄露,指的是在进程后续生命周期内,泄露的内存都没有机会回收,如goroutine内部预期之外的for-loop或者chan select-case导致的无法退出的情况,导致协程栈及引用内存永久泄露问题。

内存泄露问题快速定位

推测一:怀疑是 goroutine 逃逸

通常内存泄露的主因就是 goroutine 过多,因此首先怀疑 goroutine 是否有问题,去看了 goroutine 发现很正常,总量较低且没有持续增长现象。(当时忘记截图了,后来补了一张图,但是 goroutine 数量一直是没有变化的)

没有 goroutine 逃逸问题。

推测二:怀疑代码出现了内存泄露

通过 pprof 进行实时内存采集,对比问题实例和正常实例的内存使用状况:

正常实例:

进一步看问题实例的 graph:

从中可以发现,metircs.flushClients()占用的内存是最多的,去定位源码:

发现里面为了规避内存泄露,已经通过计数的方式,定数清理掉 sync.Map 存储的 key 了。理论上不应该出现问题。

推测三:怀疑是 RSS 的问题

这时注意到了一个事情,在 pprof 里看到 metrics 总共只是占用了 72MB,而总的 heap 内存只有 170+MB 而我们的实例是 2GB 内存配置,占用 80%内存就意味着 1.6GB 左右的 RSS 占用,这两个严重不符(这个问题的临时解决方法在后文有介绍),这并不应该导致内存占用 80%报警。因此猜测是内存没有及时回收导致的。

经过排查,发现了这个神奇的东西:

一直以来 go 的 runtime 在释放内存返回到内核时,在 Linux 上使用的是  MADV_DONTNEED,虽然效率比较低,但是会让 RSS(resident set size 常驻内存集)数量下降得很快。不过在 go 1.12 里专门针对这个做了优化,runtime 在释放内存时,使用了更加高效的  MADV_FREE  而不是之前的  MADV_DONTNEED。详细的介绍可以参考这里:

https://go-review.googlesource.com/c/go/+/135395

Go 1.12~1.15 runtime 优化了 GC 策略,在 Linux 内核版本支持时 (> 4.5),会默认采用更『激进』的策略使得内存重用更高效、延迟更低等诸多优化。带来的负面影响就是 RSS 并不会立刻下降,而是推迟到内存有一定压力时。

解决办法

升级 go 编译器版本到 1.16 以上

看到 go 1.16 的更新说明。已经放弃了这个 GC 策略,改为了及时释放内存而不是等到内存有压力时的惰性释放。看来 go 官网也觉得及时释放内存的方式更加可取,在多数的情况下都是更为合适的。

附:解决 pprof 看 heap 使用的内存小于 RSS 很多的问题,可以通过手动调用 debug.FreeOSMemory 来解决,但是执行这个操作是有代价的。

reference

从真实事故出发:golang 内存问题排查指北 | HeapDump性能社区

Go程序内存泄露问题快速定位 - MySpace