skip to content

sync/atomic and Atomic Types

For counters and flags an atomic is far cheaper than a lock, and Go 1.19's typed atomic.Int64 and atomic.Pointer make it hard to misuse compared with the old function API and its alignment requirement. Interviewers probe the boundary: atomics protect a single word, never the invariant between two fields.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

What does Go's atomic.Int64 give a shared counter that a plain hits++ from many goroutines does not?

level: juniorimportance: must knowfreq 68%

answer

  1. one source line, three machine steps
  2. two goroutines can read the same old value
  3. the total comes out too low, never too high
  4. the whole read-modify-write in one step
  5. methods on a type, unexported value inside

basics

~20 s

atomic.Int64.Add performs the read, the increment and the write as one indivisible step, so no update is lost. A plain hits++ is three separate steps and is a data race: the -race detector reports it and the total comes out too low.

solid answer

~50 s

`hits++` on a shared `int64` is a load, an add and a store. Two goroutines can load the same old value, both add one, and both store the same result, so one increment disappears. That is a data race, and a Go program with a data race has no defined behaviour — `go test -race` will report it. `atomic.Int64` (added with the typed atomics in Go 1.19) wraps the value in a struct whose only access is through methods: `Load`, `Store`, `Add`, `Swap` and `CompareAndSwap`. `hits.Add(1)` performs the whole read-modify-write indivisibly and returns the new value. Its zero value is a ready-to-use zero counter, so `var hits atomic.Int64` needs no constructor, and because the underlying value is unexported nothing can touch it non-atomically by accident. Never copy one after first use — share it through a pointer.

code

go · 15 lines
go
type admission struct {
	inFlight atomic.Int64 // zero value is a working counter at 0
}

func (a *admission) enter() int64 {
	return a.inFlight.Add(1) // Add returns the NEW value
}

func (a *admission) leave() {
	a.inFlight.Add(-1)
}

func (a *admission) depth() int64 {
	return a.inFlight.Load()
}

go deeper

for a junior

Be ready to say why an increment is not one step and what gets lost when two goroutines run it at once. Name atomic.Int64 and its Add, Load and Store methods, and mention building with -race.

for a middle

An interviewer will expect the mechanics: load-add-store, what the race detector instruments, that Add returns the new value, that the zero value is usable, and that the value is unexported so it cannot be touched non-atomically.

for a senior

Show the judgment about scope: an atomic covers one word, so say when you would keep the state that small and when a mutex over several fields is the honest choice. Be ready to explain how a lost-update bug hides in production metrics.

for a principal

Frame it as a design constraint on shared state rather than a coding trick. Choosing atomics commits a component to state that fits in one word; be able to say when that constraint is worth keeping and when it quietly pushes complexity into callers.

## What `hits++` actually is Writing `hits++` on a `var hits int64` looks like one operation in the source, but it is three at the machine level: load the current value into a register, add one, store it back. Two goroutines running on two cores can interleave those steps. Both load 41, both compute 42, both store 42 — and one increment has vanished. Under real load a counter incremented this way reports a number *lower* than the truth, never higher, and the shortfall grows with the number of goroutines. In Go's vocabulary this is a **data race**: two goroutines access the same memory location, at least one of them writes, and nothing orders the two accesses. Go does not define what a program with a data race does. It is not merely "you may lose a count" — the compiler and the hardware are both entitled to assume the variable is not shared, so a racy program can behave in ways no interleaving of the source lines explains. The practical tool is the **race detector**: build or test with `-race` (`go test -race ./...`). It instruments memory accesses and reports races that *actually happen during the run*, with both goroutines' stacks. A clean `-race` run is strong evidence about the paths you exercised and proves nothing about the paths you did not. ## What `atomic.Int64` is Since **Go 1.19**, `sync/atomic` exposes *types* alongside its older functions: `atomic.Int32`, `atomic.Int64`, `atomic.Uint32`, `atomic.Uint64`, `atomic.Bool`, `atomic.Pointer[T]`, plus the older `atomic.Value`. `atomic.Int64` is a struct holding an unexported `int64` with these methods: - `Load() int64` — read the current value atomically. - `Store(v int64)` — write it atomically. - `Add(delta int64) int64` — add and return the **new** value. - `Swap(new int64) int64` — write and return the **old** value. - `CompareAndSwap(old, new int64) bool` — write only if the current value is still `old`; the result says whether the write happened. Three properties matter for everyday use. First, the **zero value is ready**: `var hits atomic.Int64` is a working counter at zero, no constructor and no initialisation step. Second, the value is **unexported**, so there is no way to write `hits++` on it by mistake — every access has to go through a method. That is a real advantage over the older `atomic.AddInt64(&hits, 1)` style, where the variable is a plain `int64` and the very next line in the file can still increment it non-atomically. Third, an `atomic.Int64` **must not be copied after first use**: copying it produces a second, unrelated counter holding a snapshot of the first. Keep it in a struct you always pass by pointer, or share a `*atomic.Int64`. ## Using it on a hot path A typical use is an in-memory admission counter: `Add(1)` on entry, `Add(-1)` on exit, `Load()` when something wants to report the current depth. Because `Add` returns the new value, the increment and the reading of the result are the same operation — there is no window between them for another goroutine to slip in. ```go var inFlight atomic.Int64 n := inFlight.Add(1) // n is this goroutine's view of the new depth defer inFlight.Add(-1) ``` `atomic.Bool` is the same shape without `Add` (`Load`, `Store`, `Swap`, `CompareAndSwap`) and is the idiomatic way to hold a flag such as "we are shutting down, refuse new work". ## What it does not give you An atomic operation covers **one word**. It does not make the surrounding struct safe, and it does not compose: two atomic operations on two variables are two atomic operations, not one. If two pieces of state must always agree with each other — a count and the limit it is checked against, a length and the buffer it indexes — atomics on each of them separately will not hold that invariant. You need either a `sync.Mutex` covering both, or a single pointer swap that publishes both at once. It also does not express a **conditional** update. `Add` is unconditional; "increment only if we are still under the cap" cannot be written as a `Load` followed by an `Add`, because the value can change in between. That is what `CompareAndSwap` and a retry loop are for. ## Atomic or mutex? Both are correct for a counter. A `sync.Mutex` guarding `n++` gives the same result, and it scales up naturally the day the critical section grows to touch a second field. Reach for `atomic.Int64` when the shared state genuinely is one word and every operation on it is a complete update; reach for a mutex as soon as more than one variable has to move together, or the critical section does real work. Choosing atomics is a decision to keep the state that small, not just a decision about how to increment it.

  • Why is a counter under -race worth checking even though the numbers already look plausible?
    Because a lost update is silent. The count is simply low, and nothing crashes, so the bug survives review and staging. `go test -race` instruments memory accesses and reports the two goroutine stacks that raced, turning an invisible arithmetic shortfall into a concrete failure. It only catches races that actually execute during the run, so exercise the concurrent path in the test.
  • When would you still reach for a sync.Mutex rather than atomic.Int64 for shared state?
    As soon as more than one variable has to change together, or the critical section does more than a single word's worth of work. Atomics protect one word at a time and do not compose: incrementing a count and updating a related field are two separate atomic operations, and another goroutine can observe the state in between. A mutex covers the whole update as one region.
  • What goes wrong if you pass an atomic.Int64 to a function by value?
    The callee gets a second, unrelated counter holding a snapshot of the first. Increments inside the function update the copy and are lost when it returns, and readers of the original never see them. Atomic types must not be copied after first use: hold them behind a pointer, or embed them in a struct you always pass as a pointer.

saying these in an interview costs you the question

  • Says hits++ is a single CPU instruction so it is safe
  • Claims the race only matters on multi-core machines
  • Thinks a data race just reorders results, never loses them
  • Assumes atomic.Int64 makes the whole enclosing struct thread-safe
  • Passes an atomic.Int64 by value and expects updates to be shared
  • Says a clean -race run proves the program has no races
open as a page

Why does incrementing an atomic.Int64 only while it stays under a cap need CompareAndSwap rather than Add?

level: middleimportance: should knowfreq 47%

basics

~20 s

Add is unconditional, and a Load followed by an Add leaves a gap in which another goroutine can push the value over the cap. CompareAndSwap writes only if the value is still the one you read, and you retry the whole read-and-decide when it is not.

open as a page

A limiter loads an atomic.Int64 count and an atomic.Pointer[Config] separately — why can the pair disagree?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Each load is atomic by itself, but nothing makes the two happen together. Another goroutine can replace the Config between them, so the count is compared against a limit that never coexisted with it. Publish everything that must agree as one immutable struct behind a single atomic.Pointer.

open as a page

Why could atomic.AddInt64 on a struct field panic on 32-bit builds, and what does atomic.Int64 change?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

On 32-bit platforms a 64-bit atomic operation requires an 8-byte-aligned address, and only the first word of an allocated struct is guaranteed to be aligned, so a later int64 field can panic at run time. The atomic.Int64 type carries that alignment itself, wherever the field sits.

open as a page