Skip to content
gohacks.dev
Concurrency

Atomic Operations in Go and the Typed Atomic Values

Use sync/atomic in Go: Int64, Bool, Pointer and Value types, counters and flags, And and Or, ordering guarantees, and when a mutex is the safer choice.

7 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

A mutex protects a section of code. An atomic protects a single value. When what you need is exactly "increment this counter from many goroutines", "set this flag so others see it", or "swap in the new configuration", a mutex is more machinery than the job needs, and an atomic operation does the whole thing in one indivisible step, usually without ever blocking.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This is the practical tour: the types, the idioms, what the memory-ordering promise is, and the limits. It follows mutexes and leads to compare-and-swap. Everything ran on Go 1.25.6.

Why a plain int is not enough

On the first page about mutexes, a hundred goroutines each incremented a shared int a thousand times and the total came out short. The increment is read, add, write, and the interleaving loses updates. An atomic add is a single hardware operation that cannot be interleaved:

main.goGo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var count atomic.Int64
	var wg sync.WaitGroup

	for range 100 {
		wg.Go(func() {
			for range 1000 {
				count.Add(1)
			}
		})
	}
	wg.Wait()
	fmt.Println(count.Load())
}
Text
100000

It prints 100000 every time, with no lock. atomic.Int64 is a struct type, with methods Add, Load, Store, Swap and CompareAndSwap, plus And and Or for bit operations since Go 1.23. A value must be read with Load and written with Store; reading the field directly is not possible, which is the design: you cannot forget to be atomic.

The typed values

Go 1.19 introduced the typed atomics, and they are the API to use:

TypeFor
atomic.Int32, atomic.Int64signed counters and gauges
atomic.Uint32, atomic.Uint64, atomic.Uintptrunsigned counters, handles
atomic.Boolflags
atomic.Pointer[T]a pointer to a T, for snapshots and linked structures
atomic.Valueany value, as long as it is always the same concrete type

The older package-level functions, atomic.AddInt64(&n, 1), still exist and work on plain integers. They have a trap that the types remove: nothing stops code from also reading n directly, which races. Use the types in new code.

Flags

A boolean that one goroutine sets and others check, a "closed" or "ready" flag, is the simplest case:

main.goGo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

type Server struct {
	shuttingDown atomic.Bool
}

func (s *Server) handle(id int, out chan<- string) {
	if s.shuttingDown.Load() {
		out <- fmt.Sprint("request ", id, ": rejected, shutting down")
		return
	}
	out <- fmt.Sprint("request ", id, ": served")
}

func main() {
	s := &Server{}
	out := make(chan string, 4)

	var wg sync.WaitGroup
	wg.Go(func() { s.handle(1, out) })
	wg.Wait()

	s.shuttingDown.Store(true)
	time.Sleep(time.Millisecond)
	wg.Go(func() { s.handle(2, out) })
	wg.Wait()

	close(out)
	for line := range out {
		fmt.Println(line)
	}
}
Text
request 1: served
request 2: rejected, shutting down

The flag is read and written without a lock. CompareAndSwap(false, true) is the variant that tells you whether you were the one to flip it, which gives an "only one goroutine does this" guard (compare and swap). Prefer context for cancellation that others should wait on; a flag only answers "has it happened", and cannot wake a sleeper.

Swapping a snapshot

atomic.Pointer[T] is the right tool for read-mostly data that is replaced as a whole, like a configuration or a routing table. Readers Load a pointer and use it without any lock; a writer builds a new value and Stores it:

main.goGo
package main

import (
	"fmt"
	"sync/atomic"
)

type Config struct {
	Region  string
	Workers int
}

var cfg atomic.Pointer[Config]

func main() {
	cfg.Store(&Config{Region: "eu", Workers: 4})

	old := cfg.Load()
	fmt.Println("reader sees:", *old)

	cfg.Store(&Config{Region: "us", Workers: 8}) // writer publishes a new snapshot
	fmt.Println("new readers see:", *cfg.Load())
	fmt.Println("an old reader still holds a consistent value:", *old)
}
Text
reader sees: {eu 4}
new readers see: {us 8}
an old reader still holds a consistent value: {eu 4}

The snapshot is immutable once published: nobody modifies a Config in place, they replace the pointer. A reader holding the old pointer keeps a coherent view for as long as it likes, and the garbage collector frees it when the last reader is done. This beats an RWMutex for read-heavy configuration (RWMutex shows the pattern with numbers). Never mutate the pointed-to value after storing it; that reintroduces the race.

atomic.Value

atomic.Value holds any value and was the only option before generics. It has two rules that its documentation states and that the runtime enforces: the first Store fixes the concrete type, and every later Store must use the same type, or it panics; and you cannot store nil:

main.goGo
package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	defer func() { fmt.Println("recovered:", recover()) }()

	var v atomic.Value
	v.Store("hello")
	fmt.Println(v.Load().(string))

	v.Store(42) // a different concrete type: panics
}
Text
hello
recovered: sync/atomic: store of inconsistently typed value into Value

The panic is sync/atomic: store of inconsistently typed value into Value. atomic.Pointer[T] is preferable, since the type is part of the declaration and the compiler enforces it.

What the ordering promise is

An atomic operation is not just indivisible; it also orders memory. In Go, atomic operations are sequentially consistent: if one goroutine's Store is observed by another's Load, then everything the first goroutine did before the store is visible to the second after the load. The Go memory model states it directly. That is why publishing through an atomic pointer works: the writer fills in the new struct, then stores the pointer, and any reader that sees the pointer sees the filled-in struct (the memory model). You do not need to reason about fences. You do need to remember that the guarantee is about the atomic operations and what precedes them: reading non-atomic fields that another goroutine writes, without such an ordering, is still a race.

Limits: one value at a time

Atomics make one variable safe. They do not make a pair of variables consistent:

main.goGo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var deposits, total atomic.Int64
	var wg sync.WaitGroup

	for range 1000 {
		wg.Go(func() {
			deposits.Add(1) // two separate atomic steps
			total.Add(10)
		})
	}

	var inconsistent atomic.Int64
	for range 10 {
		wg.Go(func() {
			for range 1000 {
				d, t := deposits.Load(), total.Load() // may read between the two Adds
				if t != d*10 {
					inconsistent.Add(1)
				}
			}
		})
	}
	wg.Wait()
	fmt.Println("final totals agree:", deposits.Load()*10 == total.Load())
	fmt.Println("readers saw a mismatch at some point:", inconsistent.Load() > 0)
}
Text
final totals agree: true
readers saw a mismatch at some point: true

Each variable is updated atomically, and a reader that samples both can observe deposits incremented and total not yet, so the invariant total == deposits*10 fails transiently. The final totals agree, because every add completes, but the intermediate view is inconsistent. If readers need the pair to be consistent, protect both with one mutex, or combine them into one immutable struct behind an atomic.Pointer. This is the main reason to prefer a mutex when in doubt: it protects invariants, and an atomic protects variables (mutexes).

Counters at scale

An atomic counter is fast, and when many cores hammer the same one it is no longer fast: every Add writes the same cache line, so the cores queue for it. The mutex benchmark in the mutex guide measured 59 ns per atomic increment with ten cores contending, against 111 ns for a mutex. If a hot counter dominates a profile, shard it: keep one counter per goroutine or per CPU and sum them on read (concurrent counters). For metrics, a library that does this for you is usually the answer.

When to reach for an atomic

Use an atomic for a counter or gauge that is only incremented and read, for a flag that goes from false to true once, for publishing an immutable snapshot, and for one-time initialisation checks. Use a mutex for anything involving two variables, a map or a slice, or a read-modify-write that is not a plain add. Use a channel when the event is a handoff. And do not use atomics as a trick to avoid a lock in a design that is wrong: if you cannot explain what each atomic protects, a mutex is the honest version. The package is documented in sync/atomic, and the guarantees are specified in the Go memory model.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.