Go-21-SetFinalizer

在阅读Go 1.20 新特性 arena 的时候,看到在构建 arena 对象的时候使用了 SetFinalizer 这样一个函数

带着问题看世界

  1. 它有什么用
  2. 它怎么用
  3. 它有什么缺点导致不是随处可见这种用法

这里来详细看看它的作用,备注如下

1
2
SetFinalizer sets the finalizer associated with obj to the provided finalizer function. When the garbage collector finds an unreachable block with an associated finalizer, it clears the association and runs finalizer(obj) in a separate goroutine. This makes obj reachable again,
but now without an associated finalizer. Assuming that SetFinalizer is not called again, the next time the garbage collector sees that obj is unreachable, it will free obj.

大意是:为对象提供一个析构函数,当GC发现不可达对象带有析构函数的时候,会单独使用协程执行这个析构函数。这样对GC来说对象是可达但没有了析构函数,下次GC发现对象不可达就会释放掉对象

有一些协程的生命周期是与整个服务一致的,比如定时清理机制,它的好处是自动处理一些业务而不需要人工调用,但如果是在一些与业务完全分离的场景。比如为业务提供一个缓存池,缓存池中为了清理过期的缓存而设计了一个常驻协程。是否可以使用 SetFinalizer 的过期删除机制

首先看一个栗子:

一般情况我们会提供对象一个 Close()函数用于业务在不需要的时候清理对象,这里就可以用到这个 SetFinalizer,如 os.NewFile 就注册了 SetFinalizer 逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func newFile(fd uintptr, name string, kind newFileKind) *File {
fdi := int(fd)
if fdi < 0 {
return nil
}
f := &File{&file{
pfd: poll.FD{
Sysfd: fdi,
IsStream: true,
ZeroReadIsEOF: true,
},
name: name,
stdoutOrErr: fdi == 1 || fdi == 2,
}}

//...

runtime.SetFinalizer(f.file, (*file).close)
return f
}

调用方式

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package main

import (
"fmt"
"runtime"
"time"
)

type Foo struct {
name string
num int
}

func finalizer(f *Foo) {
fmt.Println("a finalizer has run for ", f.name, f.num)
}

var counter int

func MakeFoo(name string) (a_foo *Foo) {
a_foo = &Foo{name, counter}
counter++
runtime.SetFinalizer(a_foo, finalizer)
return
}

func Bar() {
f1 := MakeFoo("one")
f2 := MakeFoo("two")

fmt.Println("f1 is: ", f1.name)
fmt.Println("f2 is: ", f2.name)
}

func main() {
for i := 0; i < 3; i++ {
Bar()
time.Sleep(time.Second)
runtime.GC()
}
fmt.Println("done.")
}

执行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
f1 is:  one
f2 is: two
a finalizer has run for two 1
a finalizer has run for one 0
f1 is: one
f2 is: two
a finalizer has run for two 3
a finalizer has run for one 2
f1 is: one
f2 is: two
a finalizer has run for two 5
a finalizer has run for one 4
done.

注意事项

  1. obj 必须是指针
  2. SetFinalizer 执行顺序按照类似对象的出栈顺序
  3. 可以通过 SetFinalizer(obj, nil) 清理对象的析构器

栗子2,它的实际效果

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package main

import (
"fmt"
"math/rand"
"runtime"
"runtime/debug"
"strconv"
"time"
)

type Foo struct {
a int
}

func main() {
debug.SetGCPercent(-1)

var ms runtime.MemStats
runtime.ReadMemStats(&ms)

fmt.Printf("Allocation: %f Mb, Number of allocation: %d\n", float32(ms.HeapAlloc)/float32(1024*1024), ms.HeapObjects)

for i := 0; i < 1000000; i++ {
f := NewFoo(i)
_ = fmt.Sprintf("%d", f.a)
}

runtime.ReadMemStats(&ms)
fmt.Printf("Allocation: %f Mb, Number of allocation: %d\n", float32(ms.HeapAlloc)/float32(1024*1024), ms.HeapObjects)

runtime.GC()
time.Sleep(time.Second)

runtime.ReadMemStats(&ms)
fmt.Printf("Allocation: %f Mb, Number of allocation: %d\n", float32(ms.HeapAlloc)/float32(1024*1024), ms.HeapObjects)

runtime.GC()
time.Sleep(time.Second)

runtime.ReadMemStats(&ms)
fmt.Printf("Allocation: %f Mb, Number of allocation: %d\n", float32(ms.HeapAlloc)/float32(1024*1024), ms.HeapObjects)
}

//go:noinline
func NewFoo(i int) *Foo {
f := &Foo{a: rand.Intn(50)}
runtime.SetFinalizer(f, func(f *Foo) {
_ = fmt.Sprintf("foo " + strconv.Itoa(i) + " has been garbage collected")
})

return f
}

运行结果

1
2
3
4
Allocation: 0.121063 Mb, Number of allocation: 140
Allocation: 29.111671 Mb, Number of allocation: 1899990
Allocation: 128.025635 Mb, Number of allocation: 4382420
Allocation: 0.122147 Mb, Number of allocation: 155

可以看出,正如它功能说的一样,在第二次GC之后,分配的内存被释放

它也有缺点:

  1. SetFinalizer 最大的问题是延长了对象生命周期。在第一次回收时执行 Finalizer 函数,且目标对象重新变成可达状态,直到第二次才真正 “销毁”。这对于有大量对象分配的高并发算法,可能会造成很大麻烦
  2. 指针构成的 “循环引⽤” 加上 runtime.SetFinalizer 会导致内存泄露
  3. SetFinalizer只在GC 发现对象不可达之后的任意时间执行,所以如果程序正常结束或者发生错误,而对象还没有被GC选中那么 SetFinalizer 也不会执行。

所以保险起见还是提供了 Close() 逻辑供业务调用

参考链接

  1. https://zhuanlan.zhihu.com/p/76504936
  2. https://go.dev/play/p/jWhRSPNvxJ
  3. https://medium.com/a-journey-with-go/go-finalizers-786df8e17687
  4. runtime/mfinal.go