Atomic Operations in Go

The sync/atomic package provides low-level atomic memory primitives — load, store, swap, compare-and-swap, add, and (since Go 1.23) bitwise and/or — that read and modify a single memory word indivisibly, with no lock and no possibility of another goroutine observing a half-completed update. They are the foundation under every higher-level sync primitive: Mutex, RWMutex, WaitGroup, and Once are all built from atomic counters. Since Go 1.19 the package also exposes typed atomic wrappersatomic.Bool, atomic.Int32/64, atomic.Uint32/64, atomic.Uintptr, and the generic atomic.Pointer[T] — which make atomic state impossible to access non-atomically by accident and solve the notorious 64-bit alignment problem automatically (sync/atomic/doc.go, Go 1.19 release notes).

Mental Model

An ordinary x++ on a shared variable is three machine steps — load, increment, store — and any of the three can interleave with another goroutine, producing a lost update; that is a data race. An atomic operation collapses the read-modify-write into a single, hardware-indivisible instruction (LOCK XADD on x86-64, LDADD/LL-SC loops on ARM64). No other CPU can observe an intermediate state. The mental model has two layers. First, atomicity: the operation is all-or-nothing. Second — and just as important — ordering: Go’s atomics are sequentially consistent, meaning all atomic operations across the whole program appear to execute in one single global order that every goroutine agrees on. That second property is what lets atomics be used not just for counters but for publishing data — write a payload, then atomically store a pointer to it, and any goroutine that atomically loads the pointer is guaranteed to also see the payload.

flowchart LR
    subgraph race["Non-atomic x++ (RACE)"]
        direction TB
        A1["G1: load x=5"] --> A2["G1: compute 6"]
        B1["G2: load x=5"] --> B2["G2: compute 6"]
        A2 --> A3["G1: store x=6"]
        B2 --> B3["G2: store x=6"]
        A3 -.->|"both wrote 6;<br/>one increment lost"| B3
    end
    subgraph atom["atomic.Int64.Add(1)"]
        direction TB
        C1["G1: LOCK XADD -> x=6"] --> C2["G2: LOCK XADD -> x=7"]
        C2 -.->|"indivisible;<br/>global total order"| C3["result correct: 7"]
    end

Diagram: a racing non-atomic increment versus an atomic one. The insight: atomicity removes the interleaving window, and sequential consistency means the two Adds have a definite, agreed-upon order — so the result is exactly the sum of the deltas, every time.

The Two APIs: Functions and Typed Wrappers

sync/atomic exposes the same operations through two surfaces.

The original function-based API (since Go 1.0, expanded over time)

Free functions parameterized on a pointer: atomic.AddInt64(&x, 1), atomic.LoadUint32(&y), atomic.CompareAndSwapInt32(&z, old, new), atomic.SwapPointer(&p, np), and — added in Go 1.23 — atomic.AndInt32/atomic.OrUint64 and siblings (Go 1.23 release notes). The supported element types are deliberately few: int32, int64, uint32, uint64, uintptr, and unsafe.Pointer. The doc explains the restriction: “on many architectures, atomic operations on non-word-sized integers are inefficient or infeasible” (sync/atomic/doc.go).

The typed wrappers (Go 1.19; atomic.Pointer[T] uses generics)

Go 1.19 added struct types — Bool, Int32, Int64, Uint32, Uint64, Uintptr, Pointer[T] — that encapsulate the underlying word so it can only be touched through atomic methods (Go 1.19 release notes). Their definitions (sync/atomic/type.go) are thin:

type Int64 struct {
	_ noCopy
	_ align64
	v int64
}
func (x *Int64) Load() int64              { return LoadInt64(&x.v) }
func (x *Int64) Store(val int64)          { StoreInt64(&x.v, val) }
func (x *Int64) Add(delta int64) int64    { return AddInt64(&x.v, delta) }
func (x *Int64) CompareAndSwap(old, new int64) bool { return CompareAndSwapInt64(&x.v, old, new) }

Every method just forwards to the corresponding function. The value gains are: (1) the field v is unexported, so you cannot accidentally write x.v = 7 non-atomically — the type makes the mistake unrepresentable; (2) noCopy makes go vet flag any copy of the atomic; (3) the align64 marker (a compiler-recognized zero-size type) forces 64-bit alignment — the alignment problem, discussed below, simply vanishes for atomic.Int64/Uint64. The package doc now recommends the typed forms over the functions: each function’s doc says “Consider using the more ergonomic and less error-prone [Int32.Add] instead.”

atomic.Pointer[T] is the one that uses generics. Its definition holds an unsafe.Pointer plus a phantom [0]*T field “to disallow conversion between Pointer types” — so an atomic.Pointer[Foo] and an atomic.Pointer[Bar] are distinct, non-interchangeable types. Its Load() *T and Store(*T) methods do the unsafe.Pointer conversions for you, removing the single ugliest part of the old API where you had to write unsafe.Pointer casts at every call site.

There is also atomic.Value — older than the 1.19 wrappers (it dates to Go 1.4) — which atomically stores and loads a value of any concrete type via interface{}. It is the only atomic that handles arbitrary types, but it has a quirk: once Store is called, all subsequent Stores must pass the same dynamic type, or it panics. For pointers, atomic.Pointer[T] is now usually the better choice.

Mechanical Walk-through of Each Operation

  • Loadreturn *addr, atomically. Reads the whole word in one instruction; never observes a torn (partially written) value.
  • Store*addr = val, atomically. The companion to Load.
  • Swapold = *addr; *addr = new; return old. Read and overwrite as one indivisible step.
  • Add*addr += delta; return *addr. The atomic increment that drives WaitGroup’s counter and RWMutex’s readerCount. To subtract, add a negative int32/int64, or for the unsigned functions use the two’s-complement trick the doc spells out: AddUint32(&x, ^uint32(c-1)) subtracts c.
  • CompareAndSwap (CAS)if *addr == old { *addr = new; return true }; return false. The cornerstone of lock-free algorithms: it lets a goroutine commit an update only if nobody else changed the value first. Lock-free code is almost always a for loop “load current value, compute new value, CAS; retry if CAS lost.” This is exactly the shape of cansemacquire in the runtime semaphore and of RWMutex.TryRLock.
  • And / Or (Go 1.23) — atomic bitwise AND/OR against a mask, returning the old value. Useful for atomically flipping or clearing flag bits in a packed word without a CAS loop.

Each one is, on the common amd64/arm64 targets, a single instruction or a tight load-linked/store-conditional loop emitted directly by the compiler — there is no function call overhead in optimized builds because the operations are compiler intrinsics.

The 64-bit Alignment Requirement

This is the single most infamous footgun of the function-based API, and it only exists on 32-bit platforms (386, arm, 32-bit mips). On those architectures the CPU can only perform a 64-bit atomic operation on an address that is itself 64-bit (8-byte) aligned. The compiler aligns 32-bit values to 4 bytes by default, so a 64-bit field can land at a 4-byte-but-not-8-byte-aligned offset — and atomic.AddInt64 on such an address crashes with a misaligned-pointer fault.

The package doc states the caller’s responsibility precisely: “On ARM, 386, and 32-bit MIPS, it is the caller’s responsibility to arrange for 64-bit alignment of 64-bit words accessed atomically via the primitive atomic functions.” It then enumerates the addresses you can rely on: “The first word in an allocated struct, array, or slice; in a global variable; or in a local variable … can be relied upon to be 64-bit aligned” (sync/atomic/doc.go). The classic remedy was to make any int64 you intend to use with atomic.AddInt64 the first field of its struct.

The Go 1.19 typed wrappers eliminate this entirely: atomic.Int64 and atomic.Uint64 embed the align64 marker, “automatically aligned to 64-bit boundaries in structs and allocated data, even on 32-bit systems” (Go 1.19 release notes). So the modern guidance is unambiguous: on 32-bit targets, never use atomic.AddInt64 on a raw int64 field — use atomic.Int64. Note this concern does not arise on 64-bit platforms (amd64, arm64), where all 64-bit values are naturally aligned anyway; with the as-of baseline being Go 1.26 and the typed wrappers a decade old, this is now a legacy-code and 32-bit-embedded concern rather than an everyday one.

Relationship to the Go Memory Model

Atomics are not just about indivisibility — they are about ordering, and that is governed by the Go Memory Model. The memory model and the atomic doc state the rule identically: “if the effect of an atomic operation A is observed by atomic operation B, then A synchronizes before B. Additionally, all the atomic operations executed in a program behave as though executed in some sequentially consistent order. This definition provides the same semantics as C++‘s sequentially consistent atomics and Java’s volatile variables” (go.dev/ref/mem, sync/atomic/doc.go).

Two consequences. First, Go has no relaxed/acquire/release atomics in the public API — unlike C++11, every sync/atomic operation is fully sequentially consistent. This is a deliberate simplicity choice: it removes a whole category of subtle ordering bugs at some performance cost. Second, atomics establish happens-before edges, which is what makes the publication pattern sound: if goroutine A fully initializes a struct and then does p.Store(ptr), and goroutine B does q := p.Load() and observes ptr, then A’s store synchronizes-before B’s load, and therefore every write A made before the store is visible to B after the load. This is precisely how sync.Once’s fast path is correct — the done.Store(true) synchronizes-before any done.Load() that sees true, transitively publishing everything f did.

Code Examples

A lock-free counter

type Counter struct {
	n atomic.Int64   // typed wrapper: aligned + copy-checked, no raw int64 footgun
}
 
func (c *Counter) Inc()        { c.n.Add(1) }
func (c *Counter) Get() int64  { return c.n.Load() }

Add(1) and Load() are each one instruction; multiple goroutines can Inc() concurrently with no lock and no race. Compare this to wrapping a plain int64 in a Mutex — the atomic version is several times faster for this trivial workload.

Copy-on-write config with atomic.Pointer

type Config struct{ Timeout time.Duration; Retries int }
 
var current atomic.Pointer[Config]
 
func Load() *Config { return current.Load() }            // readers: one atomic load
 
func Reload(c *Config) { current.Store(c) }              // writer: build new, swap pointer

Readers do a single wait-free atomic load and get an immutable snapshot; the writer constructs a fresh Config and atomically swaps the pointer. No reader ever sees a half-updated Config, and there is no lock contention at all — this beats an RWMutex for read-mostly shared state. The discipline: the pointed-to Config must be treated as immutable after Store.

CAS loop — the lock-free update idiom

var total atomic.Uint64
 
func addClamped(delta, max uint64) {
	for {
		old := total.Load()
		nv := old + delta
		if nv > max { nv = max }
		if total.CompareAndSwap(old, nv) {   // commit only if nobody intervened
			return
		}
		// another goroutine changed total; retry
	}
}

This expresses an update that is not a simple add — a clamped add — yet remains lock-free. Every lock-free data structure in Go is built from this load/compute/CAS-retry skeleton.

Failure Modes and Common Misunderstandings

  • Mixing atomic and non-atomic access to the same word. Accessing a variable atomically in one place and with a plain = elsewhere is still a data race — the race detector will flag it. The typed wrappers prevent this by construction; the function API does not.
  • 64-bit misalignment on 32-bit targets. As above: atomic.AddInt64 on a misaligned raw int64 field crashes. Use atomic.Int64.
  • Atomicity is not transactionality. Two separate atomic operations are not collectively atomic. if a.Load() == 0 { a.Store(1) } has a race window between the load and the store; you need a single CompareAndSwap(0, 1) instead.
  • Copying a typed atomic. atomic.Int64 etc. carry noCopy; copying one (passing by value) yields an independent variable and go vet flags it. Always use a pointer or keep it in place.
  • atomic.Value dynamic-type lock-in. After the first Store, every subsequent Store to an atomic.Value must pass the same concrete type or it panics. atomic.Pointer[T] avoids this for the pointer case.
  • Assuming relaxed semantics for speed. Go atomics are always sequentially consistent; you cannot opt into weaker (cheaper) ordering. Code ported from C++ that relied on memory_order_relaxed gets stronger-than-needed (and slightly slower) ordering in Go — correct, but not a free lunch.
  • Overusing atomics. The package doc itself warns: “Except for special, low-level applications, synchronization is better done with channels or the facilities of the sync package.” Atomics are easy to get subtly wrong; reach for them only for genuine hot paths.

Alternatives and When to Choose Them

  • sync.Mutex — when the critical section touches more than one variable, or is not a single read-modify-write. A mutex protects a region; an atomic protects a word.
  • Channels — when the goal is handing off ownership or coordinating goroutines rather than sharing a mutable counter. “Share memory by communicating.”
  • sync.Once / OnceValue — for exactly-once lazy initialization; do not hand-roll it from a CAS.
  • Higher-level sync types (WaitGroup, RWMutex, Map) — these are atomics packaged with correct usage; prefer them over a bespoke atomic protocol whenever one fits.
  • Plain atomics — the right choice for a single hot counter/flag/pointer where lock contention is measurable and the operation is a single load/store/add/CAS.

Production Notes

The dominant production use of sync/atomic is exactly two patterns: hot counters (request counts, metrics, rate limiters) and atomic.Pointer snapshot publication (config reload, routing tables, feature flags) — both because they eliminate lock contention that shows up in [[pprof and Profiling|pprof]]‘s mutex profile on many-core machines. The migration story is also worth knowing: a large amount of pre-2022 Go code uses the raw function API with int64 fields and hand-placed alignment comments; modernizing it to atomic.Int64/atomic.Pointer[T] both deletes the alignment hazard and makes non-atomic access a compile-time impossibility, which is why the Go team’s own gopls/go fix tooling now suggests the conversion. One real-world gotcha that still bites: an atomic.Pointer[T] makes the pointer swap atomic but does not make the pointed-to T immutable — if a reader holds the loaded *T and a writer mutates the same T in place, that is still a race. The pattern only works if every published T is treated as read-only after Store.

See Also