使用runtime.SetFinalizer优雅关闭后台goroutine

使用runtime.SetFinalizer优雅关闭后台goroutine
机器人
摘要
lomtom

在 Go 语言中,使用 runtime.SetFinalizer 可以帮助我们优雅地关闭后台 goroutine,确保资源得到正确释放。本文将介绍 runtime.SetFinalizer 的用法以及如何利用它来解决后台 goroutine 的关闭问题。

什么是runtime.SetFinalizer?

func SetFinalizer(obj any, finalizer any)

Go 语言的 runtime.SetFinalizer 函数用于设置对象的 Finalizer(终结器)。当对象变为不可达且需要被垃圾回收时,Finalizer 将被调用。

具体地说,当 GC 发现对象不再被引用,并且该对象已经设置了 Finalizer,GC 会在单独的 goroutine 中执行 Finalizer 函数,然后清除对象的 Finalizer 关联,最终在第二次GC时回收对象。

需要注意的是,对象的 Finalizer 会在第一次 GC 时被执行,对象则会在下一次 GC 时回收。这意味着对象在第一次 GC 时仍然可能被保留。

用户场景

假设我们有一个应用:

  • 会在程序开启后(执行Start)之后,会开启一个goroutine执行一个定时任务,在定时任务内执行一些事情;
  • 并且提供了Stop方法供用户关闭该协程。
type App struct {
	stop chan bool
}

func NewApp() *App {
	return &App{
		stop: make(chan bool),
  }
}

func (a *App) Start() {
	go a.gc()
}

func (a *App) gc() {
	t := time.NewTicker(time.Second)
	for {
		select {
		case <-t.C:
			// doSomething
		case <-a.stop:
			return
		}
	}
}

func (a *App) Stop() {
	a.stop <- true
}

使用场景一:显式调用 Stop 方法

用户调用NewApp函数新建了一个app,并且开启Start执行定时任务,主程序运行一段时间后,用户在主程序退出之前显式的调用Stop方法关闭app中的goroutine。

func TestGC1(t *testing.T) {
    app := NewApp()
    app.Start()
    // dosomething
    app.Stop()
}

该场景并不会有什么问题,程序也能正常运行。

使用场景二:未显式调用 Stop 方法

用户调用NewApp函数新建了一个app,并且开启Start执行定时任务,主程序运行一段时间后,但是用户并没有在主程序退出之前显式的调用Stop方法关闭app中的goroutine。

func TestGC1(t *testing.T) {
	app := NewApp()
	app.Start()
    // dosomething
}

这样其实也并不会有什么大问题,也就是在主程序关闭之前会一直有一个goroutine在运行,一个小小的goroutine,占用内存也不大,所以也掀不起大风大浪。

使用场景三

用户需要频繁的调用NewApp函数新建了app,但是用户在开启任务后并没有调用Stop方法关闭app中的goroutine,并且由于一些原因,该app在执行一些时间后并且不需要用了。

例如以下模拟代码:

func TestGC2(t *testing.T) {
  index := 0
  for {
    app := NewApp()
    app.Start()
    index += 1
    time.Sleep(time.Second)
  }
}

随着主程序的运行,这个程序会启动越来越多的中的goroutine,并且app在不使用之后也没有得到正确的GC,那么程序占用的内存会越来越大,最终达到某一阈值后,程序崩溃或者重启,这无疑会影响主要的业务。

为了能够观察到该程序的goroutine数量,将上述代码改成以下:

func TestGC2(t *testing.T) {
	go func() {
		for {
			number := runtime.NumGoroutine()
			fmt.Println("number:", number)
			time.Sleep(time.Second)
			runtime.GC()
		}
	}()
	index := 0
	for {
		app := NewApp()
		app.Start()
		index += 1
		time.Sleep(time.Second)
	}
}

最终程序的输出为:

number: 4
number: 5
number: 6
number: 7
number: 8
number: 9
...

开始时,会有4个goroutine,并且随着for循环,程序中的goroutine会越来越多。

那么就可以使用runtime.SetFinalizer,即使用户不显式的关闭协程,也能够让垃圾回收器正确回收掉。

使用runtime.SetFinalizer

在实际应用中,我们可能会遇到用户未显式调用 Stop 方法的情况,从而导致后台 goroutine 无法正确关闭和资源泄漏的问题。为了解决这个问题,可以使用 runtime.SetFinalizer 来设置对象的 Finalizer,确保即使用户未调用 Stop 方法,后台 goroutine 也能得到正确的关闭和释放。

type App struct {
	stop chan bool
}

func NewApp() *App {
	app := &App{
		stop: make(chan bool),
	}
	runtime.SetFinalizer(app, func(a *App) {
		a.stop <- true
	})
	return app
}

func (a *App) Start() {
	go a.gc()
}

func (a *App) gc() {
	t := time.NewTicker(time.Second)
	for {
		select {
		case <-t.C:
			// doSomething
		case <-a.stop:
			return
		}
	}
}

理论上这样修改之后,再次运行使用场景三应该是能够正常GC的:

number: 4
number: 5
number: 6
number: 7
number: 8
number: 9
...

但是实际上,他仍然并没有被正确回收,这是为什么呢?

分析:

如果在上述代码的第十行也就是a.stop <- true处debug,就会发现,程序永远不会执行到该处,也就是说理论上应该GC的App对象并没有达到GC的条件。

这是因为App.Start的方法内运行的gc协程还是引用了App对象,所以此处的App对象仍然处于可达状态,也就是没有达到GC的标准。

那么如何解决这个问题呢?

在App对象的外层再套用一层对象AppWrapper,由于App.Start的方法内运行的gc协程还是引用了App对象,并没有引用AppWrapper对象,那么AppWrapper对象就会一段时间后达到GC的标准,这时候再利用runtime.SetFinalizer函数,将AppWrapper内的匿名字段App的gc协程停掉即可解决问题。

那么最终代码是这样的:

type App struct {
	stop chan bool
}
type AppWrapper struct {
	*App
}

func NewApp() *AppWrapper {
	app := &App{
		stop: make(chan bool),
	}
	appWrapper := &AppWrapper{app}
	runtime.SetFinalizer(appWrapper, func(a *AppWrapper) {
		a.stop <- true
	})
	return appWrapper
}

func (a *App) Start() {
	go a.gc()
}

func (a *App) gc() {
	t := time.NewTicker(time.Second)
	for {
		select {
		case <-t.C:
			// doSomething
		case <-a.stop:
			return
		}
	}
}

func (a *App) Stop() {
	a.stop <- true
}

最终结果能够达到预期:

number: 4
number: 3
number: 3
number: 3
number: 3
number: 3
...

总结

事实上,内存泄露的情况各式各样,runtime.SetFinalizer可以在一定程度上减少用户错误的使用协程导致的内存泄露。在某些方面,编程习惯程序也很大程度的影响程序的健壮性。

本次也是因为自己的工具类 🔗没有正确关闭协程导致的内存泄露,此工具也在不停的更新优化,感兴趣的可以尝试使用。

lomtom

标题:使用runtime.SetFinalizer优雅关闭后台goroutine

作者:lomtom

链接:https://lomtom.cn/c94mn84wd1yun