arrow_backBACK TO GO FOR BACKEND ENGINEERS — CONCURRENCY, SIMPLICITY AND REAL NUMBERS
Lesson 07Go for Backend Engineers — Concurrency, Simplicity and Real Numbers6 min read

sync & Race Conditions

October 29, 2026

TL;DR

Channels are the default (Lesson 5), but sometimes sharing memory directly is simpler — then you must synchronise. Three tools, measured on Go 1.24 with 4 goroutines hammering one counter: a CHANNEL (one owner goroutine, others send messages — safest, clearest, highest per-op cost); sync.Mutex (Lock/Unlock around a critical section — general-purpose, measured 88.5 ns/op); sync/atomic (lock-free ops on single words like atomic.AddInt64 — fastest for counters/flags, measured 32.6 ns/op, ~2.7x faster than the mutex). How to choose: channel when the data has a natural owner or flows between stages; mutex to guard any critical section (a map, a struct's fields); atomic only for single-word counters/flags where it measurably matters. A DATA RACE — two goroutines touch the same memory, at least one writing, with no synchronisation — is undefined behaviour and a top production bug. Run tests and staging with the RACE DETECTOR: go test -race, go run -race. It prints the exact two stacks that collided. Use it routinely; it is the most valuable tool for concurrent Go.

The last two lessons pushed channels as the default way to coordinate goroutines — “share memory by communicating.” But sometimes directly sharing a piece of memory is genuinely simpler: a counter, a cache map, a few fields on a struct that several goroutines update. When you do that, you must synchronise access, or you get a data race. This lesson is the three tools for it, measured against each other, plus the detector that catches the races you inevitably write.

Three Tools for Shared State

There are three ways to make concurrent access to shared state safe, and they trade clarity against speed. I measured the two lock-based ones under real contention — 4 goroutines each doing 5,000,000 increments on one counter, on Go 1.24, 4 cores.

Protecting shared state: three tools, measured on Go 1.24 with 4 goroutines times 5 million increments. Channel: one owner goroutine, others send messages; safest, clearest; best when data has a natural owner; cost higher per-op. sync.Mutex: Lock() slash Unlock() around the shared data; general-purpose; protects any critical section, any size; measured 88.5 ns per op. atomic: atomic.AddInt64 etc; lock-free, single words; fastest for simple counters and flags; no critical section; measured 32.6 ns per op. atomic is about 2.7 times faster than mutex for a single counter under contention. How to choose and the detector that keeps you honest: reach for a CHANNEL when the data has a clear owner or flows between stages — it is the Go-idiomatic default and the easiest to reason about; use a MUTEX to guard a critical section, a map or a struct's fields — correct and obvious, slightly slower; use ATOMIC only for single-word counters or flags where it measurably matters, it is fastest but easiest to get subtly wrong; measure before micro-optimising. A data race — two goroutines touch the same memory, one writing, no synchronisation — is undefined behaviour; run tests and staging with the RACE DETECTOR, go test -race, go run -race, and it reports the exact two stacks that collided; it is the single most valuable tool for concurrent Go; use it routinely.

Channel — the Go-idiomatic default (Lesson 5). One goroutine owns the state, and other goroutines send it messages to read or mutate it. Nothing is ever shared directly, so there is no race by construction. It is the safest and clearest, especially when the data has a natural owner or flows between stages. The cost is higher per operation (a channel send/receive does more work than a lock), so for a tight hot loop it may be overkill — but for most coordination it is the right call.

sync.Mutex — the general-purpose lock. You Lock() before touching the shared data and Unlock() after (usually defer mu.Unlock()), creating a critical section that only one goroutine runs at a time:

type Counter struct {
    mu sync.Mutex
    n  int64
}
func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++
}

A mutex can protect anything — a single int, a whole map, several fields updated together — which is its strength: it is the one tool that scales from a counter to a complex multi-field invariant. Measured: 88.5 ns per increment under 4-way contention. (There is also sync.RWMutex, which lets many readers in at once but only one writer — worth it when reads vastly outnumber writes.)

sync/atomic — lock-free operations on single words (an int64, a pointer, a bool). atomic.AddInt64(&c.n, 1) increments without any lock, using a single CPU instruction with the right memory ordering:

var n int64
atomic.AddInt64(&n, 1)        // safe, lock-free
cur := atomic.LoadInt64(&n)

It is the fastest option — measured: 32.6 ns per increment, about 2.7× faster than the mutex — but it only works for single-word values and simple operations (add, load, store, compare-and-swap). You cannot atomically update two fields together or a whole map. (The atomic.Int64, atomic.Bool etc. types added in Go 1.19 are the nicer modern API.)

So the measured ranking under contention is: atomic (32.6 ns) < mutex (88.5 ns) < channel (higher still). But raw speed is the last thing to optimise for, not the first.

How to Choose

Pick the simplest tool that fits, not the fastest:

  • Channel when the data has a clear owner or flows between stages/goroutines. This is the default — it keeps you in the “communicate, don’t share” model, and it is the easiest to reason about and least likely to deadlock subtly. Most coordination should be here.
  • Mutex when you need to guard a critical section — a shared map, a cache, a struct whose several fields must change together consistently. It is correct, obvious, and general. Slightly slower than atomic, but clarity wins. Reach for a mutex the moment “one goroutine owns it” stops being natural.
  • Atomic only for single-word counters and flags on a genuine hot path where the 2–3× matters and you have measured that it matters. It is the fastest but the easiest to get subtly wrong (it is easy to make each operation atomic yet have a race between operations). Do not reach for atomics by default to be clever — the mutex is almost always clear enough and the difference rarely shows up outside a tight loop.

The through-line: measure before micro-optimising. The 2.7× gap between atomic and mutex sounds dramatic, but 88.5 ns vs 32.6 ns per op only matters when you are doing tens of millions of these per second on a proven hot path. For the counter you bump a few thousand times a second, use whatever is clearest — the performance is identical in practice, and clear code has fewer bugs.

Data Races and the Race Detector

A data race is the bug all of this exists to prevent. The precise definition: two goroutines access the same memory location, at least one of them is writing, and there is no synchronisation between them. When that happens, the behaviour is undefined — not “you get an old value,” but genuinely undefined: torn reads, a value that is neither the old nor the new one, corrupted data structures, crashes that reproduce once a week. Data races are among the nastiest production bugs precisely because they are timing-dependent and often invisible in testing.

The good news is that Go ships a tool that finds them for you: the race detector. You enable it with a flag:

go test -race ./...
go run -race ./cmd/server
go build -race        # a race-instrumented binary for staging

With -race, the runtime instruments every memory access and reports the exact two goroutine stacks that collided — which variable, which goroutine wrote, which read, and where each was in the code. It turns a maddening heisenbug into a precise, actionable report. It does slow the program down (several times slower, more memory), so you do not run it in production — but you run it routinely in tests and on staging. Make go test -race part of CI. It is, without exaggeration, the single most valuable tool for writing correct concurrent Go — it catches the races you will write, because everyone writes them.

One common race worth calling out: Go maps are not safe for concurrent writes. Two goroutines writing the same map (even different keys) is a race, and the runtime will often panic outright with “concurrent map writes.” Guard a shared map with a sync.Mutex, or use sync.Map for the specific read-heavy cases it is designed for. This one bites nearly every Go newcomer once.

Put together: prefer channels, use a mutex when you need to guard a section, reach for atomics only on a measured hot path, and run everything under -race. Do that and concurrent Go is far safer than the thread-and-lock code you may be used to — not because races are impossible, but because the language hands you a tool that finds them.

Pitfalls to Avoid

  1. Sharing memory with no synchronisation at all. The fundamental bug: two goroutines touching the same variable, one writing, no mutex/atomic/channel. Undefined behaviour. Synchronise every shared access — and run -race to be sure you did.
  2. Concurrent map access. Writing a shared map from multiple goroutines races and often panics (“concurrent map writes”). Guard it with a mutex, or use sync.Map where it fits. Reads concurrent with writes are also races.
  3. Forgetting defer mu.Unlock(). Locking, then returning early on an error path without unlocking — the mutex stays locked forever and every other goroutine deadlocks. defer mu.Unlock() right after Lock().
  4. Atomic per-operation, race between operations. atomic.Load then compute then atomic.Store is three safe ops with a race in the gap — another goroutine can change the value in between. For read-modify-write use atomic.Add or compare-and-swap, or a mutex around the whole section.
  5. Copying a sync.Mutex (or a struct containing one). A mutex must not be copied after use — passing a struct-with-mutex by value copies the lock and breaks it. Hold such structs by pointer. (go vet catches this.)
  6. Not running the race detector. “It works on my machine” with no -race in CI means you are shipping races you haven’t seen yet. Add go test -race to CI; run staging with a race build periodically. The races are there whether or not you’ve hit them.

Quick Recap

  • When you share memory directly, synchronise with one of three tools. Measured (4 goroutines × 5M increments, Go 1.24): channel (safest/clearest, highest per-op cost) · sync.Mutex (general-purpose critical sections, 88.5 ns/op) · sync/atomic (lock-free single words, 32.6 ns/op, ~2.7× faster than the mutex).
  • Choose by clarity, not speed: channel when data has an owner/flows; mutex to guard a section (map, multi-field struct); atomic only for single-word counters/flags on a measured hot path. Measure before micro-optimising.
  • A data race (two goroutines, same memory, one writing, no sync) is undefined behaviour — a top production bug. Go maps aren’t safe for concurrent writes (guard with a mutex).
  • Run the race detector routinely — go test -race, go run -race — it prints the exact colliding stacks. Put it in CI. It’s the most valuable tool for concurrent Go.

Next lesson: putting it to work — building an HTTP service with the standard library, where one-goroutine-per-request and context all come together.