concurrent.Map:扩展sync.Map

前言

笔者最近在写一个开源项目gmap,里面就复用了sync.Map的技术,并不是直接使用sync.Map,而是拷贝sync.Map的代码并做一些适配性的调整和扩展。我想很多人应该会问为什么拷贝而不是直接使用?为什么不用map?这里我列举如下问题:

  1. map加读写锁(sync.RWMutex),问题在于利用读锁遍历map的时候写锁会被阻塞,说白了就是读的时候不能写。一般的应用是无所谓的,但是对于gmap或者sync.Map遍历需要回调用户函数,时间不可控,同时回调函数可能还会访问map形成圈,造成死锁。
  2. map加读写锁搞不定,笔者想到了sync.Map,但是sync.Map不支持CAS(Compare And Swap),只有LoadOrStore。gmap为用户提供了CAS的功能,可以多协程竞争更新map中的值,而sync.Map只能做到多协程竞争store。

其实sync.Map的设计完全可以实现CAS功能,不知道设计者当初为什么没有开发相应的接口,我想多半是没有需要吧,亦或是有什么细节我没想到?

笔者认为sync.Map支持CAS功能还是比较普遍的需求,于是把sync.Map的代码拷贝一份,再扩展一个Update接口,上传到了https://github.com/jindezgm/concurrent/map.go。在介绍扩展接口之前,笔者先对sync.Map做一次详细介绍,看看它是如何做到遍历map的同时还能写操作,甚至在遍历的回调函数中依然可以访问map而不死锁。

sync.Map详解

为什么sync.Map能够实现并发读写(此处主要指的是在遍历的map的过程中依然可以写入或者更新),应该很容易想到两个独立的map这样的办法:在遍历的时候先拷贝一份(遍历时复制)。这种方法虽然可行,但是存在如下几个问题:

  1. 如果map比较大,拷贝成本比较高;
  2. 如果n个协程同时遍历,需要拷贝n次;
  3. 遍历过程中如果值更新,遍历协程无法获得最新的值;

那sync.Map是如何解决以上问题的呢?遍历时复制或者读时复制笔者是没听过的,反倒是写时复制(copy-on-write)到是比较普遍,包括linux内核都在用这种技术。而sync.Map用的就是写时复制技术,让我们先来看看sync.Map的定义(本文源码源自go1.13/src/sync/map.go):

type Map struct {
    // 并不是读写锁,因为sync.Map采用写时复制(copy-on-write),互斥锁足够了
    // 一个只读map,一个读写map,只有访问读写map的时候需要加锁,所以不区分读写锁
    // 后文会有更详细说明,此处只是说明为什么不是读写锁
    mu Mutex
    // 源码注释是只读,这个变量存储的是只用来读map,采用原子的方式获取和设置map对象。
    // 需要重点说明的是,只读是针对map的,不是针对value的,也就是read存储的map不会
    // 有写和删除操作,只能读取,这样对这个map的并发访问就不用在考虑互斥了,因为全是读操作
    read atomic.Value 
    // dirty顾名思义就是脏了,是相对于read而言,如果read中的map是一个纯净的数据,
    // 那么dirty中就是在read的基础上或多或少的加了一点其他数据,这就是脏的来由。
    // 因为read是只读的,当新写入数据时将read拷贝至dirty中并将新数据写入到dirty,
    // 这就是写时复制,而新数据就是脏的那部分。那么问题来了,每次写新数据都要拷贝一次read么?
    // ‘脏’的数据什么时候能够变成‘干净’的?后文会给出详细解释。至于为什么是*entry而不是
    // interface{},因为entry是一个巧妙的设计,且听下文分解。
    dirty map[interface{}]*entry
    // 前面刚刚提到了脏的数据什么时候能够变干净,misses就是用来触发‘漂白’的。因为dirty比
    // read多一些脏数据,此时调用sync.Map.Load()访问这些脏数据的时候在read中找不到,我们
    // 称之为miss,就像CPU的cache miss。当miss次数达到阈值时,sync.Map就会把整个dirty
    // 赋值给read,达到漂白的效果就。这也解释了read为什么是atomic.Value类型,因为赋值的同事
    // 可能有其他协程在读取,并且也可以推导出read的map类型也是map[interface{}]*entry,
    // 否则光类型转换就非常麻烦的。
    misses int
}

如果从定义上看还是体会不到sync.Map设计巧妙之处,那么先来看看sync.Map的巧妙设计之一entry。在源码注释中对于entry解释是一个value的槽位,也可以理解为一个entry就是一个value。来看看entry的定义:

type entry struct {
    // 是的,你没看错,就是这么简单,用一个指针指向对象的地址,注意是*interface{}。
    // 从dirty的类型map[interface{}]*entry可知sync.Map存储的是key:*entry对,
    // entry.p指向了value。
    p unsafe.Pointer // *interface{}
}

为什么要这么设计?道理很假单,就是避免对map的修改(写/删),这让我想起了一句名言:“没有什么架构设计不能通过一层抽象解决的,如果有,那就再抽象一层”。多了一层entry让sync.Map对于value的操作更加灵活,既可以直接从map中删除,也可以从entry中删除。这让互斥变成key级别而不是map级别(只针对read而言,dirty还是map级别的互斥),配合原子操作整体性能是非常可观的。举个栗子,产出指定key的value,直接将entry.p=nil即可;此后再写入该key的新值,则将nil再改为新值的指针。具体的细节下文在解析sync.Map的接口的时候会涉及到。

接下来介绍sync.Map的另一个巧妙设计:读写分离,先来看一个类型定义:

// readOnly就是sync.Map.Read存储的类型,一个加了amended标记的map,而这个map的定义和
// sync.Map.dirty是一样的。所以可以sync.Map其实是两个map[interface{}]*entry的map,
// 一个是只读的sync.Map.read.m(后文简称read),一个是读写的sync.Map.dirty。
type readOnly struct {
    m       map[interface{}]*entry
    // true表示sync.Map.dirty中有read没有的数据,为什么需要这个标记?这样就可以避免无效加锁,
    // 因为false表示read与sync.Map.dirty相同,如果指定的key在read中不存在,
    // 也就不用从sync.Map.dirty中再找一遍了
    amended bool 
}

因为read存在,就可以很好理解sync.Map在迭代的时候不会阻塞其他操作,因为遍历的是read,而其他操作要么访问dirty,要么与迭代通过原子的方式互斥访问read中的值,所以不会有阻塞。是时候分析sync.Map的接口实现了,看看和我们的理解是否相同?

func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
    // 现在read中查找,这个比较好理解,不需要多解释了
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    // 这里就可以看到amended的价值了,如果read不存在指定的key,只有dirty比read数据多,
    // 才需要查找dirty,否则没必要。
    if !ok && read.amended {
        m.mu.Lock()
        // 再次访问一次read,写过并发的同学都应该明白为什么,笔者将这称之为锁前判断锁后校验。
        // 毕竟是并发访问,当某一个协程获得锁后刚刚的判断条件可能已经被前一个获得锁的协程修改了。
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok && read.amended {
            // 在dirty中查找指定的key
            e, ok = m.dirty[key]
            // missLocked就是累加read查找miss计数,如果miss计数太多就将dirty更新到read中。
            // 就是因为有可能更新read,所以才有加锁后二次查找read。missLocked见下文。
            m.missLocked()
        }
        m.mu.Unlock()
    }
    // 把entry中的value返回
    if !ok {
        return nil, false
    }
    return e.load()
}

func (m *Map) missLocked() {
    // 看来miss的次数的阈值是dirty的中记录的数量,miss计数不仅可以在必要的时候将dirty
    // 更新到read,同时也可以避免频繁插入新值时的拷贝(write-on-copy),只要dirty
    // 还没有被更新read就不需要拷贝,因为dirty才是全集,直接插入新值即可。而miss计数的阈值
    // 设置为常数也不不合理的,因为map中的数据量大小不同阈值也是不同的,所以采用dirty中的
    // 记录的数量是一个与map数据量线性变化的动态阈值,此设计也是非常巧妙的。
    m.misses++
    if m.misses < len(m.dirty) {
        return
    }
    // 因为read是dirty的子集,所以直接将dirty赋值给read即可,为什么dirty和readOnly.m
    // 的类型相同,这样就不用转换了,方便!
    m.read.Store(readOnly{m: m.dirty})
    m.dirty = nil
    m.misses = 0
}

func (e *entry) load() (value interface{}, ok bool) {
    p := atomic.LoadPointer(&e.p)
    // 其他的代码无需解释,这里面关键需要说明的是expunged,这是一个标记变量,表示这个entry被删除,
    // 意思就是这个entry是不可用的。这么设计的目的是为了避免执行map的delete操作,因为read
    // 是不能够执行删除操作的。
    if p == nil || p == expunged {
        return nil, false
    }
    return *(*interface{})(p), true
}

以上就是sync.Map.Load()的代码了,基本没有跑出我们前面的假设,expunged除外。因为read不可修改的特点,在一些情况下必须删除read中的某条记录变得非常困难,expunged就是用来标记key已经被删除。expunged不同于nil,虽然二者都代表value不存在(如entry.load()代码所示),但是expunged代表key也不再,而nil只是代表value不存在。至于什么情况下需要设置expunged,答案在写时复制里,来看看sync.Map.Store()的代码:

func (m *Map) Store(key, value interface{}) {
    // 如果read中有指定的key,则直接写入到read中,entry.tryStore见下文详解
    read, _ := m.read.Load().(readOnly)
    // 此时建议读者现去阅读下面关于entry.tryStore()的解析,这样更有利于理解。
    if e, ok := read.m[key]; ok && e.tryStore(&value) {
        return
    }
    // 因为read里的没有指定的key,expunged也算作key不存在,所以只能写入dirty了
    m.mu.Lock()
    read, _ = m.read.Load().(readOnly)
    if e, ok := read.m[key]; ok {
        // entry.unexpungeLocked()是将expunged的value设置为nil,为什么又把expunged
        // 变为nil?不是说expunged的key相当于被删除了么?这一点笔者需要说明一下,
        // 当read中的数据已经被复制到dirty中,那么read中所有entry.p=nil的记录都会被改为
        // entry.p=expunged以表示从read中删除。但需要注意的是,此时dirty中也没有这个key,
        // 此时再写入的记录的key被标记为expunged,肯定是要写入dirty的,正如下面代码所示。
        // 与此同时,因为read中已经有这个key了,重新激活它岂不是更好?这样再次访问该key的记录
        // 就直接访问read即可,这就是为什么又将expunged的记录改为nil的原因。unexpungeLocked()
        // 很简单:atomic.CompareAndSwapPointer(&e.p, expunged, nil),通过原子的方式
        // 把expunged变为nil。如果成功了,说明写时复制已经完成,可以直接写入dirty,因为只有
        // 写时复制的过程才能设置expunged;如果失败了,直接赋值entry即可。
        if e.unexpungeLocked() {
            m.dirty[key] = e
        }
        // entry.storeLocked就是调用atomic.StorePointer(),让entry.p指向value。
        // 为什么此处不用atomic.CompareAndSwapPointer()?答案是当前没有一个协程会可能
        // 同时将entry.p设置为expunged就没必要用CAS,具体为什么读者应该能够想明白。因为
        // 将entry.p设置为expunged是在写时复制过程中执行的,而这个过程必须加锁,而此处已经
        // 完成所操作,所以就直接赋值。
        e.storeLocked(&value)
        // 以上代码笔者任务可以做一点点优化,如下所示:
        //  if atomic.CompareAndSwapPointer(&e.p, expunged, unsafe.Pointer(&value)) {
        //      m.dirty[key] = e
        //  } else {
        //      e.storeLocked(&value)
        //  }
        // 以上代码省去了将expunged变为nil后再赋值的过程,而是直接将expunged改为具体的值,
        // 然后写入dirty,如果不是expunged就直接修改值。这种小优化在值为expunged情况下
        // 少一次原子写操作。
    } else if e, ok := m.dirty[key]; ok {
        // 如果dirty中有则更新dirty中的值
        e.storeLocked(&value)
    } else {
        // dirty中没有只能在dirty中插入新的记录,此处需要注意,dirty中没有有两种可能:
        // 1.dirty=nil,此时要做的就是前面说了很多遍的写时复制
        // 2.dirty!=nil,此时直接写入dirty即可
        // golang还是不错的,空指针的map依然可以查找,如果是C++早就不知道崩溃多少回了~
        // 此处用read.amended做判断笔者认为语义上并不通畅,感觉if nil == m.dirty会更好。
        // amended变量的注释是dirty是否包含一些read中没有的记录,因为只有写操作并且read中
        // 的情况下才会复制,所以只要复制amended必然为true,dirty必然非nil。此处用
        // amended逻辑上不会错误,但是笔者认为用dirty指针判断语义更顺畅。
        if !read.amended {
            // dirtyLocked就是实现写时复制的函数,见下文详解
            m.dirtyLocked()
            // 复制与amended=true是强关联,读者在解析LoadOrStore()接口的时候还会看到
            m.read.Store(readOnly{m: read.m, amended: true})
        }
        // dirty中插入新的记录,也是amended=true的原因
        m.dirty[key] = newEntry(value)
    }
    m.mu.Unlock()
}

func (e *entry) tryStore(i *interface{}) bool {
    for {
        p := atomic.LoadPointer(&e.p)
        // 这句足以证明笔者前面对于expunged的解释,他等同于key不存在
        if p == expunged {
            return false
        }
        // 通过CAS的方式多协程竞争写,为什么不是atomic.StorePointer()?因为其他协程写时复制
        // 时会把p改为expunged,一旦p为expunged就不可以在写入了。
        if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
            return true
        }
    }
}

// dirtyLocked实现写时复制
func (m *Map) dirtyLocked() {
    // 这有何必呢?在之前的用dirty指针判断多好?难道说笔者有什么细节没有注意到么?
    // 左思右想还是没有想到,哪位读者知道烦请告知笔者。
    if m.dirty != nil {
        return
    }
    // 下面的代码比较简单就是在复制read的记录,其中entry.tryExpungeLocked()是一个关键函数
    // 他是把的entry.p==nil的记录设置为entry.p=expunged,目的很简单,因为nil不会被拷贝到dirty
    // 那么read此后也不能在对其更新,否则read就不是dirty的子集了。不用再解释了,应该够明白了。
    read, _ := m.read.Load().(readOnly)
    m.dirty = make(map[interface{}]*entry, len(read.m))
    for k, e := range read.m {
        if !e.tryExpungeLocked() {
            // 没有被删除的记录被拷贝到dirty中
            m.dirty[k] = e
        }
    }
}

// tryExpungeLocked把entry.p=nil的entry标记为删除
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
}

理解sync.Map的Load和Store两个接口的实现,其他接口的实现相信读者基本也能想象得到,甚至可以自己实现,鉴于文章的完整性,笔者对Delete和Range两个接口做简单说明,LoadOrStore留给读者。

func (m *Map) Delete(key interface{}) {
    // 首先在read里查找key是否存在
    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 {
            // read里没有,就直接删除dirty即可,因为dirty是读写的,所以直接删除
            delete(m.dirty, key)
        }
        m.mu.Unlock()
    }
    if ok {
        // 如果在read找到了,调用entry.delete(),见下文
        e.delete()
    }
}
func (e *entry) delete() (hadValue bool) {
    for {
        p := atomic.LoadPointer(&e.p)
        // 如果是nil或者expunged说明已经被删除
        if p == nil || p == expunged {
            return false
        }
        // 将value指针设置为nil
        if atomic.CompareAndSwapPointer(&e.p, p, nil) {
            return true
        }
    }
}
func (m *Map) Range(f func(key, value interface{}) bool) {
    // 如果dirty中的数据不比read多,那么直接遍历read即可
    read, _ := m.read.Load().(readOnly)
    if read.amended {
        m.mu.Lock()
        read, _ = m.read.Load().(readOnly)
        if read.amended {
            // dirty数据更多,则将整个dirty更新到read然后清空dirty
            read = readOnly{m: m.dirty}
            m.read.Store(read)
            m.dirty = nil
            m.misses = 0
        }
        m.mu.Unlock()
    }
    // 下面的代码比较简单,就不用解释了
    for k, e := range read.m {
        v, ok := e.load()
        if !ok {
            continue
        }
        if !f(k, v) {
            break
        }
    }
}

至此,笔者把sync.Map基本总结如下:

  1. 有两个map,一个只读read,一个读写dirty,访问read无锁操作,访问dirty需要锁操作;
  2. 查询(Load)时先查找read,如果read中没有在查找dirty,如果查找read的miss次数太多,将dirty更新到read并清空dirty;
  3. 删除(Delete)时如果read中有指定key则将value设置为nil,否则直接删除dirty中指定key
  4. 插入(Store)时如果read中指定key存在并且value不是expunged,那么直接将新的值更新到read中;如果value是expunged则修改为nil再次复用;如果read中没有指定key,则执行写时复制并将新的值插入dirty,同时read的amended标记true;
  5. 能够触发dirty更新到read的条件有两个,一个是miss次数,一个就是调用Range,如果使用sync.Map频繁的Range和插入新纪录,会让sync.Map忙于写时复制,性能会有所下降;

sync.Map扩展

concurrent.Map在sync.Map基础上扩展了Update接口,Update接口设计目标是支持并发更新同一个value对象。Update不同于Store,Store最终只有一个协程的value会被写入,其他的都会被覆盖,而Update可以将所有协程的更新都保证跟新到value对象中,除非这些协程有更新了value对象相同的字段(field)。

因为有了上一章节对sync.Map的详解,本章节扩展内容理解会非常容易,关键在于sync.Map本身具备这个能力,无非没有开发专门接口而已

// Update会持续调用tryUpdate()直到更新成功,因为过程中可能会冲突,就像CAS失败一样。每次更新前
// 都会将当前值通过tryUpdate传给调用者,调用者需要参考传入的value,因为其中可能包含已完成的更新,
// 调用者需要将更新的value通过tryUpdate返回,如果不需要更新则返回false
func (m *Map) Update(key interface{}, tryUpdate func(interface{}) (interface{}, bool)) bool {
    for {
        // load()函数实现与Load一样,只是返回值变成了*entry
        e, ok := m.load(key)
        if ok {
            // 即便entry存在,也可能value是空的亦或是expunged,这种entry也视为value不存在
            _, ok = e.load()
        }
        // 如果value不存在则用LoadOrStore()实现.
        if !ok {
            if value, ok := tryUpdate(nil); !ok {
                return false
            } else if _, loaded := m.LoadOrStore(key, value); !loaded {
                return true
            }
        // 如果value存在,让entry尝试更新
        } else if updated, ok := e.tryUpdate(tryUpdate); !ok {
            return false
        } else if updated {
            return true
        }
    }
}

func (e *entry) tryUpdate(tryUpdate func(interface{}) (interface{}, bool)) (bool, bool) {
    // 再次校验,因为可能被更新过
    p := atomic.LoadPointer(&e.p)
    if p == expunged || p == nil {
        return false, true
    }
    // 调用tryUpdate()获取更新的value
    value, ok := tryUpdate(*(*interface{})(p))
    if !ok {
        return false, false
    }
    for {
        // 当前值与调用tryUpdate()之前的值相同才能更新,否则会覆盖其他已完成的更新
        if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(&value)) {
            return true, true
        }
        p = atomic.LoadPointer(&e.p)
        if p == expunged || p == nil {
            return false, true
        }
        value, ok = tryUpdate(*(*interface{})(p))
        if !ok {
            return false, false
        }
    }
}

现在笔者来解释一下什么场景会用到Update接口,比如gmap中的CAS功能,该功能和etcd的功能相似,就是比较value的revision,如果与指定的revision相同就更新。在sync.Map存储的就是版本化的value,定义如下:

type revisionedValue struct {
    Revision uint64
    Value    interface{}
}

所以此时就需要concurrent.Map能够原子的实现获取revisionedValue、比较revision、更新revisionedValue。tryUpdate回调函数让Update非常灵活,concurrent.Map将当前值传给调用者让其决定是否更新,可以做比较(类似CAS),也可以是其他的判断。

当然,这样的需求也是可以学习sync.Map抽象entry的方式解决,即在sync.Map存储的是*entry,然后对entry的各种并发操作交给使用者实现。这肯定是没问题的,笔者扩展sync.Map的目标是希望把类似的功能统一在concurrent.Map实现,这样就不用每次使用再实现一遍了。

除了Update,笔者还扩展了Clear、Copy两个接口。Clear是用来原子清空Map的,因为sync.Map是结构体,通过赋新值不是原子的,除非通过用*sync.Map类型并通过原子设置指针的方式。

LEAQ 0(IP), CX          [3:7]R_PCREL:type.sync.Map
MOVQ CX, 0(SP)
MOVQ AX, 0x8(SP)
CALL 0x4be              [1:5]R_CALL:runtime.typedmemclr
以上是下面对sync.Map类型变量x赋值代码的汇编,证明赋值的方式是不原子的。
var x sync.Map
x = sync.Map{}

因为sync.Map有很多非指针的使用场景,所以Clear也是有应用的地方,代码如下所示:

func (m *Map) Clear() {
    // 实现很简单,就是清空read和dirty
    m.mu.Lock()
    m.read.Store(readOnly{})
    m.dirty, m.misses = nil, 0
    m.mu.Unlock()
}

而Copy接口支持原子的将一个map拷贝到concurrent.Map中,因为concurrent.Map存储的map类型是map[interface{}]*entry,而输入map类型无法预知,所以类型转换难以避免。为了最小化开销,笔者定义了Ranger类型,如下代码所示:

// Ranger主要功能是对输入map的遍历,这样只需要读取一次输入map,写入一次输出map,开销最小
type Ranger interface {
    Len() int
    Range(func(key, value interface{}) bool)
}

func (m *Map) Copy(r Ranger) {
    // 构造一个与输入map一样大小的map
    read := readOnly{m: make(map[interface{}]*entry, r.Len())}
    // 遍历输入map然后逐一插入到新map中
    r.Range(func(key, value interface{}) bool {
        read.m[key] = &entry{p: unsafe.Pointer(&value)}
        return true
    })
    // 最后将新的map写入read并清空dirty完成原子拷贝
    m.mu.Lock()
    m.read.Store(read)
    m.dirty, m.misses = nil, 0
    m.mu.Unlock()
}

至于Clear和Copy的需求来自于哪里,当然是gmap,因为笔者开篇就提到了,因为gmap的需求才对sync.Map进行扩展。这两个接口主要用于gmap从snapshot复原。

猜你喜欢

转载自blog.csdn.net/weixin_42663840/article/details/107958274