sync.Map实现分析

golang的SDK中提供线程安全的map实现sync.Map。它是针对RWMutex+map的实现方案中存在cache line的false share提出来的。主要适用于两个场景:

  1. 针对一个key一次写多次读。
  2. 多个goroutine并发读写修改的key是没有交集。

在这两种情况下,相比一个Mutex或者RWMutex加上普通的map,锁的竞争要少的多。那为什么呢?

数据结构

type Map struct {
  mu Mutex

  // read contains the portion of the map's contents that are safe for
  // concurrent access (with or without mu held).
  //
  // The read field itself is always safe to load, but must only be stored with
  // mu held.
  //
  // Entries stored in read may be updated concurrently without mu, but updating
  // a previously-expunged entry requires that the entry be copied to the dirty
  // map and unexpunged with mu held.
  read atomic.Value // readOnly

  // dirty contains the portion of the map's contents that require mu to be
  // held. To ensure that the dirty map can be promoted to the read map quickly,
  // it also includes all of the non-expunged entries in the read map.
  //
  // Expunged entries are not stored in the dirty map. An expunged entry in the
  // clean map must be unexpunged and added to the dirty map before a new value
  // can be stored to it.
  //
  // If the dirty map is nil, the next write to the map will initialize it by
  // making a shallow copy of the clean map, omitting stale entries.
  dirty map[interface{}]*entry

  // misses counts the number of loads since the read map was last updated that
  // needed to lock mu to determine whether the key was present.
  //
  // Once enough misses have occurred to cover the cost of copying the dirty
  // map, the dirty map will be promoted to the read map (in the unamended
  // state) and the next store to the map will make a new dirty copy.
  misses int
}

// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
  m       map[interface{}]*entry
  amended bool // true if the dirty map contains some key not in m.
}

// An entry is a slot in the map corresponding to a particular key.
type entry struct {
  // p points to the interface{} value stored for the entry.
  //
  // If p == nil, the entry has been deleted and m.dirty == nil.
  //
  // If p == expunged, the entry has been deleted, m.dirty != nil, and the entry
  // is missing from m.dirty.
  //
  // Otherwise, the entry is valid and recorded in m.read.m[key] and, if m.dirty
  // != nil, in m.dirty[key].
  //
  // An entry can be deleted by atomic replacement with nil: when m.dirty is
  // next created, it will atomically replace nil with expunged and leave
  // m.dirty[key] unset.
  //
  // An entry's associated value can be updated by atomic replacement, provided
  // p != expunged. If p == expunged, an entry's associated value can be updated
  // only after first setting m.dirty[key] = e so that lookups using the dirty
  // map find the entry.
  p unsafe.Pointer // *interface{}
}

Map.read包含了部分数据,读写请求优先考虑read,针对它的操作都是CAS,无锁的。

Map.dirty包含的数据是read的超集,对他的操作需要加锁。

readOnly.m表示当前read的数据,readOnly.amended表示是否有数据在dirty中。

entry保存具体数值的指针。有三种情况:

  1. nil,表示已经删除,这个时候dirtyentry的值也是nil,因为他们是同一个entry的地址。
  2. expunged,表示数据已经擦除,entry不在dirty中。
  3. 具体的数据值,一定会在dirty中。

接口

sync.Map包含五个接口:LoadStoreLoadOrStoreDeleteRange

Load、Store、LoadOrStore和Delete

这几个接口都有类似的模式:

read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok {
	ret := do_the_operation()
	if ret is success {
		return
	}
}
m.mu.Lock()
read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok {
	ret := do_the_operation()
} else if e, ok := m.dirty[key]; ok {
	ret := do_the_operation()
}
m.mu.Unlock()

利用read的CAS操作减少锁并发,同时由于并发存在获取锁之后还是有可能数据已经在read中,因此还是对read再做一次同样的操作,复用内存。如果数据仍然不在read中才会考虑操作dirty。针对数据是否在read中这几个接口的逻辑如下:

read包含了部分数据,如果key存在并而且与它对应的entry不是expunged,数据操作优先在这里进行。

  1. Store:直接更新entry值。
  2. Load:直接返回entry值。
  3. LoadOrStore:针对entrytryLoadOrStore操作。
  4. Delete:把entry设置成nil

当数据不在read中时,就会涉及到dirty了:

  1. Store
    a. 如果entryread中并且是expunged,则复用,同时把它修改成nil,然后把entry赋值到dirty,这样就避免了只在read不在dirty的情况。
    b. 如果entrydirty中,那么更新entry值。
    c. 如果entry也不在dirty中,如果dirtynil,则复制read中的entry值非nil的数据。然后,添加值到dirty
  2. Load:从dirty查找,同时增加misses。如果超过一定的阀值,就会发生数据从dirtyread的迁移。
  3. LoadOrStore:流程和Store接口类似,只是返回值和对entry的处理逻辑不一样。
    a. 如果entry有值,则返回具体值以及存在的标识。
    b. 如果entry值为nil,设置entry为新的值并返回它和不存在标识。
    c. 如果entry值是expunged,则返回nil和存在标志,这个是比较特殊。在LoadOrStore同时,并发的把entryread复制到dirty,这种情况就会发生。

Range

Range接口相对简单,如果有部分数据在dirty中就会把dirty的数据提升到read中,并重置dirty。然后,遍历的是dirty数据。否则,只遍历read中的数据。这里不保证能遍历到之后添加的数据。

通过上面的逻辑我们发现readdirty直接数据流转逻辑如下:

  1. readdirty:在StoreLoadOrStore的时候,如果需要保存的key既不在read也不在dirty,而且这时dirtynil,就会把read中的nil数据变成expunged,并复制除了这份以外的数据到dirty
  2. dirtyread
    a. 在LoadLoadOrStore的时候,如果read中不存在,需要从dirty中获取数据,就会增加misses,当misses等于dirty的大小时,就会把dirty封装成readOnly,然后原子的赋值给read,并重置dirty
    b. 在Range的时候,如果有数据不在read中同样会把dirty封装成readOnly,然后原子的赋值给read,并重置dirty数据。

疑问

  1. 为什么需要expunged状态?
    > 如果没有这个状态,更新已经删除的但是已经存在的数据就需要加锁了。

  2. 为什么newEntry的时候取的是参数interface{}的地址,这个地址不是栈上的么,会不会有问题?
    > 参数i的地址被保存到map中时,变量&i已经逃逸到堆上面去了。

总结

文中开头提到的两个主要使用场景的原因主要使用的以下技术:

  1. 无锁的CAS操作。
  2. 读写分离,通过一份只读的数据结合CAS操作减少锁竞争。
  3. 延迟删除,只有当只读的数据被写的数据覆盖以后才会被gc回收。
  4. 内存复用,已经删除的数据所在的内存,当同一个key赋值的时候,可以被重新被使用。
  5. 分摊分析。

参考连接

  1. https://github.com/golang/sync/commits/master/syncmap/map.go
  2. https://golang.org/pkg/sync/#Map
← 如何学习一门编程语言 技术选型 →
存档 关于