Go's Atomics and the Memory Model: What sync/atomic Actually Guarantees

sync/atomic is sequentially consistent, its typed wrappers exist to stop you shooting yourself in the foot, and on amd64 every one of them bottoms out in LOCK CMPXCHG or XADD.

TL;DR: sync/atomic gives you sequentially consistent operations: if an atomic write is observed by an atomic read, the write “synchronizes before” the read. The typed wrappers exist to stop you storing the wrong type into the wrong variable, and on amd64 every call bottoms out in LOCK CMPXCHG or XADD.

Concurrency primitives are the parts of a language where the marketing and the semantics are usually further apart than anyone would like. In Go the gap is narrower than in most languages, but it is still wide enough that “just use an atomic” is not a plan.

So let’s get precise about what sync/atomic actually promises, how the typed wrappers protect you, and what the compiler emits when you call them. This is a walk through the Go 1.28 source tree.

What the package promises

The package doc is short, and it is unusually careful. From src/sync/atomic/doc.go:

go
// src/sync/atomic/doc.go:14
// The swap operation, implemented by the SwapT functions, is the atomic
// equivalent of:
//
//	old = *addr
//	*addr = new
//	return old
//
// The compare-and-swap operation, implemented by the CompareAndSwapT
// functions, is the atomic equivalent of:
//
//	if *addr == old {
//		*addr = new
//		return true
//	}
//	return false
//
// The add operation, implemented by the AddT functions, is the atomic
// equivalent of:
//
//	*addr += delta
//	return *addr

And then the part that actually matters:

go
// src/sync/atomic/doc.go:40
// In the terminology of [the Go memory model], 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.

Two guarantees, and they are different guarantees.

Synchronizes before is the memory-model guarantee. If goroutine A does an atomic store and goroutine B does an atomic load that observes that store, then everything A did before the store is visible to B after the load. That is the edge you need to build a lock-free queue.

Sequentially consistent is the stronger guarantee. It says that all atomic operations in the whole program can be lined up in one total order that is consistent with what each goroutine observes. This is C++’s memory_order_seq_cst and Java’s volatile. It means you do not have to think about reordering, and that is why the Go runtime is willing to make it the default for every atomic operation.

The package doc also warns you off the whole thing, which is good advice:

go
// src/sync/atomic/doc.go:8
// These functions require great care to be used correctly.
// Except for special, low-level applications, synchronization is better
// done with channels or the facilities of the [sync] package.
// Share memory by communicating;
// don't communicate by sharing memory.

The typed wrappers

The raw API is atomic.AddInt64(addr *int64, delta int64) int64. It is easy to pass the wrong pointer. The typed wrappers exist so that the variable carries its own atomicity, and the compiler and go vet can check that you are not copying it or using it as a plain integer.

go
// src/sync/atomic/type.go:104
// An Int64 is an atomic int64. The zero value is zero.
//
// Int64 must not be copied after first use.
type Int64 struct {
	_ noCopy
	_ align64
	v int64
}

// Load atomically loads and returns the value stored in x.
func (x *Int64) Load() int64 { return LoadInt64(&x.v) }

// Store atomically stores val into x.
func (x *Int64) Store(val int64) { StoreInt64(&x.v, val) }

// Swap atomically stores new into x and returns the previous value.
func (x *Int64) Swap(new int64) (old int64) { return SwapInt64(&x.v, new) }

// CompareAndSwap executes the compare-and-swap operation for x.
func (x *Int64) CompareAndSwap(old, new int64) (swapped bool) {
	return CompareAndSwapInt64(&x.v, old, new)
}

// Add atomically adds delta to x and returns the new value.
func (x *Int64) Add(delta int64) (new int64) { return AddInt64(&x.v, delta) }

Three details in that struct are doing real work.

_ noCopy makes the struct uncopyable, in the sense that go vet’s copylocks checker will flag x := *myAtomic and passing it by value. The mechanism is the classic one:

go
// src/sync/atomic/type.go:238
// noCopy may be added to structs which must not be copied
// after the first use.
//
// See https://golang.org/issues/8005#issuecomment-190753527
// for details.
//
// Note that it must not be embedded, due to the Lock and Unlock methods.
type noCopy struct{}

// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

Lock and Unlock do nothing. The vet checker simply looks for a type with those methods and reports a copy. Copying an atomic is genuinely a bug: two goroutines could end up mutating two copies of the same variable and neither would ever see the other’s write.

_ align64 forces the struct to be 8-byte aligned even on 32-bit platforms, where an ordinary int64 field might only be 4-byte aligned. The package doc is blunt about the consequences of getting this wrong:

go
// src/sync/atomic/doc.go:59
// BUG(rsc): On 386, the 64-bit functions use instructions unavailable before the Pentium MMX.
//
// On non-Linux ARM, the 64-bit functions use instructions unavailable before the ARMv6k core.
//
// 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 (types [Int64] and [Uint64] are automatically aligned).

The value field is private. You cannot reach x.v from outside the package. That is the whole point: if you hold an atomic.Int64, the only way to touch the number is through an atomic operation. There is no way to accidentally read it non-atomically.

atomic.Bool is a little odd, and worth knowing about:

go
// src/sync/atomic/type.go:9
// A Bool is an atomic boolean value.
// The zero value is false.
//
// Bool must not be copied after first use.
type Bool struct {
	_ noCopy
	v uint32
}

A uint32, not a uint8 and not a bool. The package doc explains why:

go
// src/sync/atomic/doc.go:48
// Only a few integer sizes are supported: on many architectures,
// atomic operations on non-word-sized integers are inefficient or
// infeasible. For example, a [Bool] may be larger than a built-in bool.

There is no atomic.Int8 or atomic.Uint8 in sync/atomic for exactly this reason. (The runtime’s internal version, internal/runtime/atomic, does have a Uint8 and a Bool — it can use a byte-wide LOCK OR — but that package is not for general use.)

atomic.Pointer[T]: type safety without unsafe.Pointer

Before Go 1.19, the way to publish a pointer atomically was to store a unsafe.Pointer and cast it back on the way out. Every cast site was a place where you could store a *Widget and read it back as a *Gadget.

atomic.Pointer[T] fixes that:

go
// src/sync/atomic/type.go:44
// A Pointer is an atomic pointer of type *T. The zero value is a nil *T.
//
// Pointer must not be copied after first use.
type Pointer[T any] struct {
	// Mention *T in a field to disallow conversion between Pointer types.
	// See go.dev/issue/56603 for more details.
	// Use *T, not T, to avoid spurious recursive type definition errors.
	_ [0]*T

	_ noCopy
	v unsafe.Pointer
}

// Load atomically loads and returns the value stored in x.
func (x *Pointer[T]) Load() *T { return (*T)(LoadPointer(&x.v)) }

// Store atomically stores val into x.
func (x *Pointer[T]) Store(val *T) { StorePointer(&x.v, unsafe.Pointer(val)) }

// Swap atomically stores new into x and returns the previous value.
func (x *Pointer[T]) Swap(new *T) (old *T) { return (*T)(SwapPointer(&x.v, unsafe.Pointer(new))) }

// CompareAndSwap executes the compare-and-swap operation for x.
func (x *Pointer[T]) CompareAndSwap(old, new *T) (swapped bool) {
	return CompareAndSwapPointer(&x.v, unsafe.Pointer(old), unsafe.Pointer(new))
}

The zero-length array _ [0]*T is the clever bit. It has size zero, so it costs nothing, but it mentions *T in the struct’s type. That makes atomic.Pointer[Widget] and atomic.Pointer[Gadget] different types, so you cannot convert one into the other even with an explicit conversion. The compiler will not let you write atomic.Pointer[Gadget](myWidgetPointer) any more. Go 1.19 issue 56603 is about exactly this hole.

Note what is not in there: no unsafe visible to the user. The unsafe conversion is confined to the four methods, where the type system has already guaranteed that *T is the right thing to put in.

Why atomic.Value was hard to use

atomic.Value predates atomic.Pointer[T] and it shows. It stores an any, and the type of what you store has to be consistent for the life of the value:

go
// src/sync/atomic/value.go:44
// Store sets the value of the [Value] v to val.
// All calls to Store for a given Value must use values of the same concrete type.
// Store of an inconsistent type panics, as does Store(nil).
func (v *Value) Store(val any) {
	if val == nil {
		panic("sync/atomic: store of nil value into Value")
	}
	vp := (*efaceWords)(unsafe.Pointer(v))
	vlp := (*efaceWords)(unsafe.Pointer(&val))
	for {
		typ := LoadPointer(&vp.typ)
		if typ == nil {
			// Attempt to start first store.
			// Disable preemption so that other goroutines can use
			// active spin wait to wait for completion.
			runtime_procPin()
			if !CompareAndSwapPointer(&vp.typ, nil, unsafe.Pointer(&firstStoreInProgress)) {
				runtime_procUnpin()
				continue
			}
			// Complete first store.
			StorePointer(&vp.data, vlp.data)
			StorePointer(&vp.typ, vlp.typ)
			runtime_procUnpin()
			return
		}

Read that closely and you can see the pain. Value is a hand-rolled interface header with a firstStoreInProgress sentinel, a CAS loop to initialise it, preemption disabled around the first store so that other goroutines can spin on it, and a runtime panic if you ever store a different concrete type. Every user has to know that the first store decides the type forever, and the panic message is the only warning you get:

go
// src/sync/atomic/value.go:76
		// First store completed. Check type and overwrite data.
		if typ != vlp.typ {
			panic("sync/atomic: store of inconsistently typed value into Value")
		}

atomic.Pointer[T] deletes all of that. There is no sentinel, no CAS loop, no preemption dance, and no possibility of a type mismatch, because the type is in the static type of the variable. atomic.Value is still there for the case where you genuinely need to swap between different concrete types, but for “publish an immutable snapshot of a thing that never changes type”, atomic.Pointer[T] is strictly better.

Down to the metal: what the compiler emits

sync/atomic is a thin shell. On amd64, every function in src/sync/atomic/asm.s is a jump into the runtime’s internal atomic package:

asm
// src/sync/atomic/asm.s:24
TEXT ·CompareAndSwapInt32(SB),NOSPLIT,$0
	JMP	internal∕runtime∕atomic·Cas(SB)

TEXT ·CompareAndSwapUint32(SB),NOSPLIT,$0
	JMP	internal∕runtime∕atomic·Cas(SB)

TEXT ·CompareAndSwapUintptr(SB),NOSPLIT,$0
	JMP	internal∕runtime∕atomic·Casuintptr(SB)

TEXT ·CompareAndSwapInt64(SB),NOSPLIT,$0
	JMP	internal∕runtime∕atomic·Cas64(SB)

TEXT ·CompareAndSwapUint64(SB),NOSPLIT,$0
	JMP	internal∕runtime∕atomic·Cas64(SB)

But that assembly is the fallback. The compiler knows about these functions and replaces them with intrinsics before it ever gets to the assembler. From src/internal/runtime/atomic/doc.go:

go
// src/internal/runtime/atomic/doc.go:5
/*
Package atomic provides atomic operations, independent of sync/atomic,
to the runtime.

On most platforms, the compiler is aware of the functions defined
in this package, and they're replaced with platform-specific intrinsics.
On other platforms, generic implementations are made available.

Unless otherwise noted, operations defined in this package are sequentially
consistent across threads with respect to the values they manipulate. More
specifically, operations that happen in a specific order on one thread,
will always be observed to happen in exactly that order by another thread.
*/

The intrinsic table in src/cmd/compile/internal/ssagen/intrinsics.go maps each of those functions to an SSA opcode:

go
// src/cmd/compile/internal/ssagen/intrinsics.go:523
	addF("internal/runtime/atomic", "Cas",
		func(s *state, n *ir.CallExpr, args []*ssa.Value) *ssa.Value {
			v := s.newValue4(ssaop.OpAtomicCompareAndSwap32, types.NewTuple(types.Types[types.TBOOL], types.TypeMem), args[0], args[1], args[2], s.mem())
			s.vars[memVar] = s.newValue1(ssaop.OpSelect1, types.TypeMem, v)
			return s.newValue1(ssaop.OpSelect0, types.Types[types.TBOOL], v)
		},
		sys.AMD64, sys.MIPS, sys.MIPS64, sys.PPC64, sys.RISCV64, sys.S390X)
	addF("internal/runtime/atomic", "Cas64",
		func(s *state, n *ir.CallExpr, args []*ssa.Value) *ssa.Value {
			v := s.newValue4(ssaop.OpAtomicCompareAndSwap64, types.NewTuple(types.Types[types.TBOOL], types.TypeMem), args[0], args[1], args[2], s.mem())
			s.vars[memVar] = s.newValue1(ssaop.OpSelect1, types.TypeMem, v)
			return s.newValue1(ssaop.OpSelect0, types.Types[types.TBOOL], v)
		},
		sys.AMD64, sys.MIPS64, sys.PPC64, sys.RISCV64, sys.S390X)

Then the architecture-specific rewrite rules turn the SSA opcode into a machine instruction. From src/cmd/compile/internal/ssa/_gen/AMD64.rules:

text
// src/cmd/compile/internal/ssa/_gen/AMD64.rules:510
// Atomic loads.  Other than preserving their ordering with respect to other loads, nothing special here.
(AtomicLoad8 ptr mem) => (MOVBatomicload ptr mem)
(AtomicLoad32 ptr mem) => (MOVLatomicload ptr mem)
(AtomicLoad64 ptr mem) => (MOVQatomicload ptr mem)
(AtomicLoadPtr ptr mem) => (MOVQatomicload ptr mem)

// Atomic stores.  We use XCHG to prevent the hardware reordering a subsequent load.
// TODO: most runtime uses of atomic stores don't need that property.  Use normal stores for those?
(AtomicStore8 ptr val mem) => (Select1 (XCHGB <types.NewTuple(typ.UInt8,types.TypeMem)> val ptr mem))
(AtomicStore32 ptr val mem) => (Select1 (XCHGL <types.NewTuple(typ.UInt32,types.TypeMem)> val ptr mem))
(AtomicStore64 ptr val mem) => (Select1 (XCHGQ <types.NewTuple(typ.UInt64,types.TypeMem)> val ptr mem))
(AtomicStorePtrNoWB ptr val mem) => (Select1 (XCHGQ <types.NewTuple(typ.BytePtr,types.TypeMem)> val ptr mem))

// Atomic exchanges.
(AtomicExchange8 ptr val mem) => (XCHGB val ptr mem)
(AtomicExchange32 ptr val mem) => (XCHGL val ptr mem)
(AtomicExchange64 ptr val mem) => (XCHGQ val ptr mem)

// Atomic adds.
(AtomicAdd32 ptr val mem) => (AddTupleFirst32 val (XADDLlock val ptr mem))
(AtomicAdd64 ptr val mem) => (AddTupleFirst64 val (XADDQlock val ptr mem))

Note XCHG for stores. An XCHG with the memory operand is implicitly locked on x86, so it is a full barrier, and it also returns the old value — which is why the same instruction serves as both Store and Swap.

And compare-and-swap:

text
// src/cmd/compile/internal/ssa/_gen/AMD64.rules:552
// Atomic compare and swap.
(AtomicCompareAndSwap32 ptr old new_ mem) => (CMPXCHGLlock ptr old new_ mem)
(AtomicCompareAndSwap64 ptr old new_ mem) => (CMPXCHGQlock ptr old new_ mem)

The lock suffix is what makes these LOCK-prefixed instructions. The runtime’s own hand-written assembly, used when the compiler is not in the picture, spells the same thing out:

asm
// src/internal/runtime/atomic/atomic_amd64.s:30
TEXT ·Cas(SB),NOSPLIT,$0-17
	MOVQ	ptr+0(FP), BX
	MOVL	old+8(FP), AX
	MOVL	new+12(FP), CX
	LOCK
	CMPXCHGL	CX, 0(BX)
	SETEQ	ret+16(FP)
	RET

and the add:

asm
// src/internal/runtime/atomic/atomic_amd64.s:89
TEXT ·Xadd(SB), NOSPLIT, $0-20
	MOVQ	ptr+0(FP), BX
	MOVL	delta+8(FP), AX
	MOVL	AX, CX
	LOCK
	XADDL	AX, 0(BX)
	ADDL	CX, AX
	MOVL	AX, ret+16(FP)
	RET

LOCK CMPXCHGL is the workhorse. XADD returns the old value, so the code adds the delta back to it to produce the “new value” that atomic.Add promises.

There is one more subtlety worth knowing. LOCK CMPXCHG on x86 has always had a stronger memory ordering than the language requires — it is a full sequentially consistent barrier. That is why the runtime has to offer the weaker LoadAcquire/StoreRelease variants separately, and why they are marked “WARNING: Use sparingly and with great care”:

go
// src/internal/runtime/atomic/types.go:197
// LoadAcquire is a partially unsynchronized version
// of Load that relaxes ordering constraints. Other threads
// may observe operations that precede this operation to
// occur after it, but no operation that occurs after it
// on this thread can be observed to occur before it.
//
// WARNING: Use sparingly and with great care.
//
//go:nosplit
func (u *Uint32) LoadAcquire() uint32 {
	return LoadAcq(&u.value)
}

The runtime’s own use: uintptr as a lock-free handle

The uintptr variants exist for a specific reason: the runtime uses them to publish pointers to objects it has already accounted for, where the pointer itself is the last word on liveness.

sync.Pool uses atomic.LoadUintptr and atomic.StoreUintptr to size its victim cache:

go
// src/sync/pool.go:174
	// Try the victim cache. We do this after attempting to steal
	// from all primary caches because we want objects in the
	// victim cache to age out if at all possible.
	size = atomic.LoadUintptr(&p.victimSize)
	if uintptr(pid) >= size {
		return nil
	}
	locals = p.victim
	l := indexLocal(locals, pid)
	if x := l.private; x != nil {
		l.private = nil
		return x
	}
	for i := 0; i < int(size); i++ {
		l := indexLocal(locals, (pid+i)%int(size))
		if x, _ := l.shared.popTail(); x != nil {
			return x
		}
	}

	// Mark the victim cache as empty for future gets don't bother
	// with it.
	atomic.StoreUintptr(&p.victimSize, 0)

No lock, no CAS loop, no atomic.Pointer. Just a size published with a sequentially consistent store, and read with a sequentially consistent load. The ordering guarantee is exactly what is needed: if a goroutine sees victimSize == 3, it is guaranteed to see the victim array contents that were published before that store.

The runtime itself does the same thing for pointers, and has to be careful about the garbage collector. Here is src/runtime/atomic_pointer.go:

go
// src/runtime/atomic_pointer.go:76
// Like above, but implement in terms of sync/atomic's uintptr operations.
// We cannot just call the runtime routines, because the race detector expects
// to be able to intercept the sync/atomic forms but not the runtime forms.

//go:linkname sync_atomic_StoreUintptr sync/atomic.StoreUintptr
func sync_atomic_StoreUintptr(ptr *uintptr, new uintptr)

//go:linkname sync_atomic_StorePointer sync/atomic.StorePointer
//go:nosplit
func sync_atomic_StorePointer(ptr *unsafe.Pointer, new unsafe.Pointer) {
	if writeBarrier.enabled {
		atomicwb(ptr, new)
	}
	if goexperiment.CgoCheck2 {
		cgoCheckPtrWrite(ptr, new)
	}
	sync_atomic_StoreUintptr((*uintptr)(unsafe.Pointer(ptr)), uintptr(new))
}

That comment is a beautiful little lesson in how the pieces fit together. The runtime could use its own atomic.StorepNoWB, which is one instruction cheaper because it skips the write barrier. It doesn’t, because the race detector needs to intercept sync/atomic operations, and sync/atomic is the layer it hooks. The uintptr indirection is how the runtime turns a pointer store into something the detector can see — and the write barrier is inserted by hand because the compiler will not emit one for an unsafe.Pointer stored through a uintptr.

Trade-offs

What sequentially consistent atomics buy you. You never have to reason about acquire/release ordering, fences, or memory barriers. The default is the strongest guarantee the hardware offers, and the compiler will not let you accidentally weaken it in sync/atomic.

What they cost. On x86, a LOCK CMPXCHG is a full barrier: it drains the store buffer and costs on the order of 20–100 cycles on a modern server CPU. On ARM64 it is cheaper in the uncontended case but still a barrier. If you are building a hot counter, that cost is real.

The typed wrappers versus the raw API. The typed wrappers add a field access and a pointer cast, and they cost you the ability to pass a raw address to a function that does not know the type. In exchange you get compile-time checking of the value and vet-time checking of copying. This is a good trade and the standard library made it for you.

atomic.Pointer[T] versus atomic.Value. The generic version is faster (no sentinel, no CAS loop on first store), safer (type errors are compile errors, not runtime panics), and clearer. atomic.Value remains only for the genuinely dynamic case.

Caveats

An atomic operation on one variable does not protect a different variable. The synchronizes-before edge exists only between the two atomic operations that touch the same location. If you atomically publish a pointer and then write through it, the reader must load the pointer atomically and the writer must have published all the pointed-to data before the store. For a single pointer that means: build the object completely, then publish it with a single atomic store.

Sequential consistency has a cost on the writer, not the reader. A reader that does a plain load may still see a stale value; atomicity is about unpredictability, not freshness. If you need freshness, you need the synchronizes-before edge, and that requires the reader to use an atomic load too.

atomic.Uintptr is not a pointer. Converting a pointer to a uintptr and back is not allowed by the unsafe rules, and if you do it anyway, the garbage collector may not see the reference. The uintptr atomics exist for storing handles — offsets, indices, already-accounted-for addresses — not for smuggling pointers past the GC.

64-bit atomics on 32-bit platforms need alignment. If you use the raw atomic.LoadInt64 on a 386, an ARM, or a 32-bit MIPS, you are responsible for the 8-byte alignment. Use atomic.Int64 and the problem disappears.

The compiler’s LOCK prefix is not free. On amd64 the compiler adds the lock prefix to CMPXCHG, XADD, XCHG and the locked OR/AND forms. On some very old or very exotic microcontrollers this is a trap rather than an instruction. The doc.go BUG comment at the top of the package is about exactly this.

Wrapping up

The one thing to remember: sync/atomic is sequentially consistent, full stop. If an atomic write is observed by an atomic read, the write happens before the read, and every atomic operation in the program can be placed in one total order. You do not need to think about memory barriers at this level.

The part people get wrong: the atomicity is on the location, not on the data. An atomic store of a pointer does not make the object it points to visible to anybody who reads that pointer non-atomically, and an atomic increment of a counter does not make the other fields of the struct it lives in safe.

What to go do about it: reach for atomic.Int64, atomic.Bool and atomic.Pointer[T] rather than the raw functions, and if you find yourself writing a compare-and-swap loop by hand, ask whether sync.Map, a channel, or a sync.Mutex would do the same job with less code.

Further reading

  • src/sync/atomic/doc.go — the package contract, including the memory model paragraph and the 32-bit alignment BUG.
  • src/sync/atomic/type.go — the typed wrappers: Bool, Int32, Int64, Uint32, Uint64, Uintptr and the generic Pointer[T].
  • src/sync/atomic/value.go — atomic.Value, the hand-rolled interface header with the firstStoreInProgress sentinel.
  • src/sync/atomic/asm.s — the thin shell that jumps into internal/runtime/atomic.
  • src/sync/atomic/doc_32.go — the 32-bit variants, where the 64-bit functions are not free of caveats.
  • src/internal/runtime/atomic/doc.go — how the compiler replaces these functions with intrinsics.
  • src/internal/runtime/atomic/atomic_amd64.s — the hand-written amd64 LOCK CMPXCHGL and LOCK XADDL implementations.
  • src/internal/runtime/atomic/types.go — the runtime’s own typed atomics, including the weaker LoadAcquire/StoreRelease variants.
  • src/cmd/compile/internal/ssagen/intrinsics.go — the intrinsic table that maps internal/runtime/atomic functions to SSA atomic opcodes.
  • src/cmd/compile/internal/ssa/_gen/AMD64.rules — the amd64 lowering rules that turn those opcodes into LOCK-prefixed instructions.
  • src/runtime/atomic_pointer.go — how the runtime stores pointers atomically while keeping the write barrier and the race detector happy.
  • src/sync/pool.go — the sync.Pool victim cache, a real lock-free use of atomic.LoadUintptr and atomic.StoreUintptr.

GoInternalsAtomicsMemory modelConcurrency