Deeply Understanding the Principle of Go-sync.Map

Keywords: Go less Attribute

Map is like a Go map[interface{}]interface{} but is safe for concurrent use

by multiple goroutines without additional locking or coordination.

Loads, stores, and deletes run in amortized constant time.

The above paragraph is the official description of sync.Map. From the description, sync.Map is very similar to map. The underlying implementation of sync.Map also relies on map, but sync.Map is concurrent security compared with map.

1. Overview of structure

1.1. sync.Map

The structure of sync.Map

type Map struct {
    mu Mutex

  // Then there is the readOnly structure, which is implemented by map and is only used for reading.
    read atomic.Value // readOnly

    // This map is mainly used for writing and sometimes for reading.
    dirty map[interface{}]*entry

    // Record the number of failed key s read from read since the last update to read
    misses int
}

1.2. readOnly

The structure corresponding to the sync.Map.read attribute is not clear here why the properties of the readOnly structure should not be put directly into the sync.Map structure.

type readOnly struct {
  // Maps corresponding to read operations
    m       map[interface{}]*entry
  // Does dirty contain key s that do not exist in m?
    amended bool // true if the dirty map contains some key not in m.
}

1.3. entry

entry is unsafe.Pointer, which records the real address of the data store.

type entry struct {
    p unsafe.Pointer // *interface{}
}

1.4. Structural sketch

Through the above structure, we can draw a simple structure sketch.

2. Process analysis

Let's take a look at the transformation of this structure when we execute Store Load Delete through the following motion map (or manual debug). Let's first add a little bit of our knowledge.

func main() {
    m := sync.Map{}
    m.Store("test1", "test1")
    m.Store("test2", "test2")
    m.Store("test3", "test3")
    m.Load("test1")
    m.Load("test2")
    m.Load("test3")
    m.Store("test4", "test4")
    m.Delete("test")
    m.Load("test")
}

For example, let's look at the structure transformation of m.

3. Source code analysis

3.1. New key

Add a new key value, through the Store method to achieve

func (m *Map) Store(key, value interface{}) {
    read, _ := m.read.Load().(readOnly)
  // If this key exists, update it through tryStore
    if e, ok := read.m[key]; ok && e.tryStore(&value) {
        return
    }
  // There are two situations. 1. key does not exist. 2. The corresponding value of key is marked as expunged. When the entry in read is copied to dirty, the key is marked as expunged and needs to be unlocked manually.
    m.mu.Lock()
    read, _ = m.read.Load().(readOnly)
    if e, ok := read.m[key]; ok {
    // In the second case, unlock first, and then add to dirty
        if e.unexpungeLocked() {
            // The entry was previously expunged, which implies that there is a
            // non-nil dirty map and this entry is not in it.
            m.dirty[key] = e
        }
        e.storeLocked(&value)
    } else if e, ok := m.dirty[key]; ok {
    // Not in m, but in dirty, update the value in dirty
        e.storeLocked(&value)
    } else {
    // If amend==false, dirty and read are identical, but we need to add a new key to dirty, so update read.amended
        if !read.amended {
            // We're adding the first new key to the dirty map.
            // Make sure it is allocated and mark the read-only map as incomplete.
      // This step marks all key s in read as expunged
            m.dirtyLocked()
            m.read.Store(readOnly{m: read.m, amended: true})
        }
        m.dirty[key] = newEntry(value)
    }
    m.mu.Unlock()
}

3.1.1. tryLock

func (e *entry) tryStore(i *interface{}) bool {
    p := atomic.LoadPointer(&e.p)
  // This entry is the entry corresponding to the key and p is the value corresponding to the key. If p is set to expunged, the storage cannot be updated directly.
    if p == expunged {
        return false
    }
    for {
    // Atomic Renewal
        if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
            return true
        }
        p = atomic.LoadPointer(&e.p)
        if p == expunged {
            return false
        }
    }
}

tryLock determines whether the value of the key is set to expunged, in which case it cannot be updated directly.

3.1.2. dirtyLock

This is where the expunged flag is set, and this function synchronizes the data in read to the dirty operation.

func (m *Map) dirtyLocked() {
  // Dirty!= nil indicates that dirty has been modified since the last read synchronized dirty data. At this time, read data is not necessarily accurate and cannot be synchronized.
    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 {
    // Here, tryExpungeLocked is called to set the flag bit for entry, the value corresponding to key.
        if !e.tryExpungeLocked() {
            m.dirty[k] = e
        }
    }
}

3.1.3. tryExpungeLocked

By atomic operation, the expunged flag is set to the corresponding value of entry and key.

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
}

3.1.4. unexpungeLocked

func (e *entry) unexpungeLocked() (wasExpunged bool) {
    return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}

Based on the above analysis, we find that there are four kinds of situations in the new period:

  1. Key originally exists in read. Get the memory address corresponding to the key and modify it atomically.
  2. The key exists, but the value corresponding to the key is marked expunged, unlocked, unlabeled, and updated in dirty, synchronized with read, and then modified the value corresponding to the key.
  3. There is no key in read, but this key exists in dirty, which directly modifies the value of key in dirty
  4. There are no values in read and dirty. First, judge whether the contents of dirty have been modified since read last synchronized the contents of dirty. If not, first synchronize the values of read and dirty, and then add a new key value to dirty.

When the fourth situation arises, it is easy to get confused: since read. amended = false means that the data has not been modified, why synchronize the read data into dirty?

The answer will be found in the Load function, because when read synchronizes dirty data, it gives the dirty pointer to map directly to read.m, and then sets the dirty pointer to nil, so after synchronization, dirty is nil.

Let's look at the implementation

3.2. Load

func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
  // If read does not exist in the map and there are modifications
    if !ok && read.amended {
        m.mu.Lock()
        // Avoid reporting a spurious miss if m.dirty got promoted while we were
        // blocked on m.mu. (If further loads of the same key will not miss, it's
        // not worth copying the dirty map for this key.)
    // Look it up again. Maybe you just upgraded dirty to read.
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok && read.amended {
      // If amended is still in the modified state, go to dirty to find it
            e, ok = m.dirty[key]
            // Regardless of whether the entry was present, record a miss: this key
            // will take the slow path until the dirty map is promoted to the read
            // map.
      // Increase misses count, trigger upgrade dirty to read when the count reaches a certain rule
            m.missLocked()
        }
        m.mu.Unlock()
    }
  // Not found in read dirty
    if !ok {
        return nil, false
    }
  // Find out, through load to determine the specific return content
    return e.load()
}

func (e *entry) load() (value interface{}, ok bool) {
    p := atomic.LoadPointer(&e.p)
  // If p is identified as nil or expunged, key does not exist
    if p == nil || p == expunged {
        return nil, false
    }
    return *(*interface{})(p), true
}

Why did you find p, but p corresponds to nil? This answer will be revealed later when the Delete function is parsed.

3.2.1. missLocked

func (m *Map) missLocked() {
    m.misses++
    if m.misses < len(m.dirty) {
        return
    }
  // Direct the dirty pointer to read.m and set dirty to nil, which is why the Store function will call m.dirtyLocked at the end.
    m.read.Store(readOnly{m: m.dirty})
    m.dirty = nil
    m.misses = 0
}

3.3. Delete

Deleting here is not simply removing key s from map s

func (m *Map) Delete(key interface{}) {
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
  // There's no key in read, but the Map has been marked and changed, so go to dirty and see it.
    if !ok && read.amended {
        m.mu.Lock()
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok && read.amended {
      // Call delete to delete the map of dirty, and delete will determine whether the key exists or not.
            delete(m.dirty, key)
        }
        m.mu.Unlock()
    }
  // If read exists, then false deletion
    if ok {
        e.delete()
    }
}

func (e *entry) delete() (hadValue bool) {
    for {
        p := atomic.LoadPointer(&e.p)
    // It's been deleted. No need to worry about it.
        if p == nil || p == expunged {
            return false
        }
    // Atomicity sets the value of key to nil
        if atomic.CompareAndSwapPointer(&e.p, p, nil) {
            return true
        }
    }
}

According to the above logic, there are several situations when deleting.

  1. If there is no key in read and the Map is modified, try to delete the key in the map in dirty
  2. There is no change in read, and there is no change in Map. That is, there is no key and no operation is required.
  3. In read, try to set the value corresponding to the key to nil, and you will know that it was deleted when you read later, because the value of map in dirty and the value in read map point to the same address space, so modifying read means modifying dirty.

3.3. Range

The traversal logic is simpler. Map has only two states, modified and unchanged.

Modified: Give the dirty pointer to read, read is the latest data, and then traverse the map of read

No modification: just go through read's map

func (m *Map) Range(f func(key, value interface{}) bool) {
    read, _ := m.read.Load().(readOnly)
    if read.amended {
        m.mu.Lock()
        read, _ = m.read.Load().(readOnly)
        if read.amended {
            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
        }
    }
}

3.4. Applicable Scenarios

At the time of the official introduction, the applicable scenario was also explained.

The Map type is optimized for two common use cases:

(1) when the entry for a given key is only ever written once but read many times, as in caches that only grow,

(2) when multiple goroutines read, write, and overwrite entries for disjoint sets of keys.

In these two cases, use of a Map may significantly reduce lock contention compared to a Go map paired with a separate Mutex or RWMutex.

Through the analysis of the source code to understand the reasons for these two rules:

Read more and write less: In the environment of read more and write less, all of them read from read map without locking, while in the case of write more and read less, they need locking. Secondly, there is the possibility of synchronizing read data to dirty operation, and a large number of copy operations will greatly reduce performance.

Reading and writing different keys: sync.Map is an atomic operation for the value of keys, which is equivalent to loading keys with locks, so reading and writing multiple keys can be concurrent at the same time.

Posted by xardas on Sun, 08 Sep 2019 04:04:54 -0700