AlgoMaster Logo

Atomic Operations (sync/atomic)

Medium Priority16 min readUpdated June 6, 2026

A mutex is suited to protecting a block of code, but it's overkill for a single-word update like incrementing a page-view counter. The sync/atomic package exposes hardware-level read-modify-write instructions that finish in one indivisible step, so two goroutines hammering the same int64 can't tear the value apart. This lesson covers when atomic operations beat a mutex, the two API flavours (function form and the newer type form), the compare-and-swap pattern for lock-free state machines, and the alignment trap that causes panics on 32-bit builds.

Why Atomics Exist

A regular counter++ looks like one statement, but the CPU runs it as three steps: load the current value from memory into a register, add one, store it back. If two goroutines do this on the same variable, their loads and stores can interleave. Both read 42, both compute 43, both write 43, and one increment vanishes. That's a data race, and the race detector will flag it.

The final number changes between runs. It's almost never 1000. The fix is to make the increment indivisible, so no goroutine can ever observe a half-updated state. That's what atomic.AddInt64 does: the CPU runs the load, add, and store as one uninterruptible instruction. Other cores have to wait their turn.

Atomics aren't magic. They're hardware support for a small set of operations on single machine words. Anything bigger than a word, or anything involving multiple variables that must stay consistent together, still needs a mutex.

Mutex vs Atomic: A Side-by-Side Counter

Here's the page-view counter implemented twice, once with sync.Mutex and once with atomic.Int64. Both are correct. The difference is in cost and clarity.

Both report 1000 every time. The mutex version takes a lock, increments, and releases. The atomic version issues a single CPU instruction. On the read side, mc.Get() still has to lock so it doesn't observe a torn value, while ac.views.Load() is a single instruction with no locking.

The atomic version is shorter and faster, but only because the protected work is a single integer update. If Inc had to check stock, decrement a different counter, and log the result, you'd want a mutex covering the whole sequence. Atomics protect one word at a time, not a sequence of related changes.

The Two API Flavours: Function Form vs Type Form

The sync/atomic package has two ways to do the same thing. The original API (since Go 1.0) is a set of free functions that take a pointer to the variable. The newer API (added in Go 1.19) wraps the variable in a struct with methods. Both are still supported, but new code should prefer the type form.

Function Form

The function form is built around free functions like atomic.AddInt64, atomic.LoadInt64, and atomic.StoreInt64. They each take a pointer.

The variable is a plain int64, and you remember to use the atomic functions every time you touch it. If anyone, anywhere, writes views++ instead of atomic.AddInt64(&views, 1), the program races and the compiler won't tell you. The discipline lives entirely in convention.

The function form covers these word-sized operations:

OperationFunction (Int64 shown, also Int32/Uint32/Uint64/Uintptr)What it does
Addatomic.AddInt64(&x, n)Atomically adds n, returns the new value
Loadatomic.LoadInt64(&x)Atomically reads the current value
Storeatomic.StoreInt64(&x, n)Atomically writes n
Swapatomic.SwapInt64(&x, n)Atomically writes n, returns the old value
CASatomic.CompareAndSwapInt64(&x, old, new)If *x == old, set to new; returns true on success

There's also atomic.Pointer and atomic.Value for pointer-sized payloads, covered later.

Type Form (Go 1.19+)

The type form wraps each word-sized variable in a struct, and you call methods on it. There's no &x to forget and no plain int64 lying around that someone could touch non-atomically.

The wrapper types match the function form one-for-one:

TypeMethods
atomic.Int32Add, Load, Store, Swap, CompareAndSwap
atomic.Int64Add, Load, Store, Swap, CompareAndSwap
atomic.Uint32same as Int32
atomic.Uint64same as Int64
atomic.Uintptrsame shape
atomic.BoolLoad, Store, Swap, CompareAndSwap (no Add)
atomic.Pointer[T]Load, Store, Swap, CompareAndSwap
atomic.ValueLoad, Store, Swap, CompareAndSwap (interface payload)

There are three reasons to prefer the type form in new code:

  1. No `&` everywhere. You call views.Add(1), not atomic.AddInt64(&views, 1). Less noise, less chance of accidentally taking the address of a temporary.
  2. Alignment handled for you. On 32-bit platforms, the function form requires that the int64 you're hitting is 64-bit aligned, and getting that wrong panics at runtime. The wrapper types pad themselves to guarantee alignment. We'll come back to this.
  3. Type safety. atomic.Pointer[T] is generic, so the load returns a *T. The old atomic.LoadPointer returns unsafe.Pointer and you have to cast.

The function form is still useful when you're working with an existing variable you can't change (a int64 field on a struct you don't own, for example), or when you're targeting Go 1.18 or earlier. Otherwise, use the wrapper types.

Compare-and-Swap (CAS)

Add, Load, Store, and Swap are useful, but CAS is where atomics get interesting. The signature is:

CAS says, "if the current value equals old, set it to new, and return true. Otherwise leave it alone and return false." The whole comparison-plus-update happens as one indivisible step, so two goroutines can't both win the race.

That makes CAS the building block for lock-free state machines. You read the current state, decide what the next state should be, and ask the hardware to update only if nothing else has touched it. If someone else got there first, you loop and try again with the fresh state.

A Single-Step Transition

Consider an order that has a status: pending, confirmed, or cancelled. The business rule is that only one party can confirm a pending order. If two services try to confirm at the same time, exactly one should succeed.

Exactly one goroutine sees the order as pending and successfully flips it to confirmed. The other two arrive after the transition has happened, find the value is confirmed (not pending), and CAS returns false. No mutex, no lost updates, no goroutine ever blocked on a lock.

The diagram below shows two goroutines racing to confirm the same order:

Goroutine A and B both read pending. Both try to CAS from pending to confirmed. The hardware serializes them: one CAS lands first and wins, the second CAS sees that the value is now confirmed (no longer pending), and returns false.

The CAS Retry Loop

The single-step example above doesn't retry, because there's no useful state to retry into. Once an order is confirmed, you don't want a second Confirm to "re-confirm" it.

But for an additive update, retrying makes sense. Consider pushing an order into the next valid status, whatever that is, without locking. Each loop iteration reads the current state, computes the next one, and tries to CAS into it. If someone else changed the state in between, the CAS fails and you start over with the fresh value.

Each goroutine reads the counter, checks the cap, and tries to CAS in current+1. If another goroutine got there first, the CAS fails, and the loop reads the now-updated value before trying again. Exactly five goroutines successfully increment. The other five see the counter is already at five and bail out.

atomic.Pointer[T] for Snapshot Swaps

A common pattern in production systems: you have a slowly-changing data structure (a price list, a routing table, a config file) and many readers that need a consistent view. Locking would block readers. Copying the data on every read would be expensive. What you want is for readers to grab the current pointer atomically, work with that snapshot, and let writers swap a new snapshot in without disturbing anyone.

atomic.Pointer[T] (Go 1.19+) does exactly this. It's a generic atomic pointer with Load, Store, Swap, and CompareAndSwap. Each operation is a single indivisible pointer write.

Each Lookup does one atomic pointer load and then reads the underlying map. The map itself is never modified once published, so readers can scan it freely without locking. When Update runs, it builds a fresh PriceSnapshot and atomically swaps the pointer. Readers that loaded the old pointer keep working with the old snapshot; readers that load after the swap see the new one. No reader ever blocks.

The rule that makes this safe: the inner data (the Prices map here) must be immutable after publication. Once you've stored a snapshot, you never modify it. Updates always build a fresh snapshot and swap the whole pointer.

atomic.Value vs atomic.Pointer[T]

Before generics arrived in Go 1.18, the way to atomically swap an arbitrary value was atomic.Value, which holds an interface{}. It's still in the package, and you'll see it in older codebases. The load returns any and you have to assert the type at every call site, and it panics if you Store two different concrete types into the same Value. The generic atomic.Pointer[T] is type-safe and harder to misuse, so new code should use it. Keep atomic.Value for cases where you genuinely don't know the payload type at compile time, which is rare.

When Atomic Beats Mutex, and When It Doesn't

Atomics are fast and lock-free, but they only protect one word at a time. If your operation touches two related variables, atomics can't keep them consistent with each other.

ScenarioChooseWhy
Increment a page-view counterAtomicSingle word, no related state, atomics are 3-5x cheaper than a mutex.
Toggle a shutdown flag readers pollAtomicBoolean flag is one word, readers just need a fresh value.
Increment quantity AND decrement stock togetherMutexTwo related variables. Atomics can't keep them consistent.
Append to a slice from many goroutinesMutexSlice append touches the header, possibly reallocates the backing array. Not one word.
Swap in a new config snapshotatomic.Pointer[T]Readers want a consistent view of the whole snapshot. Pointer swap is one word.
Update the value at a specific map keyMutex (or sync.Map)Map writes are not safe for concurrent use, and a single map write isn't one word.
Implement a lock-free state machine for one variableAtomic CASThe CAS loop expresses "transition if no one else got here first".
Coordinate "exactly N workers can run"Mutex / semaphoreCounting up and down with a cap involves logic on top of the counter that's awkward in CAS.

The rule is: if you can describe the protected work as "read this one number/pointer, write this one number/pointer", atomics fit. If the protected work spans multiple fields or has any branch that touches more than one variable, use a mutex.

The 32-bit Alignment Gotcha

This trap only affects 32-bit platforms (like 32-bit ARM, 32-bit x86, or GOARCH=386), but the bug is brutal when it fires: a runtime panic that looks like a Go bug, not a user bug.

The function-form atomic operations on 64-bit values (atomic.AddInt64, atomic.LoadInt64, etc.) require that the int64 you're touching is 8-byte aligned in memory. On 64-bit hardware, the compiler aligns 64-bit values automatically. On 32-bit hardware, the default alignment for an int64 inside a struct is 4 bytes, not 8. If you call atomic.AddInt64 on a misaligned field, the program panics:

The classic bad case is an int64 field that's not the first field of a struct, on 32-bit ARM:

On 64-bit it runs fine because the compiler aligns everything to 8 bytes anyway. On 32-bit ARM, the field's address might not be a multiple of 8, and the atomic instruction faults.

There are three ways to avoid this:

  1. Use the type form. atomic.Int64 is a struct that includes alignment padding internally, so it's always correctly aligned regardless of platform. This is the simplest fix and the main reason to prefer the type form in new code.
  1. Put 64-bit fields first. Go aligns the first field of a struct to the most strict alignment any field requires. If the int64 is first, it'll be 8-byte aligned even on 32-bit.
  1. Allocate the variable separately. A free var x int64 at package scope or as the receiver of &x is always 8-byte aligned because the runtime ensures it.

Memory Ordering: Go Keeps It Simple

In C++ or Rust, atomics expose memory ordering options (memory_order_relaxed, memory_order_acquire, etc.) so you can trade ordering guarantees for performance. Go doesn't expose any of that. All Go atomic operations are sequentially consistent. Every goroutine sees a single, agreed-upon order of atomic operations.

The practical consequence: if you atomic.Store a flag and then atomic.Store to another variable, any goroutine that atomic.Loads the second variable and sees the new value is guaranteed to also see the new value of the first. There's no reordering window you have to worry about.

Because both stores are atomic and atomics are sequentially consistent, the reader is guaranteed to see price == 2999 once it observes ready == true. The compiler and CPU are not allowed to reorder the two stores past each other.

This is a deliberate Go design choice. Sequential consistency is easier to reason about than weaker orderings, and the performance cost is small enough on modern hardware that the Go team decided giving you the looser variants would cost more in bugs than it saves in cycles.

You don't get to opt into faster, weaker orderings. If a microbenchmark shows you "need" relaxed atomics, you almost certainly need a different design (a sharded counter, a channel-based aggregator, batched updates), not a different ordering model.

Quiz

Atomic Operations Quiz

10 quizzes