Buys lock-free, fastest protection when shared state is exactly one word; pays by being useless for multi-variable invariants — two atomics aren't one transaction.
sync/atomic provides operations that the CPU guarantees to be indivisible: an atomic add, load, store, or compare-and-swap completes in one step that no other goroutine can interleave with. No lock, no critical section — just a single hardware-backed operation. When the shared state is exactly one thing — a counter, a flag, a pointer — atomics are the lightest and fastest way to make it safe. The moment you need to update two things together, atomics stop being enough and you want a Mutex.
Since Go 1.19 there are typed atomic wrappers — atomic.Int64, atomic.Bool, atomic.Pointer[T] — that are clearer and harder to misuse than the older free functions. Prefer them.
Scenario#
You've got the counter race again: several goroutines incrementing one shared integer. A Mutex fixes it, but for a single int a full lock is more machinery than the job needs — every increment pays lock/unlock overhead to protect one add:
// WORKS, but heavy — a whole mutex to guard a single integer's increment.
mu.Lock()
counter++
mu.Unlock()Smell: Your critical section is exactly one operation on one machine word — an increment, a flag flip, a pointer swap — and the lock exists only to make that single operation indivisible. That's what atomics are for.
Solution#
Replace the int with an atomic.Int64 and call Add. It's a single lock-free instruction, and the same 100 goroutines × 1000 increments always lands on 100000:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter atomic.Int64 // zero value is ready to use
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 1000; j++ {
counter.Add(1) // indivisible: no lock, no race
}
}()
}
wg.Wait()
fmt.Println(counter.Load()) // always 100000
}counter.Add(1) reads, adds, and writes back as one uninterruptible step, so the lost-update problem can't happen. counter.Load() reads the value atomically — you use it instead of touching the field directly, because even a plain read racing with an atomic write is undefined.
Flags with atomic.Bool#
A boolean that flips once — "have we started shutting down?", "is the cache warm?" — is the other everyday use. atomic.Bool makes the check-and-set safe across goroutines:
var shuttingDown atomic.Bool
// In the signal handler:
shuttingDown.Store(true)
// In every worker loop:
if shuttingDown.Load() {
return
}For exactly-once actions (run this setup the first time anyone asks, never again), reach for [sync.Once](/patterns/synchronisation/once) instead — it handles the "wait until the first caller finishes initialising" case that a bare flag doesn't.
Copy-on-write with atomic.Pointer#
atomic.Pointer[T] swaps an entire value behind a single pointer, atomically. This is the lock-free way to hold read-heavy, replace-rarely state like configuration: readers do one atomic load, writers build a fresh value and swap it in. No reader ever takes a lock.
type Config struct {
Limit int
Upstream string
}
var current atomic.Pointer[Config]
func init() {
current.Store(&Config{Limit: 100, Upstream: "a.internal"})
}
// Readers: one atomic load, zero contention.
func Current() *Config { return current.Load() }
// Writer: build a whole new Config, swap the pointer in one step.
func Reload(c *Config) { current.Store(c) }The rule that makes this safe: the pointed-to Config is immutable once stored. Readers may be looking at the old value while a writer swaps in the new one — that's fine, because nobody mutates a Config in place; writers only ever replace the pointer. This often beats an RWMutex for config, since reads become a bare load.
For "read it, compute a new version, store it only if nobody changed it underneath me", use CompareAndSwap in a retry loop — the foundation of lock-free algorithms.
When to Use#
- The shared state is a single integer, boolean, or pointer.
- A counter or flag on a hot path where mutex overhead shows up in a profile.
- Read-heavy state you replace wholesale —
atomic.Pointercopy-on-write gives lock-free reads. - Building a lock-free structure with
CompareAndSwap(advanced; reach for it deliberately).
When Not to Use#
- You need to update more than one variable as a unit, or keep an invariant across several steps — that's a critical section; use a Mutex.
- The logic is "check a condition, then act on it" where the value can change between the check and the act — atomics don't give you that window; a mutex does.
- Readability matters more than the last few nanoseconds and the state is small — a
Mutexis often easier for the next reader to verify than a clever atomic.
Common Mistakes#
Mixing atomic and non-atomic access to the same variable. If one goroutine does counter.Add(1) and another reads the field directly, the direct read is a race. Every access — read and write — must go through the atomic methods. The typed wrappers help by not exposing the raw value at all.
Thinking two atomics make a safe transaction. if balance.Load() >= amount { balance.Add(-amount) } is racy: another goroutine can drain the balance between the Load and the Add. Two atomic operations are not one atomic operation. When the check and the update must be inseparable, that's a Mutex, or a single CompareAndSwap loop — not two separate atomics.
Using the old function API on a struct field. The pre-1.19 functions (atomic.AddInt64(&x, 1)) require the variable to be 64-bit aligned, which isn't guaranteed for an int64 field on 32-bit platforms and silently corrupts or panics. The atomic.Int64 wrapper handles alignment for you — another reason to prefer the typed forms.
Copying an atomic after use. Like a Mutex, the typed atomics must not be copied once used (they embed a noCopy guard for vet). Pass pointers to structs that contain them.
The Decision#
Atomic vs. Mutex. Count the things you're protecting. One word — an integer, a flag, a pointer — atomics, lock-free and fast. More than one, or an invariant that spans several statements — a mutex, because only a lock can make a multi-step section indivisible. The trap is stretching atomics to cover a transaction ("check then act") they can't actually make atomic; the result compiles, runs, and is subtly wrong. When in doubt, a mutex is never wrong — only sometimes heavier than necessary.
atomic.Pointer vs. RWMutex for read-heavy state. For config and other replace-wholesale state, an atomic.Pointer swap gives readers a single lock-free load — faster than an RWMutex's shared lock under heavy read load — at the cost of rebuilding the entire value on every write and keeping it immutable. If writers mutate fields in place rather than swapping the whole object, you need a lock instead.
Related Patterns#
- Mutex: the general fix when more than one word, or an invariant, is in play.
- RWMutex: the lock-based alternative for read-heavy state;
atomic.Pointeris the lock-free counterpart. - Once: for exactly-once initialisation, which a bare atomic flag can't express safely.
- Data Races: the problem; this is the lightest fix when the state is a single word.