Skip to main content
  1. internet/

记一次内存泄露

·3950 words·8 mins·

#

在程序的一生中,似乎总是要经历一次内存泄露。

也许没有内存泄漏过的程序是不完美的。

前言 #

造成内存泄露的原因有很多(因为我经历的不多,所以特地问了下Claude):

问:go中内存泄露的原因有哪些

答:在Go语言中,内存泄露的常见原因包括:

  1. goroutine泄露:启动的goroutine忘记或无法正确关闭,持续占用内存。
  2. 缓存对象忘记释放:某些缓存对象(如图片、文件等)加载后忘记显式关闭或删除,导致持续占用内存。
  3. 引用计数循环:对象之间互相引用造成引用计数无法归零,从而无法释放内存。
  4. 关闭HTTP连接失败:没有正确关闭HTTP请求响应后生成的连接,导致连接未释放。
  5. 没有释放资源:打开文件、数据库连接等后,没有调用Close方法释放。
  6. 大对象分配:函数内部创建的大对象分配,函数退出后无法释放。
  7. 非池化对象:频繁创建一些非池化的对象,而没有重新使用。
  8. 并发数据结构:一些并发的数据结构如Map实现不当,导致内存泄露。

其中尤其需要注意goroutine泄露的问题,这是Go语言中一个常见的内存泄露来源。识别、预防和发现这些泄露的技巧非常重要。

Claude的回答很官方,也很简单——或者应该说幼稚。

作为一名成熟的程序员,我们不可能写出这么简单的造成内存泄露的代码,而内存泄露的问题又往往是因为这些简单的原因引起的。问题在于,程序的复杂性提升了简单问题的复杂性,而程序的复杂性是由其所依赖的大量组件导致的!

看下实际项目中引用的第三方包的数量,就明白了这个复杂性!

就像我在go中unmarshal的坑中写的:

工具虽然提高了生产效率,但同时也提高了系统的复杂性。

构建一个系统所使用的工具已经达到了一个离谱的数量。这些工具就像隐藏的炸弹一样,在某个时刻“砰”的一下炸掉整个系统。当然,结果就是又产生了一批专门解决这些“炸弹”的工具。。。

问题回顾 #

定位问题 #

线上的一个服务占用了大量内存,于是我们下载了pprof,内存占用的火焰图如下:

可以看到是在thttp.getBody中调用的io.ReadAll造成了内存泄露!

更准确的说,是在thttp.getBody中调用的io.ReadAll向内存申请的空间没有被释放

火焰图中的“堆栈”只是调用关系,而非数据的存储。

那么数据都去哪了,为什么没被释放?!

go中的内存回收使用的是三色标记法,那么也就是说这个数据是被其他存活的对象引用了导致的没有被回收。我们的目的就是找到这个存活的对象!

排查代码 #

通过查看代码,这个数据是需要被记录到日志的:

// 数据作为field传到了这里
func (sl spanLogger) Error(msg string, fields ...zapcore.Field) {
	sl.logToSpan("error", msg, fields...)
	sl.logger.Error(msg, append(sl.spanFields, fields...)...)
}

这时候有两条分支:一个是作为链路追踪用的日志(logToSpan),另一个是作为日志检索用的日志(logger),前者用的jaeger,后者用的zap.

两个组件都是go生态中非常流行的组件,因此出现内存泄漏问题的概率不大,但也不能完全相信。

两个组件的代码都大概看了以下,发现没有大的问题,其中jaeger中有个地方可能会产生问题:

// this function should only be called while holding a Write lock
func (s *Span) appendLogNoLocking(lr opentracing.LogRecord) {
	maxLogs := s.tracer.options.maxLogsPerSpan
  // 如果没有设置maxLogs,那么s.logs就会一直append,造成内存泄漏
	if maxLogs == 0 || len(s.logs) < maxLogs {
		s.logs = append(s.logs, lr)
		return
	}

	// We have too many logs. We don't touch the first numOld logs; we treat the
	// rest as a circular buffer and overwrite the oldest log among those.
	numOld := (maxLogs - 1) / 2
	numNew := maxLogs - numOld
	s.logs[numOld+s.numDroppedLogs%numNew] = lr
	s.numDroppedLogs++
}

如果没有设置maxLogs,那么s.logs就会一直append,造成内存泄漏。但是这个坑之前已经踩过并修复了(这是另一个服务内存泄漏问题的原因。。。),因此也不是这里的问题。

看不到问题,就只能debug了。好在本地能够复现!

通过不断测试,确定是链路追踪的日志数据没有被释放!

汇总一下当前的问题:

  1. 链路追踪的日志数据没被释放。

  2. 日志数据存储在Span对象中。

  3. jaeger会在上报完Span中的数据后不会清空Span而是靠垃圾回收机制自动释放。

    func (pool simpleSpanAllocator) Put(span *Span) {
    	// @comment https://github.com/jaegertracing/jaeger-client-go/pull/381#issuecomment-475904351
    	// since finished spans are not reused, no need to reset them
    	// span.reset()
    }
    
  4. 综上,可以确定Span被其他存活对象引用了!

又经过一番查找,终于终于找到了这个存活的对象,那就是context:

func ContextWithSpan(ctx context.Context, span Span) context.Context {
	// 。。。 省略无关代码
	// 这里将span作为value存入了ctx中
	return context.WithValue(ctx, activeSpanKey, span)
}

// context.WithValue的实现:
func WithValue(parent Context, key, val any) Context {
	// 。。。 省略无关代码
  // 将parent放到了valueCtx导致parent没被释放
	return &valueCtx{parent, key, val}
}

context为什么没被释放呢?

因为我们的服务有一个消费者,这个消费者每次获取一个消息后,会将之前的ctx作为父ctx生成了一个新的ctx(这是为了保证链路追踪的完整性,即每个消息都使用同一个trace id),这个过程也是调用的ContextWithSpan

总结 #

总结一下,问题是:

  1. 消费者每次消费数据时会新建一个ctx,并引用了旧的ctx,导致消费者产生的所有ctx都没有被释放。
  2. ctx中又放入了链路追踪中的Span,因此Span也没有被释放。
  3. Span中会存储大量的log,因此内存急剧上升!

题外话 #

如何判断一个对象是否被释放 #

既然已经推断出了Span和Context没有被释放,那么如何证明呢?

为了查看Span和Context的状态,我就需要引用它们,而我一旦引用了它们,它们就一定不会被释放,这似乎是个无解的问题。

好在go团队在垃圾回收过程中提供了回调函数,可通过runtime.SetFinalizer函数对某个对象设置回调!

对于这个问题——“如何判断一个对象是否被释放”,我是通过询问Claude获得的,这个过程可见下个“题外话”。

而实际上runtime.SetFinalizer这个函数我之前就已经了解过了,但是在使用的时候还是没能立即想起来,由此可见看和用是完全不同的事情!

记得当时和runtime.SetFinalizer一起看到的还有runtime.KeepAlive,两者经常搭配使用!

四个AI平台比较 #

对于判断一个对象是否被释放,我对四个AI平台分别进行了测试。

提问为:go中如何判断一个对象是否被释放.

以下为三个平台的回答

Chatgpt(3.5) #

在 Go 中,我们无法直接判断一个对象是否被释放。Go 语言的内存管理是通过垃圾回收器(Garbage Collector)自动管理的,当一个对象不再被引用时,垃圾回收器会在适当的时候自动回收该对象所占用的内存。

垃圾回收器是基于引用计数(reference counting)加标记清除(mark-and-sweep)算法的。当一个对象的引用计数为零时,垃圾回收器会将其标记为可回收的,然后在适当的时候进行垃圾回收并释放内存。

因此,我们无法直接判断一个对象是否被释放。相反,我们应该遵循以下一些规则来避免出现无效引用和内存泄漏:

- 避免循环引用:循环引用可能导致对象无法被回收,因此应尽量避免在对象之间创建循环引用关系。

- 及时释放资源:当我们使用的类型实现了资源释放的接口(如 io.Closer),我们应该在使用完后显式调用 Close() 方法,以确保资源能够被及时释放。

- 避免长时间持有对对象的引用:如果对象的生命周期已经结束,尽量及时释放对对象的引用,以便垃圾回收器能够回收该对象的内存。

- 注意内存泄漏的情况:内存泄漏是指分配的内存无法被及时释放,造成内存占用过高。在编写代码时,要注意避免意外的循环引用、不必要的全局变量、不恰当的缓存使用等情况,避免出现潜在的内存泄漏问题。

总之,Go 的垃圾回收器会自动管理内存的释放,我们需要遵循良好的编程习惯,及时释放不再使用的对象的引用,并注意避免出现潜在的内存泄漏问题。

BingChat #

在Go中,垃圾回收器会自动回收不再使用的对象。如果一个对象不再被引用,那么它就会被垃圾回收器回收。如果您想知道一个对象是否被垃圾回收器回收,可以使用runtime包中的SetFinalizer函数。SetFinalizer函数允许您设置一个函数,该函数在对象被垃圾回收器回收时调用。

Claude #

在Go语言中判断一个对象是否被释放主要有以下两种方法:

  1. 通过内存 profiler 工具来分析内存使用情况,判断对象是否被成功回收。常用的内存 profiler 工具有 pprof、go-torch 等。这些工具可以检测内存泄露等问题。

  2. 通过代码手动设置标记来跟踪对象的生命周期。例如:

func foo() *Obj {
  obj := &Obj{}
  runtime.SetFinalizer(obj, func(o *Obj) {
    fmt.Println("obj finalized") 
  })

  return obj
}

// 使用obj
obj := foo() 
// 过一段时间后,如果看到"obj finalized"打印,表示obj被标记为可回收并成功释放

通过runtime.SetFinalizer设置一个finalizer函数,它会在对象被垃圾回收时调用,可以确认对象是否被成功回收。

总体来说,利用工具profiler或手动跟踪的方式都可以有效判断Go对象的生命周期和是否被释放。但需要注意方法本身也会有一定的性能开销。

通义千问 #

在Go中,可以通过调用内存管理器的IsZero()方法来判断一个对象是否被释放。具体来说,如果对象的值为零,则表示该对象已经被释放。例如:

package main

import "fmt"

func main() {
    // 创建一个结构体对象
    x := myStruct{Value: 10}

    // 对象未被释放
    fmt.Println(x.IsZero()) // 输出 false

    // 释放对象
    x.Value = 0
    x = nil

    // 对象已被释放
    fmt.Println(x.IsZero()) // 输出 true
}

type myStruct struct {
    Value int
}

func (x myStruct) IsZero() bool {
    return x.Value == 0
}

在上述示例中,我们定义了一个名为myStruct的结构体,并实现了一个名为IsZero()的方法,该方法返回一个布尔值,表示对象的值是否为零。在main函数中,我们先创建了一个myStruct对象,并打印其IsZero()方法的返回值,表示对象未被释放。然后,我们将对象的值赋为零,并将对象指向nil,再次打印其IsZero()方法的返回值,表示对象已被释放。

结论 #

  1. Claude和BingChat的答案是最准确的,其中BingChat的回答最简洁并且完全理解我的需求。
  2. Chatgpt理解了问题,但是回答不准确,并且对go的垃圾回收机制有错误的理解。
  3. 通义千问没有理解问题。