go 内存泄漏

564 阅读4分钟

内存泄漏对于developer来说是十分常见的,每当出现内存泄漏后,都需要静下心来好好的review代码,寻找潜在的漏洞。

但对于 go developer又不是那么明显,因为 go 良好的内存管理(内存逃逸等技术),帮助 developer 没有必要这么在意内存释放的问题(相对于 c 系列)。

但是了解常见的内存泄漏场景,掌握分析内存的工具对于go developer的进阶又是格外重要的,下面总结了一些典型的内存泄漏场景,以及配套分析的工具,帮助大家一起迈过内存泄漏这道坎。

内存泄漏场景

在看具体的内存场景之前,我们先明确一件事,是什么导致了内存泄漏?

内存对象被多个变量引用,在gc回收时,由于其中一个未回收的变量仍在调用,导致其无法回收

我们需要理解gc是想回收的,但是发现这个内存对象,被其他的未回收的变量仍在引用,最终导致内存泄漏

字符串截取

由于go内部的优化导致的,对于一段字符串S的子截取sub_a、sub_b、sub_x,在底层会复用最大的父字符串S代表的字符串,从而避免内存频繁copy

但是由于复用了同一个字符串A,可能导致在不经意间泄漏了内存,看下面这个 case

var s0 string // 包层次变量,常规使用不会回收

func f(s1 string) {
    // s1 看起来没有任何直接引用会释放,但是由于 s0 是 s1 的子字符串,底层会复用 s1
    // 所以 s1 所占用的 1M 空间也不会释放
    s0 = s1[:50] 
}

func demo() {
    s := createStringWithLengthOnHeap(1 << 20) // 创建了 1M 内存
    f(s)
}

解决方案

string([]byte(xxx))

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

利用 []byte 能额外的创造出一块空间,但 []byte->string 又会额外创造出另一块空间,从而导致 2x50 字节空间的利用,多用了 50 字节

(" " + s1[:50])[1:]

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

使用了 50+1 的空间,多用了 1 字节

strings.Builder

import "strings"

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

除了太长,没有问题

strings.Repeat

import "strings"

func f(s1 string) {
    s0 = strings.Repeat(s1[:50],1)
}

strings.Builder 简化版,略丑

strings.Clone

import "strings"

func f(s1 string) {
    s0 = strings.Clone(s1[:50])
}

1.18 官方终于支持了

切片截取

与字符串截取类似,这种场景也存在同样的内存泄漏情况

var s0 []int

func g(s1 []int) {
    s0 = s1[len(s1)-30:]
}

解决方案

func g(s1 []int) {
    s0 = make([]int, 30)
    copy(s0, s1[len(s1)-30:])
}

通过申请新的内存空间,并利用copy函数实现元素的复制

timer.Ticker

使用完后,需要stop,否则会发生泄漏,把这种case当做文件、http链接对待

Finalizers

func memoryLeaking() {
    type T struct {
        v [1<<20]int
        t *T
    }
    var finalizer = func(t *T) {
         fmt.Println("finalizer called")
    }
    var x, y T
    // 会让 x 逃逸到堆上
    runtime.SetFinalizer(&x, finalizer)
    // x 和 y 发生循环引用
    x.t, y.t = &y, &x
}

这种泄漏的原因是:

  1. runtime.SetFinalizer帮助 x 逃逸到堆上
  2. x 和 y 发生循环引用,从而整体能避免回收

defer

由于在函数最后退出时才调用,会延迟内存释放,某种意义上也算是一种内存泄漏

协程泄漏

大量go出去的协程,没有结束,也会导致内存泄漏

内存泄漏查看工具

top

一般用来看哪些用cpu和memory的占用情况,可以很便捷的查看内存究竟占用了多少,这里需要注意以下几个内存参数:

  • VIRT(virtual):虚拟内存,并不代表使用占用的
  • RES(Reserved):驻留内存,代表实际占用
  • SHR(share):共享内存,代表与其他进程共享的空间,多用于进程通信或者对系统lib库的调用

memory leak.png

上图很好的展示了 VIRT、RES、SHR 三者的关系

pprof

go官方推荐的内存泄漏排查工具,能很好的从 cpu、memory(准确说是heap) 两个维度来查看内存的使用情况

因为pprof只能发现内存问题,而不一定是内存泄漏问题,所以存在无法定位的场景:

  • 真的内存泄漏:真的内存泄漏后,无法触达,也无法采样到
  • 是对堆的采样:不能完整的反应内存真实的使用情况,比如在goroutine泄漏时,内部使用的变量是在栈上,此时无法采样到
  • 未能采样到:如果使用频次很低,可能无法采样到

排查思路

  1. 用 top 看是否是当前进程占用的比重最大(top)

  2. 通过 pprof 采样该进程的 memory 的使用情况,若能定位到则很快的能发现内存泄漏位置。

  3. 通过 pprof 采样该进程的 cpu 的使用情况,看看哪块 cpu 使用的比较多,从而顺藤摸瓜抓到内存泄漏的位置

  4. 上述方法都无法发现,恭喜你踩到大坑了,这时候你需要静下心来,考虑下一些常见的内存泄漏场景

    • 各类资源文件,是否close,例如:timer.ticker
    • 所有 go 协程的位置
    • 全局变量

参考资料

Memory Leaking Scenarios -Go 101

理解virt res shr之间的关系 - linux - OrcHome

Golang资源泄露排查一例

实战Go内存泄露 | Go语言充电站