缘起

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

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

 

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

 

此次上线的变动只有 Go 版本的升级,没有任何其它变更,因而在本地开始测试,发如今本地也能百分百复现。golang

排查过程

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

  1. 去除了一些 GC Data,使得 binary size 减小了 5%;app

  2. 新的内存分配算法。less

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

 

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

 

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

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

示例代码

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

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 在这:https://github.com/golang/go/issues/33762CL, 在这:https://go-review.googlesource.com/c/go/+/205899/。

为何我确认是这个改动致使的呢?很简单:我在本地把这个改动 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 中,都是永远不会被删除的。

总结

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

针对这个问题,我已经向 Go 官方提出了 Issue,目前尚不清楚是 by-design 仍是 bug:https://github.com/golang/go/issues/40999