网站Logo 90的blog

内存泄漏

root
14
2025-03-02

Go 和 Python 都有垃圾回收机制。通常情况下,我们创建对象后,等对象没有任何引用时,运行时会自动回收它们。

例如 Go:

func createUser() {
	user := &User{Name: "Alice"}
	fmt.Println(user.Name)
}

createUser 执行结束后,user 不再被引用,后续可以被 GC 回收。

但如果我们把它放进一个全局变量、缓存、长期运行的 goroutine,或者没有退出的队列中,它就依然“可达”。GC 不会判断它是不是“业务上没用了”,只能判断“是否仍被引用”。

所以,垃圾回收解决的是“手动释放内存”的问题,并不能替我们判断对象是否还应该存在。

常见的内存泄漏情况

1. 全局缓存只进不出

最直观的例子是缓存。

var cache = make(map[string][]byte)

func save(key string, data []byte) {
	cache[key] = data
}

如果服务持续运行、key 持续增加,cache 会越来越大。即使某些数据早就没用了,因为它们还被 map 引用,GC 也不会回收。

解决思路通常是:

  • 设置缓存过期时间;

  • 限制缓存最大容量;

  • 使用带淘汰策略的缓存,例如 LRU;

  • 明确哪些数据不应该放入全局缓存。

2. goroutine 没有退出

Go 的 goroutine 很轻量,但不是没有成本。如果不断创建 goroutine,又没有让它们退出,内存和调度资源都会持续被占用。

例如:

func worker(ch <-chan int) {
	for {
		value := <-ch
		fmt.Println(value)
	}
}

如果 ch 后续不再发送数据,但也没有关闭,worker 会永远阻塞在这里,goroutine 无法退出。

更常见的写法是配合 context.Context

func worker(ctx context.Context, ch <-chan int) {
	for {
		select {
		case value, ok := <-ch:
			if !ok {
				return
			}
			fmt.Println(value)

		case <-ctx.Done():
			return
		}
	}
}

以前我会把这种问题只理解为“协程泄漏”,但它本质上也会带来内存问题:goroutine 的栈、局部变量,以及它引用的对象都可能一直无法释放。

3. 切片引用了一个很大的底层数组

这是 Go 里很容易忽略的一类问题。

data := make([]byte, 10*1024*1024) // 10 MB
small := data[:10]

虽然 small 只有 10 个字节,但它仍然引用着原来的 10 MB 底层数组。如果 small 被长期保存,大数组就不能被回收。

如果只需要小切片的数据,可以复制一份:

smallCopy := make([]byte, 10)
copy(smallCopy, data[:10])

或者:

smallCopy := append([]byte(nil), data[:10]...)

这个问题提醒我:看内存占用时,不能只看切片的 len,还要考虑它背后到底引用了多少数据。

4. 定时器和资源没有停止或关闭

比如 time.Ticker、文件、HTTP 响应体、数据库查询结果等,都应当在合适的时候释放。

ticker := time.NewTicker(time.Second)
defer ticker.Stop()

HTTP 请求也要注意关闭响应体:

resp, err := http.Get(url)
if err != nil {
	return err
}
defer resp.Body.Close()

严格说,这些有些属于资源泄漏,而不只是堆内存泄漏。但长期运行的服务里,它们往往会一起出现:文件描述符耗尽、连接池堆积、内存持续上升,最后让服务变得不稳定。

内存泄漏在python中情况也是类似,比如全局列表或者字典不断累积,文件或者网络连接没有被正确关闭(一般使用with管理资源)

with open("data.txt", "r", encoding="utf-8") as file:
    content = file.read()

这样即使中途出现异常,文件也会被正确关闭。

对于数据库连接、HTTP 客户端会话、异步任务,同样需要有明确的关闭逻辑。

总结

我现在对内存泄漏的理解是:

内存泄漏不只是忘记释放内存,更常见的是忘记让不再需要的对象“失去引用”。

Go 和 Python 的 GC 能帮我们回收无用对象,但它们不会知道业务上哪些数据已经过期,也不会主动结束 goroutine、清理无限增长的缓存。

因此,写长期运行的程序时,比起只关心“能不能跑”,还应该多问一句:

这个对象、缓存、协程或连接,什么时候应该结束?

动物装饰