查看原文
其他

如何优雅关闭后台goroutine?

Go开发大全 2021-01-31

(给Go开发大全加星标)

来源:刘士涛

https://zhuanlan.zhihu.com/p/76504936

【导读】golang超时的channel就不去关掉了吗?应该如何关掉卡住的channel?本文通过istio的实现展示了runtime.SetFinalizer的正确使用姿势。


问题

在日常项目的开发过程中, 总会使用后台goroutine做一些定期清理或者更新的任务, 这就涉及到goroutine生命周期的管理。


处理方式


(一)对于和主程序生命周期基本一致的后台goroutine,一般采用如下显式的`Stop()`来进行优雅退出:

type IApp interface { //... Stop()}
type App struct { // some vars
running bool stop chan struct{} onStopped func()}
func New() *App { app := &App{ running: true, stop: make(chan struct{}), } go watch() return app}
func (app *App) watch() {
ticker := time.NewTicker(time.Second) defer ticker.Stop()
for { select { case <-app.stop: if app.onStopped != nil { app.onStopped() } return case <-ticker.C: //do something } }}
func (app *App) Stop() { if !app.running { return } close(app.stop)}

这种方式除了需要在程序终止之前显式调用一下`Stop()`, 没有其他的问题。但是在其他的一些场景中, 你可能就会confuse了


(二) 比如我现在想实现一个cache模块,功能和接口都很简单:

type Cache interface { Get(key string) (interface{}, bool) Set(key string, value interface{})}

由于需要定时清理过期的缓存, 所以会使用一个后台goroutine来执行清理的工作, 但是这些应该是对使用者透明的, 不过往往总会出现一些意料之外的结果:

func main() { c := cache.New() c.Set("key1", obj) val, exist := c.Get("key1") // ... c = nil // do other things}

在使用者看来, cache已经没有引用了, 会在gc的时候被回收。 但实际上由于后台goroutine的存在, cache始终不能满足不可达的条件, 也就不会被gc回收, 从而产生了内存泄露的问题。


解决这个问题当前也可以按照上面的方式, 显式增加一个`Close()`方法, 靠channel通知关闭goroutine, 但是这无疑增加了使用成本, 而且也不能避免使用者忘记`Close()`这种场景。


还有没有更好的方式,不需要用户显式关闭, 在检查到没有引用之后, 主动终止goroutine,等待gc回收? 当然。 `runtime.SetFinalizer` 可以帮助我们达到这个目的。

runtime.SetFinalizerfunc SetFinalizer(obj interface{}, finalizer interface{})
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.

上面是官方文档对SetFinalizer的一些解释,主要含义是对象可以关联一个SetFinalizer函数, 当gc检测到unreachable对象有关联的SetFinalizer函数时,会执行关联的SetFinalizer函数, 同时取消关联。 这样当下一次gc的时候,对象重新处于unreachable状态并且没有SetFinalizer关联, 就会被回收。


仔细看文档,还有几个需要注意的点:


  • 即使程序正常结束或者发生错误, 但是在对象被 gc 选中并被回收之前,SetFinalizer 都不会执行, 所以不要在SetFinalizer中执行将内存中的内容flush到磁盘这种操作

  • SetFinalizer 最大的问题是延长了对象生命周期。在第一次回收时执行 Finalizer 函数,且目标对象重新变成可达状态,直到第二次才真正 “销毁”。这对于有大量对象分配的高并发算法,可能会造成很大麻烦

  • 指针构成的 "循环引⽤" 加上 runtime.SetFinalizer 会导致内存泄露


正确姿势


回到上面的问题, 如何利用SetFinalizer来进行cache后台goroutine的清理呢?

istio的中lrucache给了我们一种巧妙的方式:

type lruWrapper struct { *lruCache}
// We return a 'see-through' wrapper for the real object such that// the finalizer can trigger on the wrapper. We can't set a finalizer// on the main cache object because it would never fire, since the// evicter goroutine is keeping it aliveresult := &lruWrapper{c}runtime.SetFinalizer(result, func(w *lruWrapper) { w.stopEvicter <- true w.evicterTerminated.Wait()})

在lrucache外面加上一层wrapper, lrucache作为wrapper的匿名字段存在, 并且在wrapper上注册了SetFinalizer函数来终止后台的goroutine。 由于后台goroutine是和lrucache关联的, 当没有引用指向wrapper的时候, gc就会执行关联的SetFinalizer终止lrucache的后台goroutine,这样最终lrucache也会变成不可达的状态, 被gc回收。

type Cache = *wrapper
type wrapper struct { *cache}
type cache struct { content string stop chan struct{} onStopped func()}
func newCache() *cache { return &cache{ content: "some thing", stop: make(chan struct{}), } }
func NewCache() Cache { w := &wrapper{ cache : newCache(), } go w.cache.run() runtime.SetFinalizer(w, (*wrapper).stop) return w}
func (w *wrapper) stop() { w.cache.stop()}
func (c *cache) run() { ticker := time.NewTicker(time.Second) defer ticker.Stop()
for { select { case <-ticker.C: // do some thing case <-c.stop: if c.onStopped != nil { c.onStopped() } return } }}
func (c *cache) stop() { close(c.stop)}

对于对象是否被回收, 最靠谱的方式就是靠test来检测并保证这一行为:

func TestFinalizer(t *testing.T) { s := assert.New(t)
w := NewCache() var cnt int = 0 stopped := make(chan struct{}) w.onStopped = func() { cnt++ close(stopped) }
s.Equal(0, cnt)
w = nil
runtime.GC()
select { case <-stopped: case <-time.After(10 * time.Second):t.Fail() }
s.Equal(1, cnt)}

事实上,在基础库中SetFinalzer主要的使用场景是减少用户错误使用导致的资源泄露,比如 os.NewFile() 和 net.FD() 都注册了 finalizer 来避免用户由于忘记调用 `Close` 导致的 fd leak, 有兴趣的读者可以去看一下相关的代码。


 - EOF -

推荐阅读(点击标题可打开)

1、基于 etcd 实现分布式锁

2、开源goraft源码分析

3、Go逃逸分析从入门到精通

如果觉得本文不错,欢迎转发推荐给更多人。


分享、点赞和在看

支持我们分享更多好文章,谢谢!

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存