Understanding sync.Map in Go

As we know that go provides a type of map that allows us to store key-value pair data, but if we use map in a concurrent situation, we will find that it does not support concurrent reading and writing (an error will be occured).
In this case, we can use sync.Mutex to ensure concurrency safety, but this will cause us to need to lock when reading and writing, which will lead to performance degradation.
In addition to the relatively inefficient way of using mutexes, we can also use sync.Map to ensure concurrency safety, which has higher performance than using sync.Mutex in some scenarios.
This article discusses some of the more interesting issues in sync.Map, such as why sync.Map is needed when there is a map already exists? why is it fast ? .etc.

What is sync.Map?

The sync.Map is a concurrent map implementation that allows for safe access and modification of its contents from multiple goroutines. It provides an efficient way of storing and retrieving values by key.

Under the hood, sync.Map uses a combination of atomic operations, locks, and data structures such as hash tables and node-based trees to ensure that its contents remain consistent and accessible even when multiple goroutines are modifying the map concurrently. This makes it suitable for use in high-concurrency environments, where multiple goroutines are accessing and modifying shared data simultaneously.

It’s important to note that sync.Map does not guarantee iteration order, and the keys of the map may not be processed in any particular order. Additionally, unlike regular Go maps, sync.Map does not support many of the built-in Go map operations, such as delete and range loops, so care should be taken when using this type.

An example of map in concurrent environment
var m = make(map[int]int)

// write goroutine
go func() {
   // write data in map
    for i := 0; i < 10000; i++ {
        m[i] = i
    }
}()

// read goroutine
go func() {
   // read data from map
    for i := 10000; i > 0; i-- {
        _ = m[i]
    }
}()

// wait for complete
time.Sleep(time.Second)

this will cause the following error,

 fatal error: concurrent map read and map write
Use sync.Mutex for concurrency safety

For the problem of map concurrent read and write error reporting, one of the solutions is to use sync.Mutex to ensure concurrency safety, but this will cause us to need to lock when reading and writing, which will lead to performance degradation.

Use sync.Mutex to ensure concurrency safety, the above code can be changed to the following:

var m = make(map[int]int)
// define a mutex lock
var mu sync.Mutex

// write goroutine
go func() {
    for i := 0; i < 10000; i++ {
        mu.Lock() // lock first before write map
        m[i] = i
        mu.Unlock()
    }
}()

// read goroutine
go func() {
    for i := 10000; i > 0; i-- {
        mu.Lock() // lock befor read
        _ = m[i]
        mu.Unlock()
    }
}()

time.Sleep(time.Second)

In this way, no error will be occured, but the performance will decrease, because we need to lock first when reading and writing.

Use sync.RWMutex for concurrency safety

In the previous section, we used sync.Mutex to ensure concurrency safety, but we need to add mutex locks when reading and writing. This means that even if multiple goroutines perform concurrent reading, they still need to wait for the lock.
But the side effect of the mutex is too large, in fact, there is no problem with concurrent reading, and it should be allowed. The performance can be improved If we allow concurrent reading
Of course, the developers of go have also considered this situation, so they provide sync.RWMutex in the sync package. This lock can allow concurrent reading, but it still needs to wait for the lock when writing.
That is to say, when a goroutine holds a write lock, other goroutines can neither read nor write, and can only read and write after the write lock is released.

Using sync.RWMutex to ensure concurrency safety, we can change it to the following:

var m = make(map[int]int)
// define a read/write mutex
var mu sync.RWMutex

// write goroutine
go func() {
    for i := 0; i < 10000; i++ {
        // lock when write
        mu.Lock()
        m[i] = i
        mu.Unlock()
    }
}()

// read goroutine
go func() {
    for i := 10000; i > 0; i-- {
        // perform a read lock,
        // multiple goroutines can perform read lock without waiting
        mu.RLock()
        _ = m[i]
        mu.RUnlock()
    }
}()

// another read go routine
go func() {
    for i := 20000; i > 10000; i-- {
        // perform a read lock,
        // multiple goroutines can perform read lock without waiting
        mu.RLock()
        _ = m[i]
        mu.RUnlock()
    }
}()

time.Sleep(time.Second)

In this way, no error will be occured, and the performance will be improved, because we don’t need to wait for the lock when we are reading.

Note:

  • Multiple goroutines can use RLock at the same time without waiting, which is a read lock.
  • Only one goroutine can use Lock, which is a write lock. When there is a write lock, other goroutines cannot read or write.
  • A goroutine holding a write lock can use Unlock to release the lock.
  • After the write lock is released, other goroutines can acquire the lock (read lock or write lock).

That is to say, when using sync.RWMutex, read operations can be executed concurrently, but write operations are mutually exclusive. In this way, compared with sync.Mutex, the number of waiting for locks is less, and naturally better performance can be obtained.

Why we need sync.Map?

Through the above content, we know that there are two ways to ensure concurrency security:

  • sync.Mutex, in this case, reading and writing are mutually exclusive, and the performance is not good.
  • sync.RWMutex, you can read concurrently, but it is mutually exclusive when writing, and the performance is better than sync.Mutex.

And even if we use sync.RWMutex, there is still some locking overhead. So can we optimize it again? The answer is yes. That is to use sync.Map.

sync.Map has been further optimized on the basis of locks. In some scenarios, atomic operations are used to ensure concurrency safety and better performance.

Use atomic operations instead of read locks

But even if sync.RWMutex is used, the read operation still has the overhead of locking, so is there a better way? The answer is yes, that is, use atomic operations instead of read locks.

A very common example is that multiple coroutines read a variable at the same time, and then accumulate the variable:

var a int32

var wg sync.WaitGroup
wg.Add(2)

go func() {
    for i := 0; i < 10000; i++ {
        a++
    }
    wg.Done()
}()

go func() {
    for i := 0; i < 10000; i++ {
        a++
    }
    wg.Done()
}()

wg.Wait()

// expected value of a should be 20000
fmt.Println(a) // in fact, it's not, for every time

In this example, the expected result is that the value of a is 20000, but in fact, the result of each run is different, and it will not be equal to 20000. One of the very simple solutions is to add lock, but in this case, the performance is not good, we can use atomic operations to solve this problem:

var a atomic.Int32

var wg sync.WaitGroup
wg.Add(2)

go func() {
    for i := 0; i < 10000; i++ {
        a.Add(1)
    }
    wg.Done()
}()

go func() {
    for i := 0; i < 10000; i++ {
        a.Add(1)
    }
    wg.Done()
}()

wg.Wait()

fmt.Println(a.Load()) // 20000
How much is the performance difference between locks and atomic operations?

take a benchmark on these two methods:

func BenchmarkMutexAdd(b *testing.B) {
   var a int32
   var mu sync.Mutex

   for i := 0; i < b.N; i++ {
      mu.Lock()
      a++
      mu.Unlock()
   }
}

func BenchmarkAtomicAdd(b *testing.B) {
   var a atomic.Int32
   for i := 0; i < b.N; i++ {
      a.Add(1)
   }
}

the result is:

BenchmarkMutexAdd-12       100000000          10.07 ns/op
BenchmarkAtomicAdd-12      205196968           5.847 ns/op
Atomic operations in sync.Map

The reason why sync.Map has better performance than sync.RWMutex is the use of atomic operations.
When we read data from sync.Map, we will first use an atomic Load operation to read the key in sync.Map.
Note: What we get here is a snapshot of the key. When we read it, we can also write a new key to sync.Map at the same time. This is a key design to ensure its high performance (similar to reading write separation).

In other words, the reason why sync.Map is faster is that it reduces the use of locks compared to RWMutex, and this is why sync.Map exists

How to use sync.Map

Now we know that sync.Map uses atomic operations to reduce the use of locks. But we seem to not even understand some basic operations of sync.Map, now let us take a look at the basic usage of sync.Map.
The use of sync.Map is quite simple. Some operations in map are also available in sync.Map, but the difference is that in sync.Map, all operations need to be performed by calling its methods.
There are several commonly used methods in sync.Map (CRUD):

  • Store: When we add or modify data, we can use the Store method.
  • Load: method to read data.
  • Range: The method of traversing the data.
  • Delete: method to delete data.
The scenario of sync.Map

In the sync.Map source code, they have already told us the usage scenarios of sync.Map:

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, or (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.

  • When an entry for a given key is written only once but read many times, as in a cache that only grows. (read more and write less)
  • When multiple goroutines read, write and overwrite entries of disjoint keysets. (different goroutines operate different keys)
Summary

Ordinary maps do not support concurrent reads and writes. There are two ways to implement concurrent reading and writing of maps:

  • Use sync.Mutex mutex. Mutex is used for reading and writing, and the performance will be worse than sync.RWMutex.
  • Read-write lock using sync.RWMutex. Read locks are shared, but write locks are exclusive. The performance will be better than sync.Mutex.

In sync.Map, an atomic operation will be performed to read the key first, and if the key cannot be read, it will need to be locked. So the performance will be better than sync.Mutex and sync.RWMutex.
There are several commonly used methods in sync.Map (CRUD):

  • Store: When we add or modify data, we can use the Store method.
  • Load: method to read data.
  • Range: The method of traversing the data.
  • Delete: method to delete data.

The usage scenarios of sync.Map, sync.Map is optimized for the following two scenarios:

  • The key will only be written once, but will be read multiple times.
  • Multiple goroutines read, write, and overwrite entries of disjoint keysets.

Leave a Reply

Your email address will not be published. Required fields are marked *