Featured image of post Golang 1.24: runtime.AddCleanup 改进 runtime.SetFinalizer 的一些问题

Golang 1.24: runtime.AddCleanup 改进 runtime.SetFinalizer 的一些问题

Golang 1.24 introduces `runtime.AddCleanup`, improving upon `runtime.SetFinalizer` by allowing multiple cleanup functions and better handling of object references, thus preventing memory leaks and enabling timely cleanup. #Golang #Go1_24

 

以前,我写过一篇文章介绍runtime.SetFinalizer 这个函数,用于在对象被清理的时候调用,但是这个函数有一些问题,导致它的使用频率比较低。

  • 在 Go 中使用 SetFinalizer 方法时,必须确保传递给它的对象引用指向分配内存的起始位置(即分配内存块的第一个字节)。这要求程序员理解“分配”(allocation)的概念,而这个概念通常在 Go 语言的抽象层级中并未明确暴露。
  • 对象只能定义一个SetFinalizer
  • 带有SetFinalizer的对象如果涉及到任何引用循环,将无法被释放,SetFinalizer也不会运行。
  • 带有SetFinalizer的对象至少需要两个 GC 循环才能被释放。
    https://github.com/golang/go/issues/67535

对于第一条,我们可以用一个例子说明

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
	"fmt"
	"runtime"
)

type MyStruct struct {
	Field int
}

func main() {
	obj := &MyStruct{Field: 42}
	runtime.SetFinalizer(obj, func(m *MyStruct) {
		fmt.Println("Finalizer called for:", m)
	})

	// 错误示例:传递字段引用而非对象本身
	// runtime.SetFinalizer(&obj.Field, func(m *int) {
	//     fmt.Println("Finalizer called for:", *m)
	// })
}

基于上面的原因,在Golang 1.24 中添加了一个新的函数runtime.AddCleanUp 来替换 runtime.SetFinalizer

注意: runtime.AddCleanupruntime.SetFinalizer 都不保证 清理函数一定会被执行。

AddCleanup的设计目标是解决runtime.SetFinalizer的诸多问题,特别是避免对象复活,从而允许对象的及时清理,并支持对象的循环清理。
AddCleanup函数的原型如下:

1
func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup

基于此,可以写一个RAII(Resource Acquisition Is Initialization)的 Demo 。在go weak 的文章中,我们实现了一个固定长度的cache,改一下代码,添加一个newElemWeak 方法,当最旧的elem 被逐出的时候,会自动调用deletekeycache 中删除。不需要我们手动管理。

1
2
3
4
5
6
7
func (c *WeakCache) newElemWeak(elem *list.Element) weak.Pointer[list.Element] {
	elemWeak := weak.Make(elem)
	runtime.AddCleanup(elem, func(name string) {
		delete(c.cache, name)
	}, elem.Value.(*CacheItem).key)
	return elemWeak
}

需要注意的几个问题

AddCleanupptr的约束很少,支持为同一个指针附加多个清理函数。不过,如果ptr可以从cleanuparg中可达,ptr将永远不会被回收(memory leak),清理函数也永远不会运行,目前来看这种情况也不会panic。 或许以后会用 GODEBUG=gccheckmark=1 这种模式来检测?
比如
https://gist.github.com/hxzhouh/5bb1d55259dcb4dab87b37beaef9bea2

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func NewFileResource(filename string) (*os.File, error) {
	file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0666)
	if err != nil {
		return nil, err
	}

	runtime.AddCleanup(file, func(f *os.File) {
		fmt.Println("close f")
		_ = f.Close()
	}, file)

	return file, nil
}

fmt.Println("close f") 不会被打印,file 也不会被关闭。

Try It
当给一个ptr绑定多个cleanup 的时候,因为cleanup 是在一个独立的goroutine 中运行,所以它的运行顺序可能是不固定的。

特别是,如果几个对象相互指向并且同时变成 unreachable,它们的清理函数都可以运行,并且可以以任何顺序运行。即使对象形成一个循环也是如此(runtime.SetFinalizer 在这种情况下会产生内存泄露)。
例如

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func main() {
	x := MyStruct{Name: "X"}
	y := MyStruct{Name: "Y"}

	x.Other = &y
	y.Other = &x
	//runtime.SetFinalizer(&x, func(x *MyStruct) {
	//	fmt.Printf("Finalizer for %s is called\n", x.Name)
	//})
	//runtime.SetFinalizer(&y, func(y *MyStruct) {
	//	fmt.Printf("Finalizer for %s is called\n", y.Name)
	//})
	xName := x.Name
	runtime.AddCleanup(&x, func(name string) {
		fmt.Println("Cleanup for", x)
	}, xName)
	yName := y.Name
	runtime.AddCleanup(&y, func(name string) {
		fmt.Println("Cleanup for", x)
	}, yName)
	time.Sleep(time.Millisecond)
	runtime.GC()
	time.Sleep(time.Millisecond)
	runtime.GC()
}

https://gist.github.com/hxzhouh/ca402c723faa78726baba7e56bff573a

1
2
3
➜  AddCleanUp git:(main) ✗ gotip run main.go
Cleanup for Y
Cleanup for X

SetFinalizer 的会阻止 GC,但是 AddCleanup 能够正常执行

参考资料

  1. https://github.com/golang/go/issues/67535
Licensed under CC BY-NC-SA 4.0
最后更新于 Dec 17, 2024 09:55 CST
使用 Hugo 构建
主题 StackJimmy 设计