Go 中的内存泄漏

本文译自(Memory Leaking)版权@归原文所有.

当使用带有垃圾回收器(GC)的语言编程时, 通常我们不需要关心内存泄漏问题, 因为语言运行时(runtime)会定期收集未使用的内存. 但是, 我们确实需要了解一些可能导致轻微的或者真正的内存泄漏的特殊场景. 本文剩下的部分将列出几个这样的情况.

求子字符串(Substrings)导致的轻微内存泄露

Go 规范没有指定在子字符串表达式中涉及的结果字符串和基本字符串是否应共享相同的底层内存块,该内存块托管两个字符串的底层字节序列. Go 标准编译器/运行时确实会让它们共享相同的底层内存块. 这是一个很好的设计, 这既是内存也是 CPU 消耗的明智之举. 但它可能会导致内存泄漏.

例如, 调用下面的函数 f 后, 将有 1M 字节的内存泄漏(轻微), 直到其他地方修改了包级(package-level)变量 s0.

var s0 string // package level variable

func f(s1 string) {
// 假设 s1 是一个长度大于 50 的字符串.
s0 = s1[:50]
// 现在, s0 和 s1 共享相同的底层内存块.
// s1 现在不存活了, 但是 s0 依然存活.
// 尽管仅有 50 个字节在内存块中,
// s0 仍旧存活的事实阻止了这 1M 字节的内存块被回收.
}

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

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

上述避免这种轻微内存泄漏方法的缺点是在转换过程中发生了 50 字节的复制, 其中一个是不必要的.

我们可以使用 Go 标准编译器进行的一种优化来避免一次复制, 并且伴随着浪费一个字节的小的额外成本.

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

上述方法的缺点是编译器优化可能会在以后失效,并且优化可能不适用于其他编译器.

避免类型内存泄漏的第三种方法是使用直到 Go 1.10 才开始支持的 strings.Builder .

import "strings"

func f(s1 string) {
var b strings.Builder
b.Grow(50)
b.WriteString(s1[:50])
s0 = b.String()
// b.Reset() // 如果 b 在其他地方会用到, 那么它必须在这里重置掉.
}

第三种方式的缺点是有点冗长(通过比较前两种方式).

求子切片(Subslices)导致的轻微内存泄露

与求子串类似, 求子切片也可能导致轻微的内存泄漏. 在下面的代码中, 调用 g 函数后, 承载 s1 元素的内存块占用的大部分内存将会丢失(如果没有更多值引用内存块).

var s0 []int

func g(s1 []int) {
// 假设 s1 的长度远远大于 30.
s0 = s1[len(s1)-30:]
}

如果我们想避免这种轻微的内存泄漏, 我们必须复制 s0 的 30 个元素, 以便 s0 的存活不会阻止 s1 元素的内存块被回收.

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

不存活切片元素未重置指针导致的轻微内存泄露

在下面的代码中, 调用 g 函数之后, 分配给切片 s 的第一个元素的内存块会丢失. 如果最后一个元素以后从未用作任何切片的元素, 则为最后一个元素分配的内存块也会丢失.

func g() []*int {
s := []*int{new(int), new(int), new(int), new(int)}
return s[1:3]
}

如果返回的切片仍然存活, 那么它将阻止收集 s 的元素的底层内存块, 从而防止从 s 的第一个元素到最后一个元素分配的两个内存块被收集, 尽管两个元素已经不存活了.

如果我们想避免这种轻微内存泄漏, 我们必须重置不存活元素中的指针(这里, 在函数 h 被调用后, 第一个和最后一个元素被视为不存活元素).

func h() []*int {
s := []*int{new(int), new(int), new(int), new(int)}
s1 := s[1:3]
s[0] = nil; s[len(s)-1] = nil
return s1
}

我们经常需要重置切片元素删除操作中不存活元素的指针.

迷失的 Goroutines 导致的内存泄露

有时, 对于代码设计中的一些逻辑失误, 一个或多个 goroutine 会永远处于阻塞状态, 这将导致这些 goroutine 中使用的许多代码块永远无法进行垃圾收集. 这是真正的内存泄漏.

例如, 如果将以下函数作为 goroutine 的启动函数并将一个 nil channel 参数传递给它, 则 goroutine 将永远阻塞. Go 运行时认为 goroutine 仍然存活, 所以为 s 分配的内存块将永远不会被收集.

func k(c <-chan bool) {
s := make([]int64, 1e6)
if <-c { // 如果 c 为 nil, 这里将永远阻塞
_ = s
// 使用 s, ...
}
}

我们应该避免这种逻辑失误.

终结器(Finalizers)

为循环引用组内的成员设置 finalizer 可能会阻止为这个循环引用组分配的所有内存块被收集. 这不是轻微而是真正的内存泄露.

在下列函数被调用并退出之后, 为 x 和 y 分配的内存块不保证在未来会被垃圾收集器回收.

func memoryLeaking() {
type T struct {
v [1<<20]int
t *T
}

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

var x, y T

// SetFinalizer 会使 x 逃逸到堆上.
runtime.SetFinalizer(&x, finalizer)

// 以下语句将导致 x 和 y 变得无法收集.
x.t, y.t = &y, &x // y 也逃逸到了 堆上.
}

所以, 请避免为循环引用组中的值设置终结器(finalizers).