Go maps are a fundamental data structure, but their seemingly simple interface hides a complex, dynamic implementation that relies on a hash table.
Let’s see one in action. Imagine we have a map storing user IDs to their last login times.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
userLastLogin := make(map[int]time.Time)
var wg sync.WaitGroup
var mu sync.Mutex // Mutex to protect the map
// Simulate 100 users logging in concurrently
for i := 1; i <= 100; i++ {
wg.Add(1)
go func(userID int) {
defer wg.Done()
// Simulate some work before login
time.Sleep(time.Duration(userID%10) * time.Millisecond)
mu.Lock() // Lock before accessing the map
userLastLogin[userID] = time.Now()
mu.Unlock() // Unlock after accessing the map
fmt.Printf("User %d logged in at %v\n", userID, time.Now())
}(i)
}
wg.Wait()
mu.Lock() // Lock to read the final state of the map
fmt.Println("\nFinal user login times:")
for userID, loginTime := range userLastLogin {
fmt.Printf("User %d: %v\n", userID, loginTime)
}
mu.Unlock() // Unlock after reading
}
This code simulates 100 users logging in concurrently. Each goroutine attempts to update the userLastLogin map with the current time. Notice the sync.Mutex and the mu.Lock() and mu.Unlock() calls around map access. This is crucial.
The problem Go maps solve is efficient key-value storage and retrieval. Internally, a Go map is a hash table. When you insert an element, Go calculates a hash of the key and uses that hash to determine which "bucket" the key-value pair should go into. If multiple keys hash to the same bucket (a collision), Go uses a technique called separate chaining: each bucket becomes a linked list of key-value pairs.
When you access a map, Go again hashes the key, finds the bucket, and then iterates through the linked list (if any) in that bucket to find the matching key. This makes lookups, insertions, and deletions, on average, O(1) time complexity.
However, the dynamic nature of Go maps is what makes them unsafe for concurrent use. As you add more elements, the map might need to resize to maintain performance. Resizing involves allocating a new, larger underlying array of buckets and rehashing all existing elements into the new array. If a goroutine is in the middle of iterating over a map (e.g., a range loop) while another goroutine resizes the map, or if multiple goroutines are trying to write to the map simultaneously, you can get data races. These races can lead to corrupted map state, panics, or incorrect results.
Consider a scenario where two goroutines try to write to the same map at exactly the same time, and the map needs to resize. One goroutine might be halfway through copying an entry to the new underlying array when the other goroutine starts its own resize operation. The internal pointers and state of the map can become inconsistent, leading to unpredictable behavior. Even reading from a map while another goroutine is writing can be problematic if that write triggers a resize or modifies a bucket that the reader is currently examining.
The most common way to make map access safe is to use a sync.RWMutex or sync.Mutex. A sync.Mutex provides exclusive access: only one goroutine can hold the lock at a time, ensuring that no other goroutine can read or write to the map while it’s locked. A sync.RWMutex is often more performant for read-heavy workloads, allowing multiple readers to access the map concurrently but still requiring exclusive access for writes.
Here’s how you’d typically use sync.RWMutex for a map:
package main
import (
"fmt"
"sync"
"time"
)
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func (sm *SafeMap) Get(key string) (int, bool) {
sm.mu.RLock() // Acquire a read lock
defer sm.mu.RUnlock() // Release the read lock when done
val, ok := sm.m[key]
return val, ok
}
func (sm *SafeMap) Set(key string, value int) {
sm.mu.Lock() // Acquire a write lock
defer sm.mu.Unlock() // Release the write lock when done
sm.m[key] = value
}
func main() {
safeMap := SafeMap{m: make(map[string]int)}
var wg sync.WaitGroup
// Concurrent writes
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
key := fmt.Sprintf("key%d", i)
safeMap.Set(key, i*10)
}(i)
}
wg.Wait()
// Concurrent reads
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
key := fmt.Sprintf("key%d", i)
val, ok := safeMap.Get(key)
if ok {
fmt.Printf("Read: %s = %d\n", key, val)
}
}(i)
}
wg.Wait()
}
In this example, SafeMap wraps a regular map and provides Get and Set methods, each protected by sync.RWMutex. Get uses RLock (read lock) allowing multiple reads, while Set uses Lock (write lock) ensuring exclusive access for modifications.
Another common pattern, especially in Go versions 1.9 and later, is to use sync.Map. sync.Map is optimized for cases where keys are not overwritten and when goroutines access disjoint sets of keys. It provides methods like Load, Store, LoadOrStore, and Delete which are inherently safe for concurrent use. It’s a bit more complex to use than a regular map with a mutex, and its performance characteristics differ, so it’s not always a drop-in replacement.
If you’re dealing with a map where elements are only ever added and never deleted or overwritten, and you’re not iterating over it while other goroutines are writing, you might get away without explicit synchronization. However, this is a dangerous assumption. The Go runtime’s map implementation is subject to change, and subtle race conditions can still occur if a resize happens during a read or write operation. The safest approach is always to protect your map access.
The next thing you’ll likely run into is understanding how to effectively iterate over a map safely when concurrent writes are happening, which usually involves copying the map or using a mutex with a read lock for the entire iteration.