原创分享 踩了 Golang sync.Map 的一个坑

PureWhiteWu for 字节跳动 · 2020年08月24日 · 最后由 sotex 回复于 2020年09月04日 · 1645 次阅读
本帖已被设为精华帖!

缘起

最近 Go 1.15 发布了,我也第一时间更新了这个版本,毕竟对 Go 的稳定性还是有一些信心的,于是直接在公司上了生产。

结果,上线几分钟,就出现了 OOM,于是 pprof 了一下 heap,然后赶紧回滚,发现某块本应该在一次请求结束时被释放的内存,被保留了下来而且一直在增长,如图(图中的 linkBufferNode):

火焰图

这次上线的变更只有 Go 版本的升级,没有任何其它变动,于是在本地开始测试,发现在本地也能百分百复现。

排查过程

看了 Go 1.15 的 Release Note,发现有俩高度疑似的东西:

  1. 去除了一些 GC Data,使得 binary size 减少了 5%;
  2. 新的内存分配算法。

于是改 runtime,关闭新的内存分配算法,切换回旧的,等等一顿操作猛如虎下来,发现问题还是没解决,现象仍然存在。

我能怎么办?我也很绝望啊

于是实在不行,祭出了GODEBUG="allocfreetrace=1大法,肉眼从 100MB+ 的日志文件里面看啊看啊看啊看啊看啊看啊看啊看啊看啊看啊……(此处省略心酸过程)

最终直觉告诉我,这个问题可能和 Go 1.15 中 sync.Map 的改动有关(别问我为啥,真的是直觉,我也说不出来)。

示例代码

为了方便讲解,我写了一个最小可复现的代码,如下:

package main

import (
    "sync"
)

var sm sync.Map

func insertKeys() {
    keys := make([]interface{}, 0, 10)
    // Store some keys
    for i := 0; i < 10; i++ {
        v := make([]int, 1000)
        keys = append(keys, &v)
        sm.Store(keys[i], struct{}{})
    }
    // delete some keys, but not all keys
    for i, k := range keys {
        if i%2 == 0 {
            continue
        }
        sm.Delete(k)
    }
}

func shutdown() {
    sm.Range(func(key, value interface{}) bool {
        // do something to key
        return true
    })
}

func main() {
    insertKeys()
    // do something ...
    shutdown()
}

Go 1.15 中 sync.Map 改动

在 Go 1.15 中,sync.Map 增加了一个方法LoadAndDelete,具体的 issue 在这:sync: add new Map method LoadAndDelete,CL 在这:CL

为什么我确认是这个改动导致的呢?很简单:我在本地把这个改动 revert 掉了,问题就没了,好了关机下班……

关机下班

当然没这么简单,知其然要知其所以然,于是开始看到底改了哪块……(此处省略 100000 字)

最终发现,关键代码是这段:

// LoadAndDelete deletes the value for a key, returning the previous value if any.
// The loaded result reports whether the key was present.
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) {
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    if !ok && read.amended {
        m.mu.Lock()
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok && read.amended {
            e, ok = m.dirty[key]
            // Regardless of whether the entry was present, record a miss: this key
            // will take the slow path until the dirty map is promoted to the read
            // map.
            m.missLocked()
        }
        m.mu.Unlock()
    }
    if ok {
        return e.delete()
    }
    return nil, false
}

// Delete deletes the value for a key.
func (m *Map) Delete(key interface{}) {
    m.LoadAndDelete(key)
}

func (e *entry) delete() (value interface{}, ok bool) {
    for {
        p := atomic.LoadPointer(&e.p)
        if p == nil || p == expunged {
            return nil, false
        }
        if atomic.CompareAndSwapPointer(&e.p, p, nil) {
            return *(*interface{})(p), true
        }
    }
}

在这段代码中,会发现在 Delete 的时候,并没有真正删除掉 key,而是从 key 中取出了 entry,然后把 entry 设为 nil……

所以,在我们场景中,我们把一个连接作为 key 放了进去,于是和这个连接相关的比如 buffer 的内存就永远无法释放了……

那么为什么在 Go 1.14 中没有问题呢?以下是 Go 1.14 的代码:

// Delete deletes the value for a key.
func (m *Map) Delete(key interface{}) {
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    if !ok && read.amended {
        m.mu.Lock()
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok && read.amended {
            delete(m.dirty, key)
        }
        m.mu.Unlock()
    }
    if ok {
        e.delete()
    }
}

在 Go 1.14 中,如果 key 在 dirty 中,是会被删除的;而凑巧,我们其实 “误用” 了 sync.Map,在我们的使用过程中没有读操作,导致所有的 key 其实都在 dirty 里面,所以当调用 Delete 的时候是会被真正删除的。

要注意,无论哪个版本的 Go,一旦 key 升级到了 read 中,在没有 miss 到一定的值让 dirty 提升为 read 时,key 都是永远不会被删除的。也就是说,极端情况之下,key 是会泄露的。

总结

在 Go <= 1.15 版本中,sync.Map 中的 key 在极端情况下是不会被删除的,如果在 Key 中放了一个大的对象,或者关联有内存,就会导致内存泄漏。

针对这个问题,我已经向 Go 官方提出了Issue,目前来看这个 behaviour 定义为了 bug(因为违背了 Go 1 兼容性承诺,和 1.14 中的 behaviour 不同了),已经由 @ChangKun Ou 大佬提了 pr 修复了,并且 backport 到了 1.15.1 中。

而针对 read 中的 key 在没有 dirty 被提升时不会删除的问题,目前看来是一个设计上的 trade-off,如果有真实世界中的程序(real-world program)出问题的话,再提 issue,看看是否要解决。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
kevin 将本帖设为了精华贴 08月25日 11:14

哈哈,不错。最后的 pr 是个亮点...

https://github.com/golang/go/pull/41000

mahuaibo GoCN 每日新闻 (2020-08-25) 中提及了此贴 08月25日 14:18
rfyiamcool 回复

就是添加了一行注释来说明这个问题存在😅

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册