背景
在开发的过程中,应用 golang syncmap 存储连贯信息,其中将本人封装的连贯对象指针作为 key,
连贯对象大略是上面的构造
type Con struct {
con net.Conn
addr string
readBuffer []byte
writeBuffer []byte
......
}
在跑 benchmark 测试的时候,发现了一个问题,就是每次跑完 benchmark,无论工夫过来多久,内存始终回不到程序刚启动的水准。然而无论跑多少次 benchmark,内存并不会有限升高,而是维持在方才的水准高低。过后感觉这个不是简略的内存透露。go pprof 剖析内存发现是 readbuffer 和 writeBuffer 那里好多调配的内存没有开释。看到这个后果也是有点懵逼,一个会话完结的时候不仅连贯会断开,con 对象也会从 map 中被删除,为什么还没有被开释呢。因为只有这个 map 的逻辑是前面加的,过后判断问题大概率出在这里,所以仔细阅读了 sync map 的源码。
源码
源码在 sync 包的 map.go
type Map struct {
mu Mutex // 操作 dirty 时用到的锁
read atomic.Value // 只读 map,只读的删除是通过更新 entry 中 unsafe point 的指向来实现,所以其实这样的删除,key 占用的内存空间并没有被开释(这个场景在下文会说到)dirty map[any]*entry
misses int
}
type readOnly struct {m map[interface{}]*entry
amended bool // 如果 dirty map 比 read map 中存储的数据要全,则该字段的值为 true
}
type entry struct {p unsafe.Pointer // *interface{}
}
加载 key
func (m *Map) Load(key any) (value any, ok bool) {read, _ := m.read.Load().(readOnly)
e, ok := read.m[key] // 先尝试从 read map 加载数据
if !ok && read.amended {
// 如果没有找到数据,并且 read map 和 dirty 有差别
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
if !ok && read.amended {
// 这里是进行一个 double check
e, ok = m.dirty[key]
// 减少一下 miss 的次数,miss 次数达到 dirty 长度就会用 dirty 笼罩 readmap
m.missLocked()}
m.mu.Unlock()}
if !ok {return nil, false}
return e.load()}
func (m *Map) missLocked() {
m.misses++
if m.misses < len(m.dirty) {
return
// 当 miss 的次数大于等于 dirty 的长度的时候,就用 dirty 笼罩 readmap
m.read.Store(readOnly{m: m.dirty})
m.dirty = nil
m.misses = 0
}
func (e *entry) load() (value any, ok bool) {p := atomic.LoadPointer(&e.p)
// 不为空并且不是擦除状态,这个擦除状态是指的是,当从 read map 向 dirty 同步数据的时候,如果这个 key 对应 value 的 entry 指向的是 nil(删除状态),就不同步到 dirty,而是标记为擦除(expunged)状态
思考 为什么要设置为擦除状态
当发现 read map 中的一个 key 是擦除状态的时候,能够阐明 dirty 非空,并且 dirty 外面没有这个 key。这样在 store 的时候就能够通过判断擦除状态来保障,dirty 外面没有的 key,会被 sotre 到 dirty。if p == nil || p == expunged {return nil, false}
return *(*any)(p), true
}
存储 key
func (m *Map) Store(key, value any) {read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok && e.tryStore(&value) {return}
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
if e, ok := read.m[key]; ok {if e.unexpungeLocked() {m.dirty[key] = e
}
e.storeLocked(&value)
} else if e, ok := m.dirty[key]; ok {e.storeLocked(&value)
} else {
if !read.amended {m.dirtyLocked()
m.read.Store(readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value)
}
m.mu.Unlock()}
func (e *entry) tryStore(i *any) bool {
for {p := atomic.LoadPointer(&e.p)
if p == expunged {
// 响应在 load 办法中的思考
return false
}
if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {return true}
}
}
func (m *Map) dirtyLocked() {
if m.dirty != nil {return}
read, _ := m.read.Load().(readOnly)
m.dirty = make(map[any]*entry, len(read.m))
for k, e := range read.m {
// 这里就是把 readmap 外面 value 的 entry 外面 unsafe point 不是 nil 的同步到 dirty,是 nil 的原地标记为擦除状态
if !e.tryExpungeLocked() {m.dirty[k] = e
}
}
}
func (e *entry) tryExpungeLocked() (isExpunged bool) {p := atomic.LoadPointer(&e.p)
for p == nil {if atomic.CompareAndSwapPointer(&e.p, nil, expunged) {return true}
p = atomic.LoadPointer(&e.p)
}
return p == expunged
}
删除 key
删除这里后面局部跟 load 的原理差不多
func (m *Map) LoadAndDelete(key any) (value any, 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]
delete(m.dirty, key)// 先删除后计数 miss
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 any) {m.LoadAndDelete(key)
}
func (e *entry) delete() (value any, ok bool) {
for {p := atomic.LoadPointer(&e.p)
if p == nil || p == expunged {return nil, false}
if atomic.CompareAndSwapPointer(&e.p, p, nil) {return *(*any)(p), true
}
}
}
试验
如图代码在 14 行处断点
此时 sync map 中各个字段的内容如下
此时因为 13 行的删除其实有一次 load 操作,此时 misses 是 1,那么实践上 14 行运行实现,后 dirty 的长度会变成 1,missing 会变成 2. 此时会用 dirty 笼罩 read map,并且置空 dirty,如图
剖析是正确
而后如果跑完 15 行,则会从 read map 删除 ”1″, 然而不是真正删除,只是 unsafe point 指针为空
继续执行 15 行后果如下
能够看出的确是空值
剖析
其实依据试验曾经比拟显著了,当在 readmap 删除的时候,并不是传统的 delete(m,key)的形式,key 并不会删除,所以,当咱们的 key 是一个指针,刚好指向的对象占用的对象空间比拟大的时候。这里我只是拿只有一个 key 的状况做举例,在做 benchmark 的时候,这种 key 会很多,就比方结尾说的,好多 conn 对象的指针作为 key,导致这个对象不能 gc,所以就会呈现,明明删除了,然而内存还没上来的状况。
持续试验
持续写入加 load 触发 dirty 笼罩 readmap 的操作
17 行执行完,发现 readmap 中的 key”1″ 变成如图,其实就是在把 readmap 同步到 dirty 过程中,把 key”1″ 的 value 设置成擦除状态
继续执行 18 行
能够看到 readmap 曾经被笼罩,如果后面是很多个 key 的话,此时就会发现有大量的内存空间被开释