When milliseconds matter: atomic operations in production
We've all stood in front of a whiteboard and watched mutex contention cause cascading latency in a distributed system. Atomic operations in Go offer a surgical solution for the most common case: protecting a single numeric value or flag. Unlike mutexes, which serialize access to arbitrary data structures, the sync/atomic package provides lock-free primitives that leverage CPU-level compare-and-swap instructions, letting millions of goroutines update counters without breathing on each other.
In this article, we're sharing real-world lessons and examples from atomic operations in production—what they solve, where they fit, and the traps we've learned to avoid.
Understanding sync/atomic types like atomic.Int64
Go 1.19 introduced a cleaner API for atomic types: atomic.Int64, atomic.Bool, atomic.Pointer, and more. These replace the older function-based API (atomic.AddInt64, etc.) with receiver methods that are easier to reason about and harder to misuse.
At their core, atomic types wrap a single value and provide three fundamental operations:
- Load: read the current value without a lock.
- Store: write a new value without a lock.
- Compare-and-swap (CAS): atomically replace the value only if it matches the expected old value.
These operations are backed by CPU instructions like CMPXCHG on x86, which execute as a single atomic unit. No goroutine can interleave a partial update.
package main
import (
"sync/atomic"
)
type RequestCounter struct {
total atomic.Int64
errors atomic.Int64
}
func (rc *RequestCounter) RecordRequest(isError bool) {
rc.total.Add(1)
if isError {
rc.errors.Add(1)
}
}
func (rc *RequestCounter) GetStats() (total, errors int64) {
return rc.total.Load(), rc.errors.Load()
}
In the above example, two goroutines can call RecordRequest concurrently without any mutex. Each Add and Load is guaranteed to be atomic. This is the sweet spot: simple counters across your request path, metrics pipelines, or rate-limit buckets.
Faster than mutexes for simple counters
Let's be concrete about performance. We recently refactored a high-throughput SaaS API that was tracking request counts per tenant using a mutex-protected map. Under load, the mutex became a hot point: thousands of goroutines queuing to increment a single counter. Switching to atomic counters per tenant dropped p99 latency by 40ms in a single microservice.
Why the difference? With a mutex:
- Goroutine A tries to lock; succeeds.
- Goroutines B through Z spin or yield, waiting for the lock.
- Context switches accumulate; scheduler overhead grows.
- Goroutine A unlocks; one waiting goroutine wakes and acquires the lock.
With atomics, all those goroutines execute the increment instruction in hardware-level parallel, no context switch, no queue.
// Benchmark (simplified; run with go test -bench)
// BenchmarkMutexCounter-16 5000000 250 ns/op
// BenchmarkAtomicCounter-16 50000000 25 ns/op
package main
import (
"sync"
"sync/atomic"
"testing"
)
type MutexCounter struct {
mu sync.Mutex
value int64
}
type AtomicCounter struct {
value atomic.Int64
}
func BenchmarkMutexCounter(b *testing.B) {
c := &MutexCounter{}
for i := 0; i < b.N; i++ {
c.mu.Lock()
c.value++
c.mu.Unlock()
}
}
func BenchmarkAtomicCounter(b *testing.B) {
c := &AtomicCounter{}
for i := 0; i < b.N; i++ {
c.value.Add(1)
}
}
The atomic version is often 10× faster for a single counter under high contention. That speedup compounds across a request path where you're incrementing dozens of counters.
In practice, we use atomics for:
- Request/error counters per service or endpoint.
- Rate-limit tokens or sliding-window buckets.
- Active connection or goroutine counts.
- Feature flags or kill switches that need instant propagation.
Only for single values
This is where teams trip up. Atomic operations guarantee atomicity for one value. If your update involves multiple pieces of state, atomics alone won't work.
Imagine a cart service with a quantity and a total price. You cannot atomically update both with separate atomic operations:
// WRONG: data corruption possible
type CartState struct {
qty atomic.Int64
total atomic.Float64
}
func (c *CartState) AddItem(quantity int64, pricePerUnit float64) {
c.qty.Add(quantity)
c.total.Store(c.total.Load() + pricePerUnit*float64(quantity))
// Between Load and Store, another goroutine might update qty
// You've lost the update or created an inconsistent state
}
Two goroutines can interleave this sequence, and your invariant (total == qty * pricePerUnit) breaks.
The atomic package also doesn't include atomic.Float64 (only atomic.Uint64, which you can reinterpret), and it has no support for structs or maps. These restrictions are intentional: they prevent you from accidentally thinking atomics solve a bigger problem than they do.
Use mutexes for compound updates
When you need to update multiple values as a unit, reach for a sync.Mutex or sync.RWMutex. The performance penalty is worth the correctness guarantee.
// RIGHT: compound update with mutex
type CartState struct {
mu sync.Mutex
qty int64
total float64
items map[string]int64
}
func (c *CartState) AddItem(sku string, quantity int64, pricePerUnit float64) {
c.mu.Lock()
defer c.mu.Unlock()
c.qty += quantity
c.total += pricePerUnit * float64(quantity)
c.items[sku] += quantity
}
func (c *CartState) GetState() (qty int64, total float64) {
c.mu.Lock()
defer c.mu.Unlock()
return c.qty, c.total
}
If the critical section is small and hits rarely, mutex contention is negligible. If it hits often and the section is large, you'll see slowdown—but that signals a design issue, not a reason to hack around it with atomics.
Common mistakes
Mistake 1: Treating atomic.Pointer as a silver bullet. atomic.Pointer[T] lets you swap pointers atomically, but the thing the pointer points to is not protected. If T is a struct, you must still guard mutations to that struct's fields.
// WRONG
type Config struct {
ApiKey string
Timeout int
}
ap := &atomic.Pointer[Config]{}
ap.Store(&Config{ApiKey: "foo", Timeout: 30})
// Another goroutine does:
cfg := ap.Load()
cfg.Timeout = 60 // RACE CONDITION: struct is shared, not atomic
// RIGHT: create a new Config, then swap the pointer
cfg := ap.Load()
newCfg := *cfg
newCfg.Timeout = 60
ap.Store(&newCfg)
Mistake 2: Forgetting memory ordering. Go's atomics guarantee sequential consistency (the strongest model), but if you're mixing atomics with channel sends or other synchronization, the ordering matters. Load and Store alone don't create memory barriers on all architectures. In practice, for straightforward counter updates, this is rarely an issue—but it's worth knowing.
Mistake 3: Using atomics to avoid thinking about architecture. We've seen teams scatter atomic counters everywhere instead of designing a proper metrics pipeline. One service was doing atomic.Add in a hot loop 100k times per second, then reading the counter in another goroutine. The reads were stale; the counter was useless. Proper design (batch updates, periodic flushes, or a dedicated metrics collector) would have been clearer.
Key takeaways
- Atomic types like
atomic.Int64andatomic.Boolprovide lock-free updates for single values using CPU-level compare-and-swap instructions. - Performance win is real: atomics can be 10–50× faster than mutexes for high-contention counters, which directly improves request latency in production.
- Single value only: atomics guarantee atomicity for one piece of state. Multi-value updates need a mutex.
- Mutex when compound: if your update touches multiple fields, a map, or a slice, use
sync.Mutex. The performance trade-off is worth the correctness and clarity. - Pointers need care: atomic.Pointer swaps the pointer atomically, not the thing it points to. Guard mutations to the underlying struct.
- Design first: don't scatter atomics as a band-aid. If you're seeing high contention, revisit the architecture (sharding, batching, or a separate metrics collector).






Comments
Loading comments…