Go path · Many things at once · Topic 11 of 13

The sync package and the race detector

Not started

Channels are not the only tool. The sync and sync/atomic packages provide the lower-level pieces: mutexes, wait groups, Once, pools and atomic values. The Go memory model says when one goroutine is guaranteed to see another's writes, and these types are how you create those guarantees. When you miss one, you have a **data race**, and the race detector built into the toolchain is the way to find it.

Why it matters: A data race in Go is undefined behaviour waiting to happen: wrong totals, torn values, crashed maps. The race detector finds them while tests run, if you ask it to.

  1. 1Mutex for shared state

    Lock, change the data, unlock. sync.RWMutex lets many readers in at once when writes are rare.

  2. 2WaitGroup to wait for goroutines

    Count goroutines in and out, then Wait. Go 1.25 added wg.Go(f), which does the counting for you.

  3. 3Atomics for single values

    sync/atomic types such as atomic.Int64 update one value without a lock and make the update visible to other goroutines.

  4. 4go test -race

    Builds the program with instrumentation that reports two goroutines touching the same memory without synchronisation, with both stack traces.

1

How it works

The explanation, with examples you can run.

A race, found

package main import (	"fmt"	"sync") func main() {	var wg sync.WaitGroup	var mu sync.Mutex	racy, safe := 0, 0	for range 1000 {		wg.Go(func() { // Go 1.25+: Add(1), go, Done() in one call			racy++ // data race: unsynchronised read-modify-write			mu.Lock()			safe++			mu.Unlock()		})	}	wg.Wait()	fmt.Println("safe:", safe, "racy:", racy)}

One run on a 16-core machine printed safe: 1000 racy: 958. With GOMAXPROCS=1 it printed 1000 every time in five runs, and even on 16 cores an occasional run came out right: that is how races survive testing. go run -race . reports the problem regardless (output shortened):

WARNING: DATA RACERead at 0x00c0000120f0 by goroutine 11:  main.main.func1()      main.go:14 +0x38Previous write at 0x00c0000120f0 by goroutine 7:  main.main.func1()      main.go:14 +0x48...Found 1 data race(s)exit status 66

The detector instruments every memory access and tracks which accesses are ordered by synchronisation. It only sees races that actually happen in that run, so its value depends on coverage. It costs roughly 2–20× in time and 5–10× in memory, so it is for tests and staging, not production. Use go test -race ./... in CI.

The tools

  • sync.Mutex guards a critical section. Keep the section short and don't call out to unknown code while holding the lock. defer mu.Unlock() right after Lock keeps it correct when the function returns early or panics.
  • sync.RWMutex allows many concurrent readers or one writer. It only helps when reads dominate and the critical section is not tiny.
  • sync.WaitGroup waits for a group of goroutines. wg.Go(f) (Go 1.25) replaces the Add(1)/go/defer Done() pattern, and the new go vet check waitgroup reports Add called inside the goroutine, where it is too late.
  • sync.Once runs initialisation exactly once; sync.OnceValue (Go 1.21) also returns its result.
  • sync/atomic types (atomic.Int64, atomic.Bool, atomic.Pointer[T]) do a single read, write, add or compare-and-swap atomically, with the same visibility guarantee as a lock. Good for counters and flags; anything with two related fields needs a mutex.
  • sync.Pool reuses temporary objects such as buffers to reduce garbage. Anything in a pool may be removed at any time without notice, so it is a cache, not storage.

All of these must not be copied after first use: copying a struct copies its lock state. Pass such structs by pointer; go vet reports most copies.

The memory model, briefly

If a goroutine writes a variable and another reads it, the read is only guaranteed to see the write if something orders them: a channel send before the matching receive completes, an Unlock before the next Lock, a wg.Done before Wait returns, or an atomic operation. Without such an edge the program has a data race. Go's memory model guarantees much less for racy programs, and a race on a map, slice or interface can corrupt memory and crash the process.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.Without -race, a program that does an unsynchronised counter++ from 1,000 goroutines printed 1000. Is it correct?
  2. 2.What does the race detector not find?
  3. 3.Why must a struct containing a sync.Mutex not be copied after use?

0 of 3 right so far. Get all three to finish this topic.