背景
在开发的过程中,使用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的话,此时就会发现有大量的内存空间被释放