使用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, 有兴趣的读者可以去看一下相关的代码。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,001评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,210评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,874评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,001评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,022评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,005评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,929评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,742评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,193评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,427评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,583评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,305评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,911评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,564评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,731评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,581评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,478评论 2 352

推荐阅读更多精彩内容