Dive Deep Series: Sync.Map in Golang
We deconstruct Go's sync.Map to reveal its two-map, lock-free design, explaining its performance optimisations for read-heavy workloads, its key design trade-offs, and ideal specific use cases.
Go's built-in map
is a powerful and highly-optimised data structure. However, it comes with a critical limitation: it is not safe for concurrent use by multiple go-routines that perform writes. This leads to the infamous "fatal error: concurrent map read and map write."
The common solution is to wrap a regular map with a synchronisation primitive like sync.Mutex
or sync.RWMutex
. While effective, this can introduce performance bottlenecks, especially in high-concurrency, read-heavy scenarios.
This is where sync.Map
comes in. It may feel like a general-purpose concurrent map, but in reality, it is a highly specialised one, designed to excel in very specific use cases. To truly understand when to use it, we need to look under the hood and understand it’s implementation to better understand where it works well.
The Problem with Map
+ Mutex
Consider a standard concurrent map implementation using map and Mutex:
This works, but sync.RWMutex
serialises(sequentialises) all write operations in a high concurrency environment with many go-routines. If one go-routine holds the write lock, all other go-routines—even those just trying to read—will be blocked. This can create a significant performance bottleneck.
The sync.Map
Optimisation: The Two-Map Design
sync.Map
's core innovation is a lock-free, read-optimised design. It achieves this by maintaining two internal maps:
read
map: Anatomic.Pointer
to a read-only map. This map contains all the keys and values that are safe for concurrent, lock-free reads.dirty
map: A standardmap[any]*entry
that is protected by async.Mutex
. This map contains new keys or keys that have been modified.
Here's a simplified look at the sync.Map
struct (from the Go source code, src/sync/map.go
):
This design creates a fast path for reads and a slower path for writes and read misses.
Lifecycle of an Entry
The states of an entry are represented by the pointer p
in the entry
struct, which points to the value. It's a clever use of pointers to encode state information without adding extra fields to the struct.
1. Active State
Meaning: The entry is valid and holds a value. It can be read or updated.
Pointer Value:
p
points to the actual valueinterface{}
. The value is stored on the heap, and the entry's pointer points to it.Location: An active entry exists in the
read
map and/or thedirty
map.If an entry is only in the
read
map, it's considered "clean" and can be read lock-free.If an entry is in both the
read
anddirty
maps, theread
map's pointer and thedirty
map's pointer point to the same underlyingentry
struct. This is a key optimization: a value update to an active entry can be done atomically and will be visible to both maps simultaneously.
2. Deleted State
Meaning: The entry has been deleted but has not yet been physically removed from the underlying maps.
Pointer Value:
p
isnil
.Location: A deleted entry exists in both the
read
anddirty
maps.Transition: An entry transitions to the deleted state when
m.Delete()
is called. The go-routine that performs the deletion will acquire the lock and set thep
pointer tonil
. This is a quick and cheap way to mark an entry as "deleted" without having to reorganise the entire map. The deletion is immediately visible to all go-routines that acquire a lock or check thedirty
map.
3. Expunged State
Meaning: The entry has been deleted and is marked for physical removal during the next map promotion. It's in an intermediate state that signifies the entry is no longer valid.
Pointer Value:
p
points to the sentinel valueexpunged
.var expunged = new(any)
is a global, unique pointer used for this purpose.Location: An expunged entry only exists in the
read
map. It is explicitly not copied to thedirty
map.Transition: An entry transitions to the expunged state as part of the map promotion process. When the
dirty
map is promoted, anynil
-marked entries from the oldread
map are not copied over to the newread
map. Instead, thenil
pointer is atomically replaced with theexpunged
sentinel. This marks the entry as being "gone" from thedirty
map, which is now the newread
map.
In-Depth State Transitions
The core of sync.Map
's performance lies in how these states transition between the read
and dirty
maps.
1. Load
Operation (Read Path)
The go-routine first attempts a lock-free read from the
read
map by atomically loading the pointer:read :=
m.loadReadOnly().If the key is found and its state is Active, the value is returned immediately. This is the fastest and most common path, as it requires no locks.
If the key is not in the
read
map, or theread
map'samended
flag is true (meaning there are new keys in thedirty
map), the go-routine must acquire themu
mutex.With the mutex held, it checks the
dirty
map. If the key is found and is Active, the value is returned. Themisses
counter is then incremented.
2. Store
Operation (Write Path)
This operation is more complex and has two distinct paths:
Case A: Updating an Existing Key
If the key already exists in the
read
map, the go-routine attempts a lock-free update. It tries to atomically swap theentry.p
pointer to the new value.If the entry's state is Active or Deleted, this atomic swap can succeed without acquiring the
mu
mutex. The new value is now immediately visible to all future readers.If the entry’s state in the
read
map is Expunged, it means the key is not present in thedirty
map. Hence the go-routine must add it to thedirty
map.A new
entry
struct is created, and itsp
pointer is atomically initialised to point to the new value. This newentry
is then inserted into thedirty
map with the key.
Case B: Storing a New Key
If the key is not found in the
read
map, the go-routine immediately proceeds to the slow path by acquiring themu
mutex.It checks the
dirty
map. If the key is still not found, it creates a newentry
with an Active state and adds it to thedirty
map.The
mu
mutex is released.
3. Delete
Operation
A go-routine calls
Delete
and finds the key's entry in theread
map.It attempts to atomically set the
entry.p
pointer tonil
. If this succeeds, the entry is now in the Deleted state. This is a quick, lock-free soft-delete.Future reads from this entry will correctly return
(nil, false)
.
4. Map Promotion (misses
Threshold)
This is the cleanup and rebalancing mechanism based on the misses threshold.
When a
Load
operation misses theread
map and has to check thedirty
map, amisses
counter is incremented.Once the number of
misses
is roughly equal to the number of entries in thedirty
map, a go-routine acquires themu
mutex and triggers a promotion.The
dirty
map is promoted to become the newread
map. Any entries in the oldread
map that were in the Deleted state are simply omitted. The entries from the oldread
map that were in the Expunged state are not copied over.A new,
nil
dirty
map is created, and themisses
counter is reset.This promotion effectively garbage collects old, deleted entries and makes the
dirty
map's contents available for lock-free reads.
Memory Model and Atomic Operations
Understanding Memory Visibility
One of the most critical aspects of sync.Map
's design is how it ensures memory visibility across go-routines without explicit synchronisation for read operations. This is achieved through careful use of atomic operations and Go's memory model.
The Role of atomic.Pointer
The read
field in sync.Map
is an atomic.Pointer[readOnly]
, which provides several guarantees:
// Reading from the read map (lock-free path)
read := m.read.Load() // Atomic load with acquire semantics
if entry, ok := read.m[key]; ok {
return entry.p.Load(), true // Another atomic load
}
Acquire-Release Semantics: When a go-routine performs read.Load()
, it establishes a happens-before relationship with the go-routine that performed the corresponding read.Store()
. This ensures that all memory writes that happened before the store are visible to the loading go-routine.
Memory Barriers: Atomic operations in Go provide implicit memory barriers. The atomic load of the read map ensures that:
The map structure itself is fully visible
All entries within the map are properly initialised
The entry pointers are valid and point to initialised values
Entry-Level Atomicity
Each entry's value is also stored behind an atomic.Pointer[any]
:
type entry struct {
p atomic.Pointer[any] // Atomic pointer to the actual value
}
This allows for lock-free updates to existing entries:
// Lock-free update to existing entry
for {
old := entry.p.Load()
if old == expunged {
break // Need to use slow path
}
if entry.p.CompareAndSwap(old, value) {
return // Success!
}
// Retry if CAS failed due to concurrent modification
}
Memory Ordering Guarantees
The combination of atomic operations provides these ordering guarantees:
Read Consistency: A go-routine reading from the read map will see a consistent snapshot
Write Visibility: Updates to entries are immediately visible to subsequent readers
Promotion Atomicity: When the dirty map is promoted, the switch is atomic
Why This Works Without Explicit Locks
The read path can operate without locks because:
The read map is immutable once published (it's never modified, only replaced)
Entry values are updated atomically using CAS operations
The atomic.Pointer ensures proper memory synchronisation
When to Use sync.Map
(and When to Avoid It)
sync.Map
is not a magic bullet. Its optimisations are tailored for two specific scenarios:
Where sync.Map
Works Well
"Write-Once, Read-Many" Caches: This is the classic use case. If you have a cache that is populated once and then read from thousands of times,
sync.Map
's lock-free read path provides significant performance gains. Cache-like structures that only grow (new keys are added, but old keys are never deleted) are a perfect fit.Disjoint Key Access: If multiple go-routines are independently writing to and reading from
sync.Map
using different, non-overlapping keys, the lock contention is minimised. Writes to thedirty
map will be rare, and the lock will be acquired and released quickly, allowing the fast read path to dominate.
Where sync.Map
Falls Short
General-Purpose Concurrent Maps: For most applications, a regular map with an
sync.RWMutex
is a better choice. Thesync.Map
's internal complexity and special-purpose design mean that it can perform worse than a simpler solution under a variety of workloads. For example, if you have a balanced mix of reads and writes, the overhead of managing the two maps and performing map promotions can become a performance penalty.Type Safety:
sync.Map
stores keys and values asinterface{}
, which means you lose all compile-time type checking. Every time you retrieve a value, you must perform a type assertion, which can fail at runtime and requires more boilerplate code. A regular map with a mutex provides full type safety.Frequent Deletions or Overwrites:
sync.Map
's internal mechanism for handling deletions is complex. Deleting a key from theread
map is not immediate; it's marked as "expunged." The key is only truly removed from the map after a map promotion, which can lead to memory leaks if many items are deleted but the map is never promoted. Overwriting existing keys also bypasses the fast-read path and incurs the cost of acquiring the mutex.
Design Trade-offs
sync.Map
's design is a perfect example of a performance trade-off:
Fast, Lock-Free Reads for Complex Writes.
sync.Map
sacrifices the simplicity and speed of its write path to ensure that read operations are as fast as possible, even under heavy concurrency. This is a deliberate choice to cater to read-heavy workloads where locks on a simplemap
would be a bottleneck.Runtime Type Safety for Performance. By using
interface{}
,sync.Map
avoids the need for generic constraints that were not available when it was introduced. This allows it to be a general-purpose concurrent map, but at the cost of losing compile-time type safety.Amortized Cost vs. Immediate Cost. The cost of a write operation in
sync.Map
can vary dramatically. Writing to thedirty
map is relatively cheap. However, if thedirty
map needs to be promoted, the cost is amortised across multiple operations. This can lead to unpredictable performance spikes.A
Mutex
-protected map has a more predictable, consistent performance profile.
Conclusion
sync.Map
is a specialised and powerful tool in Go's standard library. It's not a drop-in replacement for a regular map
and shouldn't be the default choice for concurrent maps.
Instead, start with a regular map
and a sync.RWMutex
. This approach is simple, type-safe, and performant enough for most use cases. Only if profiling reveals that the RWMutex
is a significant bottleneck in a read-heavy, low-write, or disjoint-key access scenario should you consider switching to sync.Map
. In the end, understanding its internal mechanics is key to using it correctly and unlocking its true performance potential.
👉 Connect with me here: Pratik Pandey on LinkedIn
Insightful