使用runtime.SetFinalizer优雅关闭后台goroutine - Go语言中文社区

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


问题

在日常项目的开发过程中, 总会使用后台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.SetFinalizer

func 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 alive
result := &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.NewFilenet.netFD 都注册了 finalizer 来避免用户由于忘记调用 Close 导致的 fd leak, 有兴趣的读者可以去看一下相关的代码。

版权声明:本文来源简书,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://www.jianshu.com/p/fcb40920071e
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。
  • 发表于 2020-01-12 12:50:54
  • 阅读 ( 1874 )
  • 分类:Go

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢