skip to content

The sync and sync/atomic Packages

"Share memory by communicating" is the slogan, but real Go code still locks: a mutex around a shared map, a WaitGroup around a fan-out, Once around lazy init, an atomic around a counter. Interviewers want to see you reach for the cheapest primitive that is still correct.

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

explore

questions

page 1 of 2

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

How do you create a sync.Cond in Go, and what does its Wait method do to c.L?

level: juniorimportance: must knowfreq 32%

basics

~20 s

sync.NewCond(l) builds a *sync.Cond over any sync.Locker, stored in its L field. Wait must be called with c.L already held: it atomically unlocks c.L and parks the goroutine, then re-locks c.L before returning to the caller.

open as a page

Why does `defer mu.Unlock()` at the top of a Go method hold the mutex longer than the data needs?

level: juniorimportance: must knowfreq 62%

basics

~20 s

A deferred unlock runs when the whole function returns, not when a block ends, so encoding, file or network work written after it still holds the mutex. Narrow it with an inner function or an early unlock.

open as a page

What is Go's sync.Map, and why can't several goroutines share a plain map instead?

level: juniorimportance: must knowfreq 55%

basics

~20 s

sync.Map is a map in Go's sync package whose methods — Load, Store, LoadOrStore, Range — are safe to call from many goroutines with no lock of your own. A plain Go map is not: concurrent access with a writer is a data race.

open as a page

Why does a sync.Mutex need no constructor, and what is the standard Lock and Unlock pattern?

level: juniorimportance: must knowfreq 85%

basics

~20 s

A sync.Mutex zero value is already an unlocked, usable lock, so a plain declaration needs no constructor. Idiomatic use is mu.Lock() followed immediately by defer mu.Unlock(), which releases it on every return path, panics included.

open as a page

What does sync.Once guarantee when ten goroutines call the same once.Do(setup) at once?

level: juniorimportance: must knowfreq 60%

basics

~10 s

Exactly one goroutine runs setup. The other nine block inside Do until that call has finished, then return. Every later call to Do on that same sync.Once returns immediately without running setup again.

open as a page

In Go's sync.RWMutex, what is the difference between RLock and Lock?

level: juniorimportance: must knowfreq 72%

basics

~20 s

RLock takes a shared read lock: any number of goroutines can hold it at once, but only while no writer holds the lock. Lock takes an exclusive write lock, so it waits until every reader and every other writer has released.

open as a page

What do sync.WaitGroup's Add, Done and Wait do, and how do they fit together to join goroutines?

level: juniorimportance: must knowfreq 80%

basics

~10 s

A sync.WaitGroup is a counter of outstanding goroutines. Add(n) raises it before you start them, each goroutine calls Done to lower it by one, and Wait blocks the caller until the counter reaches zero.

open as a page

Why must a bytes.Buffer be reset when it is recycled through a sync.Pool, and what breaks if it isn't?

level: middleimportance: must knowfreq 58%

basics

~20 s

sync.Pool hands out objects exactly as the previous user left them. A bytes.Buffer that was never reset still holds the earlier bytes, so the next writer appends to someone else's data — one caller's text leaks into another caller's output.

open as a page

What does sync.Pool's Get return when the pool is empty, and what is the New field for?

level: juniorimportance: should knowfreq 42%

basics

~20 s

sync.Pool.Get removes and returns some value that was previously handed to Put, if one is available. If the pool has nothing to give, it calls the pool's New function and returns that result. When New is nil, Get returns nil instead.

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

In Go, how do sync.Cond.Signal and Broadcast differ, and must c.L be held to call them?

level: middleimportance: should knowfreq 40%

basics

~10 s

Signal wakes at most one goroutine parked in Wait on that sync.Cond; Broadcast wakes every one of them. Neither requires holding c.L, though the state change being announced must itself be made under c.L.

open as a page

How would you split one sync.Mutex-guarded session map into hash-sharded mutexes in Go?

level: middleimportance: should knowfreq 48%

basics

~20 s

Replace the single map and mutex with a fixed array of shards, each holding its own mutex and map. Pick the shard from a hash of the session id, and always work through a pointer so the mutex is never copied.

open as a page

What do sync.Map's LoadOrStore and LoadAndDelete guarantee that a Load followed by a Store cannot?

level: middleimportance: should knowfreq 38%

basics

~20 s

Each is one atomic step on a single key, where a separate Load then Store can interleave. LoadOrStore returns the existing value or installs yours, reporting which; LoadAndDelete removes the key and hands its value to exactly one caller.

open as a page

Why does go vet flag a struct containing a sync.Mutex when it is passed or received by value?

level: middleimportance: should knowfreq 52%

basics

~20 s

Copying the struct copies the mutex, so each copy has its own independent lock and the copies no longer exclude each other. Go vet's copylocks check reports it as passing a lock by value; the fix is pointer receivers and pointer parameters.

open as a page

What happens when a method holding a sync.Mutex calls another method that locks the same mutex?

level: middleimportance: should knowfreq 60%

basics

~20 s

The goroutine blocks forever. A sync.Mutex is not reentrant and records no owner, so a second Lock from the goroutine that already holds it waits for itself. The fix is an unexported helper that assumes the lock is held.

open as a page

Why build a package's shared client behind a sync.Once instead of in an init() function?

level: middleimportance: should knowfreq 48%

basics

~20 s

A sync.Once defers the work to first use, so binaries and tests that never touch the package pay nothing, and the accessor can return a failure to its caller. init() runs at import time and can only panic.

open as a page

Why can't sync.Pool be used as a connection pool or to cap how many resources exist?

level: middleimportance: should knowfreq 46%

basics

~20 s

sync.Pool has no maximum size, never makes a caller wait, and lets the garbage collector discard its contents at any moment without running any cleanup. A resource pool has to cap the count, block when none are free, and close what it drops.

open as a page

Why can a goroutine that takes sync.RWMutex.RLock twice deadlock in Go?

level: middleimportance: should knowfreq 46%

basics

~20 s

Once a goroutine blocks in Lock, sync.RWMutex admits no new readers, so the waiting writer cannot be starved. A nested RLock then queues behind that writer while the writer waits for the outer read lock to be released, and all three block forever.

open as a page

Why does passing a sync.WaitGroup by value into a helper function break the join?

level: middleimportance: should knowfreq 55%

basics

~10 s

Go passes arguments by copy, so a helper taking sync.WaitGroup gets its own counter. Done decrements the copy, the caller's counter never falls, and the caller's Wait blocks forever. Pass *sync.WaitGroup instead.

open as a page

What makes a Go program panic with "sync: negative WaitGroup counter", and how do you avoid it?

level: middleimportance: should knowfreq 42%

basics

~20 s

Done is Add(-1), and the runtime panics the moment a decrement would take a WaitGroup's counter below zero. It means more Done calls ran than Add calls — usually a deferred Done plus a second one on an error path.

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

A Go session store stops scaling as you add cores, with wait time on one sync.Mutex — what do you change?

level: seniorimportance: should knowfreq 44%

basics

~20 s

One lock serialises every request, so extra cores only lengthen the queue behind it. Shrink what runs inside the critical section first, then partition the lock across hashed shards, then publish read-mostly state as a snapshot swapped through an atomic pointer.

open as a page

Your team swapped a map behind a sync.RWMutex for a sync.Map and throughput dropped. Why?

level: seniorimportance: should knowfreq 44%

basics

~20 s

sync.Map is not a general-purpose faster map. Keys and values are any, so every store boxes and every load asserts, and its cheap read path only pays off for the two access patterns it targets. Otherwise a lock-guarded typed map usually wins.

open as a page

A sync.Once initializer panicked on its first call; why does every later caller get an unusable value?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Once.Do treats a panicking function as having returned, so the sync.Once is marked done and no later call re-runs it. Every caller after the panic gets whatever the aborted initializer left behind, usually a nil pointer.

open as a page

Your service swapped sync.Mutex for sync.RWMutex on a hot read path and throughput did not improve. How do you find out why?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Measure first: a block profile shows whether goroutines wait on the lock at all and on which side. Usually the read section is too short for the extra bookkeeping to pay, the write rate keeps shutting readers out, or the lock was never the bottleneck.

open as a page

sync.WaitGroup.Wait never returns and a Go test hangs to its timeout — how do you diagnose it?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Let the go test timeout fire: it dumps every goroutine's stack. Find the one parked in sync.(*WaitGroup).Wait, then read the other stacks — either a Done was never reached, or a counted goroutine is itself blocked and never finishes.

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

When is swapping a whole map through atomic.Pointer[T] better than guarding it with a mutex?

level: middleimportance: nice to knowfreq 32%

basics

~20 s

When reads vastly outnumber writes and readers can tolerate a slightly stale snapshot. Writers build a fresh copy and publish it with one pointer store, so readers take no lock. It stops paying once writes are frequent, since every write copies everything.

open as a page

How do sync.OnceValue and sync.OnceValues differ from a sync.Once beside a package variable?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

sync.OnceFunc, sync.OnceValue and sync.OnceValues wrap once-only execution into a function value. Calling the returned function runs the wrapped function at most once and hands every caller the same memoized result, so no package-level variable is needed.

open as a page

showing 1–30 of 35