goleng-set错误使用导致的内存泄漏

140 阅读4分钟

事情发生在公元2021年2月份,石同学接到了一个开发任务,考虑不到本章主要介绍golang-set相关的问题,因此开发任务简单理解为:开发一个条件过滤器,用于判断某一件商品的属性是否包含于预设的屏蔽规则。考虑到该功能后续的扩展性和条件快速匹配的性能要求,很容就确定了该功能的开发要基于集合数据结构实现,然后石同学查阅了一些资料发现,golang本身没有提供set这种数据结构,但是有一个比较流行的第三方包——golang-set,这个第三方包的实现方法十分简单,同时已经被应用于docker项目中,可以说十分成熟且可靠性很好。

开发过程十分顺利,在去掉业务相关以及相似逻辑之后,业务代码可以简化为如下所示代码片段:

//the set is the 
var forbiddenWords mapset.Set

func Filter(sku SkuParam) bool{
    for forbiddenWord := range forbiddenWords.Iter() {
        if strings.Contains(sku.Name, forbiddenWord.(string)) {
            return setObj.(string), true
        }
    }
}

这部分代码目的在于判断某个商品名称中是否包含了某些敏感词语,因此选择对禁用词进行了遍历,然后判断是否包含于商品名称。代码最开始运行状态很好,但是随着时间的推移,发现服务的执行速度越来越慢,因此使用pprof对服务运行状态进行了采样分析,最终发现一个异常点,具体如下图所示,三张图片分别是pprof在程序启动后,启动一段时间,启动更长一段时间后分别采集到的协程数量分析:

刚开始启动后:

gc-1.png

经过一段时间后:

gc-2.png

再经过一段时间后:

gc-3.png

通过一段时间的分析,我们发现协程数在逐渐增多,发生了十分明显的协程泄漏,协程资源无法回收进而导致了内存泄漏,进一步,我们查找导致协程泄漏的代码发现定位到了如下代码:


for forbiddenWord := range forbiddenWords.Iter() {
        if strings.Contains(sku.Name, forbiddenWord.(string)) {
            return setObj.(string), true
        }
    }

这部分代码没有发现任何启动协程的步骤,那么问题可能出在了golang-set包中,我们进一步分析,发现golang-set的Iter()方法的具体实现代码如下:


func (set *threadUnsafeSet) Iter() <-chan interface{} {
    ch := make(chan interface{})
    go func() {
        for elem := range *set {
            ch <- elem
        }
        close(ch)
    }()

    return ch
}

原来golang-set的遍历方法是基于协程的异步遍历方案,这样做的好处是,当golang-set的集合对象很大时,无需等待用户侧就可以直接通过channel开始处理遍历结果,但是由于传输cahnnel是无缓存的结构,当用户不再读取数据时,遍历协程将阻塞。这就解释了为什么我们的服务会发生协程泄漏。由于我们只需要判断关键字的包含关系,因此,当遍历中途发现已经包含时,用户逻辑将会直接return,而此时遍历协程可能还没遍历完,遍历协程就会发生阻塞,此时,协程无法退出,同时channel也没执行stop,因此才发生了协程泄漏。

那么如何解决上述问题呢?考虑到golang-set包已经被长期验证,因此不可能发生如此低级的错误,因此在仔细阅读golang-set包源码后,发现了另一中可中途退出的遍历方法。具体如下:

type Iterator struct {
    C    <-chan interface{}
    stop chan struct{}
}

func newIterator() (*Iterator, chan<- interface{}, <-chan struct{}) {
    itemChan := make(chan interface{})
    stopChan := make(chan struct{})
    return &Iterator{
        C:    itemChan,
        stop: stopChan,
    }, itemChan, stopChan
}


func (set *threadUnsafeSet) Iterator() *Iterator {
    iterator, ch, stopCh := newIterator()

    go func() {
    L:
        for elem := range *set {
            select {
            case <-stopCh:
                break L
            case ch <- elem:
            }
        }
        close(ch)
    }()

    return iterator
}

该方法返回一个枚举对象而不是一个channel,而枚举对象中包含了一个stop()方法,用于通知遍历协程中途退出。从而避免了协程泄漏情况的发生。此时,我们的问题也就迎刃而解啦,解决问题后的代码如下:

    it := forbiddenWords.Iterator()
    for setObj := range it.C {
        if strings.Contains(sku.Name, setObj.(string)) {
            it.Stop()
            return setObj.(string), true
        }
    }
    return "", false

好了,以上就是本篇文章的全部内容。这次问题分析带给我的收获就是在使用第三方包的时候一定要研究透各种方法的实现方法和使用方法,否则很有可能因为使用不当而导致一些异常。