High-Performance Go: sync/atomic vs sync.Mutex and the Go Memory Model
"Deep benchmark comparison between sync/atomic and sync.Mutex in Go, exploring cache coherence, memory models, and zero-lock pointer swaps."
High-Performance Go: sync/atomic vs sync.Mutex and the Go Memory Model
In concurrent programming, race conditions occur when multiple goroutines access shared memory concurrently and at least one access is a write. Go provides two primary tools for thread synchronization at the shared-memory level: mutex locks (sync.Mutex / sync.RWMutex) and atomic CPU primitives (sync/atomic).
Understanding how to choose between locks and atomics requires familiarity with cache coherence, CPU memory barriers, and the official Go Memory Model.
The Go Memory Model: Happens-Before Guarantee
The Go Memory Model defines the exact conditions under which a read of a variable in one goroutine is guaranteed to observe a value produced by a write to the same variable in another goroutine.
Without synchronization, modern superscalar CPUs and Go compilers aggressively reorder read/write instructions for pipelining efficiency.
Goroutine A (Core 0) Goroutine B (Core 1)
-------------------- --------------------
a = 1 for b == 0 {}
b = 1 print(a) // MAY PRINT 0 WITHOUT BARRIERS!
Synchronization primitives establish a happens-before edge, creating hardware memory barriers that synchronize L1/L2/L3 cache lines across CPU cores.
sync.Mutex vs sync.atomic Deep-Dive
sync.Mutex: Hybrid OS-Level Synchronization
A Go mutex is a hybrid construct:
- Fast Path: It attempts a single
atomic.CompareAndSwapInt32on the mutex state bit. If uncontended, it takes ~5–10 nanoseconds. - Slow Path: If contended, the calling goroutine actively spins briefly on the CPU. If the lock remains held, the goroutine parks on an M-runtime semaphore wait queue, context-switching the OS thread () to run other runnable goroutines ().
sync/atomic: Hardware-Level Instruction
Atomic operations map directly to specialized CPU instructions (e.g., LOCK CMPXCHG, LOCK XADD on x86-64, or LDREX/STREX on ARM):
- No OS context switching.
- No goroutine descheduling.
- Single-instruction memory fence synchronization.
- Takes ~1–3 nanoseconds.
Practical Benchmark Comparison
Consider a global request counter accessed by hundreds of concurrent goroutines:
package syncbench_test
import (
"sync"
"sync/atomic"
"testing"
)
type MutexCounter struct {
mu sync.Mutex
count int64
}
func (c *MutexCounter) Inc() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
func (c *MutexCounter) Load() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
type AtomicCounter struct {
count atomic.Int64
}
func (c *AtomicCounter) Inc() {
c.count.Add(1)
}
func (c *AtomicCounter) Load() int64 {
return c.count.Load()
}
func BenchmarkMutexCounter(b *testing.B) {
var c MutexCounter
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
c.Inc()
}
})
}
func BenchmarkAtomicCounter(b *testing.B) {
var c AtomicCounter
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
c.Inc()
}
})
}
Benchmark Results (32 Cores x86-64)
BenchmarkMutexCounter-32 18,490,201 62.4 ns/op 0 B/op 0 allocs/op
BenchmarkAtomicCounter-32 94,821,432 12.1 ns/op 0 B/op 0 allocs/op
Atomic operations demonstrate ~5x throughput improvement under extreme multi-core contention for scalar updates.
Lock-Free Data Structures with atomic.Pointer[T] (Go 1.19+)
The atomic.Pointer[T] generic type allows thread-safe atomic pointer swaps without mutex locks. This is ideal for read-heavy configurations that reload periodically in the background (Copy-On-Write):
package config
import (
"sync/atomic"
)
type ServerConfig struct {
RateLimit int
FeatureFlag bool
Hosts []string
}
type ConfigStore struct {
current atomic.Pointer[ServerConfig]
}
func NewStore(initial *ServerConfig) *ConfigStore {
cs := &ConfigStore{}
cs.current.Store(initial)
return cs
}
// GetConfig is zero-lock, zero-wait, non-blocking read
func (cs *ConfigStore) GetConfig() *ServerConfig {
return cs.current.Load()
}
// Reload creates a new copy and atomically swaps the pointer
func (cs *ConfigStore) Reload(newCfg *ServerConfig) {
cs.current.Store(newCfg)
}
Memory Alignment & False Sharing Traps
1. 64-bit Word Alignment
On 32-bit platforms, 64-bit atomics require memory alignment to 8-byte boundaries, or the runtime will crash with a SIGSEGV. Using Go 1.19+ types (atomic.Int64, atomic.Uint64) automatically handles struct padding.
2. False Sharing
When two atomic variables reside on the same 64-byte CPU cache line, updates by different CPU cores invalidate the entire cache line, causing cache thrashing:
// BAD: False sharing hazard across multiple CPU cores
type Metrics struct {
ReadOps atomic.Uint64 // Sits on Cache Line 0
WriteOps atomic.Uint64 // Sits on same Cache Line 0!
}
// GOOD: Cache padding ensures isolated cache lines
type PaddedMetrics struct {
ReadOps atomic.Uint64
_ [56]byte // Pad to fill 64-byte L1 cache line
WriteOps atomic.Uint64
_ [56]byte
}
Decision Matrix
| Use Case | Recommended Primitive | Rationale |
|---|---|---|
| Single Counter / Flag / State | sync/atomic (atomic.Int64, atomic.Bool) |
Lowest latency, non-blocking |
| Read-mostly Config Refresh | atomic.Pointer[T] |
Lock-free Copy-On-Write |
| Multi-field Struct Invariants | sync.Mutex |
Multiple variables need atomic consistency together |
| Long-running I/O Section | sync.Mutex |
Atomics cannot span across I/O pauses |
| Read-heavy, Rare-write Maps | sync.RWMutex / sync.Map |
Shared read lock concurrency |
Conclusion
Use sync/atomic for isolated numeric states, metrics counters, and pointer swaps. Use sync.Mutex whenever consistency spans multiple dependent fields. Avoid prematurely optimizing with complex lock-free algorithms unless benchmarks prove mutex contention is your application's primary bottleneck.
About the Author
huud
@huud
Systems architect and software engineer building high-performance distributed platforms.