Sync Map源码解读
go1.9之后加入了支持并发安全的Map sync.Map, sync.Map 通过一份只使用原子操作的数据和一份冗余了只读数据的加锁数据实现一定程度上的读写分离,使得大多数读操作和更新操作是原子操作,写入新数据才加锁的方式来提升性能。以下是 sync.Map源码剖析, 结构体中的注释都会在具体实现代码中提示相呼应
sync.Map底层使用了两个原生map,一个叫read,仅用于读;一个叫dirty,用于在特定情况下存储最新写入的key-value数据:
read(这个map)好比整个sync.Map的一个 “高速缓存” ,当goroutine从sync.Map中读取数据时,sync.Map会首先查看read这个缓存层是否有用户需要的数据(key是否命中),如果有(命中),则通过原子操作将数据读取并返回,这是sync.Map推荐的快路径(fast path) ,也是为何上面基准测试结果中读操作性能极高的原因。
type Map struct {
// 保护dirty的锁
mu Mutex
// 只读数据(修改采用原子操作)
read atomic.Value
// 包含只读中所有数据(冗余),写入新数据时也在dirty中操作
dirty map[interface{}]*entry
// 当原子操作访问只读read时找不到数据时会去dirty中寻找,此时misses+1,dirty及作为存储新写入的数据,又冗余了只读结构中的数据,所以当misses > dirty 的长度时, 会将dirty升级为read,同时将老的dirty置nil
misses int
}
// Map struct 中的 read 就是readOnly 的指针
type readOnly struct {
// 基础Map
m map[interface{}]*entry
// 用于表示当前dirty中是否有read中不存在的数据, 在写入数据时, 如果发现dirty中没有新数据且dirty为nil时,会将read中未被删除的数据拷贝一份冗余到dirty中, 过程与Map struct中的 misses相呼应
amended bool
}
// 数据项
// 它是一个指针,指向 value。看来,read 和 dirty 各自维护一套 key,key 指向的都是同一个 value。也就是说,只要修改了这个 // entry,对 read 和 dirty 都是可见的。这个指针的状态有三种:
type entry struct {
p unsafe.Pointer
}
// 用于标记数据项已被删除(主要保证数据冗余时的并发安全)
// 上述Map结构中说到有一个将read数据拷贝冗余至dirty的过程, 因为删除数据项是将*entry置nil, 为了避免冗余过程中因并发问题导致*entry改变而影响到拷贝后的dirty正确性,所以sync.Map使用expunged来标记entry是否被删除
var expunged = unsafe.Pointer(new(interface{}))
当 p == nil 时,说明这个键值对已被删除,并且 m.dirty == nil,或 m.dirty[k] 指向该 entry。
当 p == expunged 时,说明这条键值对已被删除,并且 m.dirty != nil,且 m.dirty 中没有这个 key。
其他情况,p 指向一个正常的值,表示实际 interface{} 的地址,并且被记录在 m.read.m[key] 中。如果这时 m.dirty 不为 nil,那么它也被记录在 m.dirty[key] 中。两者实际上指向的是同一个值。
当删除 key 时,并不实际删除。一个 entry 可以通过原子地(CAS 操作)设置 p 为 nil 被删除。如果之后创建 m.dirty,nil 又会被原子地设置为 expunged,且不会拷贝到 dirty 中。
如果 p 不为 expunged,和 entry 相关联的这个 value 可以被原子地更新;如果 p == expunged,那么仅当它初次被设置到 m.dirty 之后,才可以被更新。
谈一谈P的状态机
P有三种状态
- nil
- expunged
- 正常状态
然后涉及到两个map
- readMap
- dirtyMap
假设现在是这样的
然后我删除key2,可以看到两个Map的value都变成null,因为两个map的value是指针,指向同一个数据,而且都是惰性删除
接下来我让dirty提升
再插入一条,可以看到key2的val已经变成expunged
现在我们来想为什么要设计expunged这个状态
由于我们知道readmap删除是惰性删除的,也就是赋值成nil,那么什么时候真正删除呢,是在这里
func (m *Map) dirtyLocked() {
if m.dirty != nil {
return
}
read, _ := m.read.Load().(readOnly)
m.dirty = make(map[interface{}]*entry, len(read.m))
for k, e := range read.m {
if !e.tryExpungeLocked() {
m.dirty[k] = e
}
}
}
前面我们提到过,promoted(dirty -> read)是一个整体的指针交换操作,promoted时,sync.Map直接将原dirty指针store给read并将自身置为nil,因此sync.Map要保证amended=true时,dirty中拥有整个Map的全量数据,这样在下一次promoted(dirty -> read)时才不会丢失数据。不过dirtyLocked是通过一个迭代实现的元素从read到dirty的复制,如果Map中元素规模很大,这个过程付出的损耗将很大,并且这个过程是在锁保护下的。
在存入key3后,我们调用Load方法先load了key1,由于key1在read中有记录,因此此次load命中了,走的是快路径,对Map状态没有任何影响。
之后,我们又Load了key2,key2不在read中,因此产生了一次miss。misses增加计数后的值为1,而此时dirty中的元素数量为3,不满足promote的条件,于是没有执行promote操作。后续我们又连续进行了两次key2的Load操作,产生了两次miss事件后,misses的计数值等于了dirty中的元素数量,于是promote操作被执行,dirty map整体被置换给read,自己则变成了nil。
假设只有nil和正常两个状态,那么之前的情况就会变成这样
readMap有key2,dirtymap没有key2,那么当我Store("key2","val2")的时候,readMap中的key2就会被更新,然后因为dirty没有key2,那么一旦出现dirty提升,readMap中的key2就会被丢掉,所以要设计expunged状态,当 p 为 expunged 时,表示它已经不在 dirty 中了。
参考链接
zhuanlan.zhihu.com/p/344834329
Dump辅助函数
func (m *Map) Dump() {
fmt.Printf("=====> sync.Map:\n")
// dump read
read, ok := m.read.Load().(readOnly)
fmt.Printf("\t read(amended=%v):\n", read.amended)
if ok {
// dump readOnly's map
for k, v := range read.m {
if v.p == nil {
fmt.Printf("\t\t %#v:<nil>\n", k)
continue
}
if v.p == expunged {
fmt.Printf("\t\t %#v:%#v\n", k, v.p)
continue
}
val := *(*any)(v.p)
fmt.Printf("\t\t %#v:%#v\n", k, val)
}
}
// dump dirty
fmt.Printf("\t dirty:\n")
for k, v := range m.dirty {
if v.p == nil {
fmt.Printf("\t\t %#v:<nil>\n", k)
continue
}
if v.p == expunged {
fmt.Printf("\t\t %#v:%#v\n", k, v.p)
continue
}
val := *(*any)(v.p)
fmt.Printf("\t\t %#v:%#v\n", k, val)
}
// dump miss
fmt.Printf("\t misses:%d\n", m.misses)
// dump expunged
fmt.Printf("\t expunged:%#v\n", expunged)
fmt.Printf("<===== sync.Map\n")
}
局限性
从以上的源码可知,sync.map并不适合同时存在大量读写的场景,大量的写会导致read map读取不到数据从而加锁进行进一步读取,同时dirty map不断升级为read map. 从而导致整体性能较低,特别是针对cache场景.针对append-only以及大量读,少量写场景使用sync.map则相对比较合适.
对于map,还有一种基于hash的实现思路,具体就是对map加读写锁,但是分配n个map,根据对key做hash运算确定是分配到哪个map中. 这样锁的消耗就降到了1/n(理论值).具体实现可见:concurrent-map
相比之下, 基于hash的方式更容易理解,整体性能较稳定. sync.map在某些场景性能可能差一些,但某些场景却能取得更好的效果. 所以还是要根据具体的业务场景进行取舍.