Golang的内存泄漏场景

看到个好文章,翻译一遍分享一下。

此为https://go101.org/article/memory-leaking.html的翻译,侵删。


内存泄漏场景

当使用支持自动垃圾回收的语言进行编程时,通常我们不需要关心内存泄漏问题,因为运行时会定期手机无用的内存。但是我们确实需要了解一些特殊场景,这些场景可能导致似(kind-of)/真内存泄漏。文章的后面部分会列出一些这样的场景。

子字符串导致的似内存泄漏

Go语言规范没有指明一个子字符串表达式中的基字符串和结果字符串是否应该共用同个底层内存块来管理两个字符串的底层字节序列。标准的Go编译器/运行时确实是让他们共享了同个底层内存块。这是一个很好的设计,不管是从内存还是CPU消化角度。但是有时它可能会导致某种内存泄漏。

比如,在下例中的demo函数被调用后,会有大约1M字节的似内存,知道package级变量s0在其他地方被修改。

var s0 string // 一个package级变量

// 一个用作示例的函数
func f(s1 string) {
	s0 = s1[:50]
	// 现在,s0与s1共享了同个底层内存块。尽管s1现在不存活了,
	// s0仍然存活。所以尽管只用到了50个字节,其他字节访问不到,
	// 他们共享的内存块并不会被回收。
}

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

为了避免这种似内存泄漏,我们可以转换底层字符串为一个[]byte,然后把[]byte转换回string.

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

上面这种避免这似内存泄漏的方法的缺点是,在会话处理过程中,会有两个50字节的副本,其中之一是没必要的。

我们可以利用标准Go编译器的其中一个优化来避免非必要的副本,只需要浪费一个字节的内存。

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

以上方法的缺点是,这个编译器优化在以后可能会被取消,并且这个优化可能在其他编译器中没有实现。

第三种避免这个似内存泄漏的方法是使用strings.Builder,从Go 1.10开始有的。

import "strings"

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

第三种方式的缺点是相比前两种有些麻烦。一个好消息是,从Go 1.12起,我们可以调用strings标准包的Repeat函数,传递count参数为1来克隆一个字符串。从Go 1.12起,string.Repeat的底层实现会使用strings.Builder来避免不必要的复制。

从Go 1.17起,strings标准包里加入了一个Clone函数。它成了做这个事的最好方法。

由子切片导致的似内存泄漏

与子字符串类似,子切片也可能导致似内存泄漏。在以下代码中,在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 = make([]int, 30)
	copy(s0, s1[len(s1)-30:])
	// 现在,如果没有其他值引用着存着s1的元素的内存块,
	// 它就可以被回收了。
}

没有重置丢失的切片元素的指针导致的似内存泄漏

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

func h() []*int {
	s := []*int{new(int), new(int), new(int), new(int)}
	// 做一些 s 相关的事情 ...

	return s[1:3:3]
}

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

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

func h() []*int {
	s := []*int{new(int), new(int), new(int), new(int)}
	// 做一些 s 相关的事情 ...

	// 重置指针值.
	s[0], s[len(s)-1] = nil, nil
	return s[1:3:3]
}

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

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

有时,一个Go程序中的一些goroutine会永远留在阻塞状态。这种goroutine被称为挂起的goroutine。Go运行时不会杀掉挂起的goroutine,所以为挂起的goroutine分配的资源(以及其引用的内存块)将永远不会被垃圾回收。

Go运行时不杀死挂起的goroutine有两个原因。一是有时Go运行时很难判断是否一个阻塞中的goroutine会永远被阻塞。二是,有时我们会故意让goroutine被挂起。比如,有时我们也许会让主goroutine挂起来避免程序退出。

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

没有停止不再使用的time.Ticker导致的真内存泄漏

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

不合理地使用Finalizers导致的真内存泄漏

为一个是循环引用组的成员之一的值设置finalizer可能会阻止回收为这个循环引用组分配的所有内存块。这是真内存泄漏,不是似。

例如,在以下函数被调用并退出后,为xy分配的内存块们不保证会被之后的垃圾回收回收掉。

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和y不可回收。
	x.t, y.t = &y, &x // y 也逃逸到了堆上。
}

所以请避免为一个循环引用组中的值设置finalizers。

顺便一说,我们不应该使用finalizers作为对象析构器。

Defer函数调用导致的似内存泄漏

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

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函数调用就能被更早的执行。比如,以上函数可以被重写提升为:

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
}

你可能感兴趣的:(Golang,golang,内存泄漏)