Deep Engineering
Advanced·Published·30 MIN

sync and atomic in Go: two pieces of advice that fall apart under measurement

The interview climbs a ladder: what is wrong with a plain increment — how atomic differs from a mutex — when to reach for an RWMutex — what Once does — why go vet catches a copied mutex. Measured: without contention a mutex is twice as dear as an atomic, but as contenders grow it becomes 9.6 times dearer while the atomic barely moves; and on a short section an RWMutex loses to a plain Mutex.

Full technical treatment

TL;DR

A plain n++ from several goroutines loses updates, because it is not one action but three: read, add, write. Two goroutines manage to read the same number, both add one, both write — and one of the additions disappears. Synchronisation exists not for speed but to stop that happening.

Hence the main consequence: the loss does not happen every time — and that is worse than if it always did. The code "works" until the load changes: in one run, eight goroutines of 10,000 increments produced exactly 80,000, meaning nothing was lost at all. And hence, too, the division of labour worth stating out loud: a mutex protects the integrity of a region of code — any number of fields and any logic between them; an atomic operation fits where the state is bounded and changes in one indivisible action.

Beyond that come the numbers, and they overturn two common pieces of advice. The guarantees, meanwhile, come not from the word "lock" but from the memory model: happens-before — which is why "I put a mutex on it, so it is safe" holds only if all access goes through it. Without contention a mutex is twice as dear as an atomic — 13.35 ns against 6.49; but as contenders grow from 2 to 16 the mutex becomes 9.6 times dearer while the atomic becomes 1.0: the mutex parks the loser, the atomic operation retries. "Use an RWMutex for reads" is wrong on a short section: with an empty section RLock loses to Lock (98.78 against 24.01 ns), and the gain appears only on a long one — 671.01 against 1463.55. sync.Once is free after the first call — 1.87 ns, within noise of doing nothing at all. And a copy of a struct holding a mutex is a different mutex; go vet catches it with the copylocks check, which already runs inside go test. The numbers were taken on one machine and one version of Go: what is stable is the directions and the ratios, not the nanoseconds themselves.

Where to start
Before this lesson it is enough to understand
  • several goroutines can work with the same data at the same time;
  • a variable lives in shared memory: it is visible to more than the goroutine that created it;
  • n++ looks like a single action in the code — and half the mistakes in this topic rest on that impression.
You do not need to know in advance
  • what happens-before is and what the Go memory model says about it;
  • Mutex, RWMutex, atomic, Once, WaitGroup;
  • the copylocks check in go vet, and cache lines.

What is really being asked

The ladder is almost always this one:

  1. "What is wrong with n++ across goroutines?" — the warm-up.
  2. "How does atomic differ from a mutex?" — here people start with "it is faster", which is half an answer.
  3. "Which is faster, and by how much?" — a trap: the right answer starts with a question back, "at what contention?"
  4. "When do you use an RWMutex?" — another trap: "when there are many reads" is wrong.
  5. "Why sync.Once if a flag would do?" — about happens-before, not speed.
  6. "Why must a mutex not be copied?" — and what catches it.

The lesson climbs that ladder. Its spine is one sentence: synchronisation is about guarantees rather than speed; and speed depends on contention rather than on the primitive.

Base: a data race — and why n++ is not one action

The situation the whole topic grows from looks harmless: two goroutines work with the same variable, and at least one of them changes it. That is what a data race is.

It seems nothing can go wrong: an increment is one line. But one line in the source says nothing about indivisibility. n++ is three actions:

  1. read the current value from memory;
  2. add one to it;
  3. write the result back.

Between any two of them another goroutine manages to do exactly the same. Then both read the same number, both add one, both write the result — and instead of two additions there is one. An update has disappeared, and nobody reported it: no error, no panic, not a line in the log.

That is how it looks in a run. Eight goroutines, 10,000 increments each, of a plain variable:

GO
n := 0
// eight goroutines: for i := 0; i < 10000; i++ { n++ }

A run of bench/gosync/practice.go reports that the total is less than 80,000. The same counter done atomically gives exactly 80,000.

A caveat about this measurement, and it matters more than the measurement. In the script the read and the write are separated explicitly, with a runtime.Gosched() between them. That was done not for clarity but because the first draft wrote a plain n++ and one of the runs produced exactly 80,000: no loss occurred. That does not disprove the race — it is the race's defining property: it does not show up every time. Which is exactly why such bugs are not found by tests: the code "works" until the load, the Go version or the core count changes.

And here is the question this lesson is about: what has to be added to the code so that one goroutine's three actions do not interleave with another's? In general terms: a place where goroutines agree on an order — and Go has several of those. They differ not in speed but in how big a piece of work each of them can make indivisible.

That is already enough to answer the basic interview question, "what is wrong with n++ across goroutines". What follows is what actually closes that gap: first the mutex, then the read-write lock, atomic operations, run-once initialisation and, at the very end, the price paid not to the scheduler but to the hardware.

Mechanism 1: the guarantee comes from happens-before, not from the lock

The place to start is not the tool but where the word "safe" comes from at all. Not from the word "lock" but from the memory model:

For any sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() is synchronized before call m of l.Lock() returns.

The Go Memory Model
language contractThe Go memory model. Everything below about nanoseconds is a different kind of claim, and it starts at Mechanism 3.

Hence a practical consequence worth stating out loud: a mutex protects an invariant, not a variable. The difference is not a matter of words.

"Protects a variable" suggests it is enough to take the lock around every access to a field. That is not enough: if a struct has two fields that must stay consistent — a length and its contents, a balance and its ledger — then the lock must cover the whole operation that moves them from one consistent state to another.

GO
// protects a variable — and does not work
mu.Lock(); n := len(items); mu.Unlock()
mu.Lock(); v := items[n-1]; mu.Unlock()   // items is already different by now
 
// protects an invariant
mu.Lock()
v := items[len(items)-1]
mu.Unlock()

And the other half of the same point: if even one goroutine reads the field outside the lock, there is no guarantee at all — the compiler and the processor are free to reorder anything, and -race will find it.

Which edge each primitive creates

The rest of the lesson covers five different tools, and they all do one thing: they create a happens-before edge between two points in different goroutines. They differ only in which points.

primitivethe edge: what comes "before"what comes "after"
MutexUnlock()the return from the next Lock()
RWMutexUnlock()the return from the next RLock()
atomica writea read that saw that write
Oncethe completion of f()the return from any Do(f)
WaitGroupthe Done() callsthe return from Wait()
a channela sendthe completion of the receive

That table is the whole lesson in compressed form. Everything else explains how those edges differ in price and in what they can be used to protect.

Mechanism 2: atomic is not "a fast mutex"

The difference is not speed but what exactly is protected.

An atomic operation protects one cell and performs one indivisible action on it: add, swap, compare-and-swap. It covers neither two fields nor an invariant between them.

A mutex protects a region of code — any number of fields and any logic between them.

In one sentence, worth keeping in mind for the whole lesson: a mutex protects the integrity of a region of code, while atomic operations fit bounded state that is updated by a single indivisible operation.

Hence the rule for choosing, and it is not about performance: an atomic fits exactly when the protected state is one machine word. A counter, a flag, a pointer to an immutable snapshot. As soon as it becomes "read A, decide, write B", it is a mutex, and no atomicity substitutes for it.

The package talks you out of itself:

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.

The sync/atomic package

Separately about the notation: since Go 1.19 there are typesatomic.Int64, atomic.Bool, atomic.Pointer[T]. They are preferable to the functions (atomic.AddInt64(&x, 1)) for two reasons: a type cannot be read non-atomically by accident, and it solves alignment on 32-bit platforms, where an int64 needs 8-byte alignment.

Mechanism 3: "which is faster" is an incomplete question

Here is the measurement this lesson was written for.

Without contention a mutex is twice as dear as an atomic: 13.35 ns against 6.49. That is an honest number, and it is usually where the conversation ends.

But the question is about contention. One counter, varying goroutines:

goroutinesmutexatomicratio
222.45 ns24.01 ns0.93
494.7826.003.64
8135.2726.125.18
16215.4224.788.69
growth×9.6×1.0
measured observationbench/gosync, go1.24.7 linux/amd64, two cores. The numbers were taken on one machine and one version of Go; what is stable is the direction of both columns — the mutex grows with contention, the atomic operation barely does — rather than the ratios themselves.

Look not at individual cells — they are noisy — but at the last row. The mutex grows nearly an order of magnitude with contention, while the atomic barely grows. The reason is parking: a mutex takes the losing goroutine off the CPU and the cost of waiting enters the measurement; an atomic operation parks nobody and simply retries.

What this does not mean. It does not mean an atomic is free under load: the cell is shared by everyone, and the cache line under it travels between cores. The price is simply paid by the bus rather than by the scheduler.

And the main practical point: if sixteen goroutines fight over one cell, the primitive is no longer the issue. The cheapest fix is removing the contention itself — a counter per goroutine summed at the end, sharding by key, local accumulation. Swapping a mutex for an atomic is an optimisation within one order of magnitude; removing contention is within two.

And the boundary of these numbers is named right here: they were taken on two cores and one version of Go. What travels from here is not "9.6" or "8.7" but the cause: the mutex pays by parking the loser, the atomic operation pays by agreement between cores. A number of your own, if a decision needs one, is measured on your own load and your own hardware rather than taken from somebody else's table.

Mechanism 4: an RWMutex is not a free replacement for a Mutex

"Many reads, so use an RWMutex" sounds obvious and does not survive checking. Before looking at the numbers it is worth naming what the answer actually depends on — otherwise the table looks like a refutation of common sense.

The choice between Mutex and RWMutex is settled by three quantities, and none of them is "how many readers there are in the code":

quantitywhat it meansfavours
section lengthhow long is spent under the locklong — RWMutex
read fractionwhat share of entries only readhigh — RWMutex
contentionhow many goroutines want the lock at onceheavy — RWMutex

The trap is that RLock is dearer than Lock: it counts readers and checks whether a writer is waiting. That surcharge is always paid, while the gain from parallel readers only appears when there is something to parallelise — that is, when the section is long enough.

measured observationbench/gosync, go1.24.7 linux/amd64, two cores. On a two-core machine contention is weak, so the RWMutex column here is in unfavourable conditions: what is stable is the crossover, not its position.
work under the lockRWMutexMutexfaster
none98.78 ns24.01 nsMutex
10 iterations52.8824.57Mutex
100 iterations164.64113.40Mutex
1000 iterations671.011463.55RWMutex

The reason is simple: RLock does more work than Lock — it counts readers and checks whether a writer is waiting. On a short section that difference is the whole operation, and parallel readers do not have time to repay it. The gain appears only once enough time is spent under the lock.

What is stable here is the crossover itself, not where exactly it falls: the boundary depends on the machine and its core count.

Two more things worth knowing about RWMutex:

  • It is not recursive. Taking RLock twice in one goroutine is allowed, but if a Lock from another goroutine wedges in between, the second RLock blocks and you have a deadlock — the documentation warns about this outright.
  • Writers do not starve for exactly that reason: a waiting Lock blocks new readers.

Mechanism 5: Once, WaitGroup, and the things that must not be copied

sync.Once is free after the first call. Measured: 1.87 ns against 1.92 for a hand-rolled atomic check and 2.23 for doing nothing at all — all three within noise of each other. So there is nothing to gain by replacing Once with a manual flag, while it is easy to get the manual version wrong: a second goroutine wedges in between the check and the set.

And Once gives what a manual flag does not give by itself:

For any call to once.Do(f), f is synchronized before the return from any call of once.Do(f).

The Go Memory Model

Read that as: a goroutine that sees the initialisation already done is guaranteed to see its result. A manual flag without atomic operations gives no such guarantee — and that is a correctness bug, not a performance one.

WaitGroup: two rules. Add is called before launching the goroutine, not inside it — otherwise Wait may get there first. And the counter must not go negative: an extra Done is a panic.

And the things that must not be copied. Mutex, RWMutex, Once and WaitGroup all carry the same sentence in their documentation:

A Mutex must not be copied after first use.

The sync package

The reason is that a copy does not inherit state: a copy of a locked mutex arrives free, and two goroutines enter the critical section at once. The run shows it: a struct with a mutex, copied by assignment, then lives its own life — 1 for the original and 2 for the copy.

go vet catches this with the copylocks check — "check for locks erroneously passed by value". It is in the set go test runs by default, and the message reads passes lock by value. The three most common places: taking a struct by value, a value receiver on a method, and assignment.

Deeper: cache lines — why parallelism is not free

There is one more reason "just put an atomic on every field" does not give the expected speed-up, and it lives below the level of the language — in the hardware.

A processor does not work with bytes but with cache lines — blocks of 64 bytes. If two counters updated by different cores land in the same line, every write by one invalidates the line for the other, and it is shuttled between cores:

This is called false sharing, and its symptom is recognisable: cores were added and nothing got faster — sometimes it got slower. The cure is separating them into different lines (the standard library has padding fields for this) or having each core accumulate its own counter and summing them at the end.

Practically, one thing is needed from this: atomic removes the lock but does not make the write free. A happens-before edge still requires agreement between cores — just through the cache-coherence protocol rather than through the scheduler.

And this section has the same kind of boundary as the numbers above. Sixty-four bytes is a property of the processor rather than of Go, and on another machine the line size may differ. So the rule is written from the symptom, not from the number: if adding cores does not speed things up, separate the counters — whatever the line size on that particular machine turns out to be.

How to answer in an interview

Short answer: n++ is three actions, not one — read, add, write; two goroutines wedge in between each other's steps and an update is lost. From there the choice is simple: a mutex protects the integrity of a region of code — any number of fields and any logic between them — while an atomic operation fits bounded state updated by a single indivisible operation: a counter, a flag, a pointer. Neither of them buys speed; both buy a guarantee that one goroutine sees another's work whole rather than half-done.

That is enough for a correct answer. What follows is what you add when the interviewer digs.

If the interviewer digs deeper

To "what is wrong with n++" answer with three operations. "Read, add, write; two updates merge into one and the total comes out short — eight goroutines of 10,000 gave me less than 80,000."

On atomic and mutex, talk about the scope of protection, not speed. "An atomic protects one cell and one action; a mutex a region of code. As soon as you must read one thing and write another, an atomic does not fit regardless of speed."

To "which is faster" ask back. "At what contention? Without it a mutex is twice as dear; with sixteen goroutines on one counter it is nearly an order of magnitude, because it parks the loser. And if the contention is that high, that is what to fix, not the primitive."

On RWMutex, talk about section length. "RLock is dearer than Lock, so on a short section the RWMutex loses; the gain appears once enough time is spent under the lock."

On Once, name the guarantee rather than the price. "It gives happens-before: whoever sees the flag sees the initialisation result too. Its fast path is free anyway."

On copying, name the tool. "A copy of a mutex arrives unlocked; go vet catches it with copylocks, which already runs in go test."

Next they ask

Next they ask

What is sync.Map and when is it needed?

Short answer

A map with built-in synchronisation, built for two narrow cases: an entry for a key is written once and read many times (a cache that only grows), or different goroutines work with disjoint sets of keys.

Outside those two it usually loses to a plain map under an RWMutex, and it always gives up type safety: its keys and values are interfaces. Its own documentation advises a plain map with locking by default — one of the rare cases where the right interview answer is written down in the docs.

Next they ask

Why sync.Pool if there is a garbage collector?

Short answer

To reuse objects that are expensive to create — buffers, slices, parsers — and take some work off the allocator. The classic use is a bytes.Buffer in an HTTP handler.

Two things to know about it. First: the pool's contents are cleared on garbage collection, so it is neither a cache nor a connection pool — nothing may be counted on surviving. Second: an object comes out of the pool in the state it was returned in, and resetting it is the taker's job. A forgotten Reset is the source of the nastiest bugs, where one user's data ends up in another user's response.

Next they ask

How does atomic.Value differ from atomic.Pointer?

Short answer

atomic.Value came first and stores an any, so it checks the type at run time: putting values of different types into it panics. atomic.Pointer[T] arrived in Go 1.19, is typed and is checked by the compiler.

In practice, publishing a configuration snapshot today uses atomic.Pointer[Config]: it is both safer and free of boxing the value into an interface. atomic.Value remains in older code and where the type genuinely is not known at compile time.

Next they ask

Is a mutex fair? Who gets the lock next?

Short answer

sync.Mutex has two modes. In the normal one whoever gets lucky takes it — and that is fast, because a goroutine already on the CPU does not pay for a switch. But if a goroutine has waited longer than a millisecond, the mutex enters starvation mode: the lock is handed directly to the first in the queue, and new contenders join the queue rather than trying to snatch it.

Hence the practical part: you cannot build logic on the order of acquisition, but you need not fear indefinite starvation either — the runtime cures it itself. And the boundary: the two modes and the one-millisecond threshold are the current implementation of sync.Mutex (checked on go1.24.7) rather than a promise of the language; the language guarantees no acquisition order at all, which is exactly why nothing is built on it.

Next they ask

How do you limit the number of concurrent operations?

Short answer

With a buffered channel as a semaphore — that is the idiom:

GO
sem := make(chan struct{}, 10)
for _, job := range jobs {
    sem <- struct{}{}
    go func(j Job) {
        defer func() { <-sem }()
        handle(j)
    }(job)
}

The channel's capacity is the concurrency limit. The alternatives are golang.org/x/sync/semaphore with weights, if the operations differ in "heaviness", or errgroup.SetLimit if you also want the first error collected. What not to do is roll your own counter on an atomic: waiting for a slot to free correctly does not work out that way.

Next they ask

What happens on a second Unlock?

Short answer

A panic: sync: unlock of unlocked mutex. A Go mutex counts no nesting levels and remembers no owner — it is not recursive.

Two things follow. First: taking the same mutex twice in one goroutine is a deadlock, not "re-entry". Second: Unlock may be called from a different goroutine than Lock — the language does not forbid it; that is occasionally deliberate, but usually a sign of tangled ownership.

Common misconceptions

Claim

n++ is atomic: it is one operation in the source

Actually

It is three: read, add, write. Two updates merge into one — the run gives less than 80,000 where exactly that was expected. One line in the source says nothing about indivisibility.

Claim

atomic is just a fast mutex

Actually

The difference is not speed but the scope of protection: an atomic covers one cell and one action, a mutex a region of code of any length. "Read A, decide, write B" cannot be replaced by atomicity at all.

Claim

a mutex is twice as dear as an atomic

Actually

That holds only without contention: 13.35 ns against 6.49. As contenders grow from 2 to 16 the mutex becomes 9.6 times dearer while the atomic becomes 1.0, and the ratio reaches 8.7. "Which is faster" has no answer without "at what contention".

Claim

an atomic has no cost under contention

Actually

It has, just a different one. It parks no goroutine, but the cell is shared and the cache line under it travels between cores. If sixteen goroutines fight over one cell, what to fix is the contention — a counter per goroutine, sharding — not the primitive.

Claim

many reads means an RWMutex

Actually

On a short section an RWMutex loses: RLock does more work than Lock. Measured: with an empty section, 98.78 ns against 24.01. The gain appears only on a long one — 671.01 against 1463.55.

Claim

sync.Once is expensive, a flag is better

Actually

After the first call it is free: 1.87 ns — within noise of doing nothing. And more importantly it gives happens-before: whoever sees the flag is guaranteed to see the initialisation result. A manual flag without atomics gives no such guarantee, and that is a correctness bug rather than a performance one.

Claim

a struct with a mutex can be passed by value

Actually

A copy does not inherit state: a copy of a locked mutex arrives free, and the protection vanishes silently. Mutex, RWMutex, Once and WaitGroup all carry the same sentence — must not be copied after first use. go vet catches it with copylocks.

Claim

a mutex protects a variable

Actually

It protects a region of code, and its link to a variable exists only in the author's head. If even one goroutine reads the field outside the lock there are no guarantees — and the memory model states this through happens-before, not through "the variable is protected".

Practice

Two problems. Answer first, then check against the real output: in both, the correct answer is taken from a run of the script rather than assigned.

Practice · predict the output

Will a plain increment lose updates, what will the atomic one give, how many times will the function under sync.Once run, and what happens to a copy of a struct with a mutex inside?
fmt.Println(unsynced())
fmt.Println(syncedAtomic())
fmt.Println(onceCount())
a, b := copiedStruct()
fmt.Println(a, b)

Practice · estimate

One counter, no contention. How many times dearer is mu.Lock/Unlock with an increment than an atomic Add?
times

Knowledge check

Question 1 of 6

Eight goroutines do 10,000 increments each of a plain int variable. What ends up in it?

Sources & further reading

4 SOURCES

  1. The Go Memory Model — SynchronizationOfficial documentation. The foundation the whole topic rests on: the guarantees come not from «locks» but from happens-before relations. On mutexes: «For any sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() is synchronized before call m of l.Lock() returns». On atomics: «The APIs in the sync/atomic package … behave like a sequentially consistent atomic operation». On Once: «For any call to once.Do(f), f is synchronized before the return from any call of once.Do(f)».https://go.dev/ref/mem
  2. The sync package — Mutex, RWMutex, Once, WaitGroupOfficial documentation. The rule half the mistakes grow from: «A Mutex must not be copied after first use» — the same sentence appears on RWMutex, Once and WaitGroup. On RWMutex it is also stated that the lock is not recursive: «If any goroutine calls Lock while the lock is already held by one or more readers, concurrent calls to RLock will block until the writer has acquired (and released) the lock».https://pkg.go.dev/sync
  3. The sync/atomic packageOfficial documentation. The caveat worth being able to quote: «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». And on the types that replaced the functions: «The swap operation, implemented by the SwapT functions, is the atomic equivalent of: old = *addr; *addr = new; return old».https://pkg.go.dev/sync/atomic
  4. go vet — the copylocks checkOfficial documentation. The tool that catches the quietest bug in this topic. The check's description: «check for locks erroneously passed by value». It is part of the set `go test` runs by default — so most people already have it running, and its message `passes lock by value` is worth being able to read.https://pkg.go.dev/cmd/vet