sync/atomic: Lock-Free Single-Variable Updates
sync/atomic provides lock-free atomic operations on integers and pointers. Add, Load, Store, Swap, CompareAndSwap. Go 1.19+ added typed atomic.Int64, atomic.Pointer[T], atomic.Bool which are cleaner than the function-style API. Use for counters, hot flags, lock-free data structures.
What it is
sync/atomic provides lock-free atomic operations on integers, pointers, and (via atomic.Value) interface values. The whole package is small: Add, Load, Store, Swap, CompareAndSwap, And, Or. Each is a single hardware instruction with strong memory ordering.
Go 1.19 added typed wrappers (atomic.Int64, atomic.Pointer[T], atomic.Bool) that are cleaner than the function-style API. New code should use the typed wrappers; the function-style API exists for backward compatibility.
When to use it
Counters. atomic.Int64.Add(1) for hit counts, byte counters, sequence numbers. Faster than a mutex by a wide margin.
Hot flags. atomic.Bool for shutdown signals, ready markers, any boolean that is read often and written rarely.
Hot-swappable references. atomic.Pointer[T] for configuration objects, routing tables, any immutable struct that needs to be replaced atomically.
Lock-free data structures. CompareAndSwap is the building block. Most application code does not need this; standard-library structures (channels, sync.Map) cover the common cases.
When not to use it
Compound state. If the invariant says "field A and field B must change together", atomic on each is not enough. The standard fix is to put both in an immutable struct and atomic.Pointer[T].Store the new struct. Or use a mutex.
Replacement for queues. Building a lock-free queue with atomic.Pointer is almost always a mistake; use a buffered channel instead. Channels are highly optimised, easier to reason about, and well-tested.
Optimisation that has not been measured. Atomic operations are fast, but contention on a single atomic word still serialises (cache line ping-pong between cores). Under high write contention, an atomic counter can be slower than a sharded counter.
The CAS pattern
For any update that is a function of the current value (atomic max, conditional update, lock-free linked list operations), the pattern is:
for {
cur := atomic.Load()
next := compute(cur)
if atomic.CompareAndSwap(cur, next) {
return
}
// failed, retry
}
If the CAS fails, another goroutine updated between the load and the swap. Re-read, recompute, try again. Bounded by other goroutines' progress, so it terminates.
For straight increments, use Add directly; it does the loop internally with hardware support.
A note on the memory model
Go 1.19 clarified that atomic operations are sequentially consistent. A Load synchronises with the Store that wrote the value: any writes the writer made before the Store are visible to any reader after the Load.
Concretely: atomic.Pointer[T] can publish a complex initialised object, and other goroutines that Load and dereference will see the fully-initialised object. No additional fencing needed.
This is stronger than C++'s relaxed atomics and matches what most engineers intuitively want. The trade-off (slightly slower than relaxed) is intentional: Go optimises for "obviously correct" over "maximally fast".
Primitives by language
- atomic.Int32 / Int64 / Uint32 / Uint64 (typed, Go 1.19+)
- atomic.Bool (typed boolean)
- atomic.Pointer[T] (typed pointer, generic)
- atomic.Value (interface{} value, replace-only)
- Function-style: atomic.AddInt64, LoadPointer, CompareAndSwapInt64 (legacy)
Implementation
The typed API is method-based and self-documenting. Add returns the new value; Load reads; Store writes. No function-call gymnastics, no type assertion.
1 package main
2
3 import "sync/atomic"
4
5 type Stats struct {
6 requests atomic.Int64
7 errors atomic.Int64
8 }
9
10 func (s *Stats) RecordRequest() { s.requests.Add(1) }
11 func (s *Stats) RecordError() { s.errors.Add(1) }
12 func (s *Stats) Snapshot() (int64, int64) {
13 return s.requests.Load(), s.errors.Load()
14 }Build a new immutable Config, swap the pointer atomically. Readers always see a consistent, fully-initialised Config. The old one is garbage-collected when the last reader releases its reference.
1 package main
2
3 import "sync/atomic"
4
5 type Config struct {
6 Endpoints map[string]string
7 Timeout int
8 }
9
10 var current atomic.Pointer[Config]
11
12 func init() {
13 current.Store(&Config{Endpoints: map[string]string{}, Timeout: 1000})
14 }
15
16 func Get() *Config { return current.Load() }
17
18 func Reload(next *Config) {
19 current.Store(next) // atomic; readers either see old or new, never half
20 }The CAS pattern: read, compute, try-swap, retry on failure. Same loop as in Java, C++, every other language with CAS. Use for lock-free algorithms where the update is a function of the current value.
1 package main
2
3 import "sync/atomic"
4
5 var maxSeen atomic.Int64
6
7 // Atomic max
8 func RecordSample(x int64) {
9 for {
10 cur := maxSeen.Load()
11 if x <= cur {
12 return
13 }
14 if maxSeen.CompareAndSwap(cur, x) {
15 return
16 }
17 // CAS failed, another goroutine updated; retry
18 }
19 }Cleaner than atomic.Int32 + manual 0/1. Use for shutdown flags, ready signals, any value that would otherwise be spin-loaded.
1 package main
2
3 import (
4 "sync/atomic"
5 "time"
6 )
7
8 type Server struct {
9 stopping atomic.Bool
10 }
11
12 func (s *Server) Stop() {
13 s.stopping.Store(true)
14 }
15
16 func (s *Server) Loop() {
17 for !s.stopping.Load() {
18 s.handle()
19 time.Sleep(10 * time.Millisecond)
20 }
21 }On 32-bit platforms, a plain int64 read or write may be split into two 32-bit operations, allowing tearing. atomic.Int64 forces aligned 64-bit access. On 64-bit platforms (most modern systems), plain int64 is atomic in practice, but the spec only guarantees it under atomic.Int64.
1 package main
2
3 import "sync/atomic"
4
5 // Cross-platform safe:
6 var counter atomic.Int64
7
8 func add() { counter.Add(1) }
9
10 // ALSO: alignment matters. The legacy function API requires the int64
11 // to be 8-byte aligned. The typed atomic.Int64 handles this for you.
12 // Old style:
13 // var c int64
14 // atomic.AddInt64(&c, 1) // requires 8-byte alignmentKey points
- •Atomic ops are lock-free: single hardware instruction (LOCK XADD on x86, LDXR/STXR on ARM).
- •Go 1.19+ typed API (atomic.Int64) is preferred over the function-style API.
- •CompareAndSwap is the building block for lock-free algorithms; the retry loop pattern is identical to Java.
- •atomic.Value supports any type but is replace-only (no compound update). Use atomic.Pointer[T] when possible.
- •Plain reads/writes to int64 are NOT atomic on 32-bit platforms; use atomic.Int64 if portability matters.
Follow-up questions
▸atomic.Int64 vs sync.Mutex around an int64: which is faster?
▸When to use atomic.Value vs atomic.Pointer[T]?
▸How does Go's atomic interact with the memory model?
▸Can a lock-free data structure be built with sync/atomic?
Gotchas
- !Function-style API requires 8-byte alignment for int64; typed API handles this
- !atomic.Value requires same concrete type on every Store after the first
- !Plain int64 reads/writes are NOT atomic on 32-bit platforms
- !Atomic operations are NOT enough for compound state (multiple fields); use a mutex or restructure to one immutable struct
- !CAS retry loops can spin under high contention; benchmark before assuming lock-free is faster