Source Interpretation of Golang's sync.Map Implementation Principle

Keywords: Go Programming Attribute less

brief introduction

Gos built-in maps do not support concurrent write operations, because map write operations are not concurrently secure. When you try multiple Goroutine operations on the same map, an error occurs: fatal error: concurrent map writes.

So sync.Map has been officially introduced to accommodate concurrent programming applications.

The implementation of sync.Map can be summarized as follows:

  • Read and write are separated by read and dirty fields, read-only fields exist for data read, and dirty fields exist for newly written data
  • Read queries read, no dirty queries, write dirty only
  • Read does not require a lock, but read or write dirty requires a lock
  • There is also a misses field to count the number of times a read is penetrated (when penetration indicates the need to read dirty), and to synchronize dirty data to read more than a certain number of times
  • Delay deletion by marking directly for deleted data

data structure

The data structure of Map is as follows:

type Map struct {
    // Locking protects dirty fields
    mu Mutex
    // Read-only data, the actual data type is readOnly
    read atomic.Value
    // Latest Written Data
    dirty map[interface{}]*entry
    // Counter, need to read dirty every time + 1
    misses int
}

The data structure of readOnly is:

type readOnly struct {
    // Built-in map
    m  map[interface{}]*entry
    // Indicates that there is a key in dirty that is not in read, and this field determines whether to lock dirty
    amended bool
}

The entry data structure is used to store pointers to values:

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

Attribute p has three states:

  • p == nil: The key value has been deleted and m.dirty == nil
  • p == expunged: The key value has been deleted, but m.dirty!=nil and m.dirty does not exist (expunged is actually a null interface pointer)
  • In addition to the above, the key-value pair exists in m.read.m and m.dirty if m.dirty!=nil

Map s commonly use the following methods:

  • Load: Read the specified key to return value
  • Store: Store (add or change) key-value
  • Delete: Delete the specified key

Source Parsing

Load

func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
    // First try to read the readOnly object from read
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]

    // Try to get from dirty if it doesn't exist
    if !ok && read.amended {
        m.mu.Lock()
        // Since the read fetch above is not locked, check again for safety
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]

        // Get from dirty if it does not exist
        if !ok && read.amended {
            e, ok = m.dirty[key]
            // Call miss logic
            m.missLocked()
        }
        m.mu.Unlock()
    }

    if !ok {
        return nil, false
    }
    // Read values from entry.p
    return e.load()
}

func (m *Map) missLocked() {
    m.misses++
    if m.misses < len(m.dirty) {
        return
    }
    // When miss accumulates too much, dirty is saved in read, amended = false, and m.dirty = nil
    m.read.Store(readOnly{m: m.dirty})
    m.dirty = nil
    m.misses = 0
}

Store

func (m *Map) Store(key, value interface{}) {
    read, _ := m.read.Load().(readOnly)
    // If read exists, try saving to entry
    if e, ok := read.m[key]; ok && e.tryStore(&value) {
        return
    }

    // If the previous step is not successful, it will be handled on a case-by-case basis
    m.mu.Lock()
    read, _ = m.read.Load().(readOnly)
    // Like Load, get it again from read
    if e, ok := read.m[key]; ok {
        // Case 1: read exists
        if e.unexpungeLocked() {
            // If p == expunged, you need to assign entry to dirty first (because expunged data will not remain in dirty)
            m.dirty[key] = e
        }
        // Update entry with value
        e.storeLocked(&value)
    } else if e, ok := m.dirty[key]; ok {
        // Scenario 2: read does not exist, but dirty does exist, then update entry with value
        e.storeLocked(&value)
    } else {
        // Case 3: Neither read nor dirty exists
        if !read.amended {
            // If amended == false, call dirtyLocked to copy read to dirty (except for marked deleted data)
            m.dirtyLocked()
            // Then change amended to true
            m.read.Store(readOnly{m: read.m, amended: true})
        }
        // Save new key values in dirty
        m.dirty[key] = newEntry(value)
    }
    m.mu.Unlock()
}

func (e *entry) tryStore(i *interface{}) bool {
    for {
        p := atomic.LoadPointer(&e.p)
        if p == expunged {
            return false
        }
        if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
            return true
        }
    }
}

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

func (e *entry) storeLocked(i *interface{}) {
    atomic.StorePointer(&e.p, unsafe.Pointer(i))
}

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 {
        // Determine if entry is deleted or saved in dirty
        if !e.tryExpungeLocked() {
            m.dirty[k] = e
        }
    }
}

func (e *entry) tryExpungeLocked() (isExpunged bool) {
    p := atomic.LoadPointer(&e.p)
    for p == nil {
        // If there is p == nil (that is, the key-value pair is delete d), it will be set as expunged at this time
        if atomic.CompareAndSwapPointer(&e.p, nil, expunged) {
            return true
        }
        p = atomic.LoadPointer(&e.p)
    }
    return p == expunged
}

Delete

func (m *Map) Delete(key interface{}) {
    m.LoadAndDelete(key)
}

// LoadAndDelete acts as Delete and returns a value equal to whether it exists or not
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) {
    // Getting logic is similar to Load, querying dirty if read does not exist
    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]
            m.missLocked()
        }
        m.mu.Unlock()
    }
    // Delete after querying entry
    if ok {
        // Marking entry.p as nil does not actually delete the data
        // Truly deleting data and being set as expunged is in Store's tryExpungeLocked
        return e.delete()
    }
    return nil, false
}

summary

It can be seen that this read-write separation design solves the write security in concurrent situations, and makes the read speed close to the built-in map in most cases, which is very suitable for cases with more reads and less writes.

sync.Map has other methods:

  • Range: Traverses through all key-value pairs and the parameter is a callback function
  • LoadOrStore: Read data, save it and read it if it doesn't exist

This is no longer detailed here, see Source code.

Posted by skeener on Sun, 22 Mar 2020 22:56:00 -0700