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 66The 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.Mutexguards a critical section. Keep the section short and don't call out to unknown code while holding the lock.defer mu.Unlock()right afterLockkeeps it correct when the function returns early or panics.sync.RWMutexallows many concurrent readers or one writer. It only helps when reads dominate and the critical section is not tiny.sync.WaitGroupwaits for a group of goroutines.wg.Go(f)(Go 1.25) replaces theAdd(1)/go/defer Done()pattern, and the newgo vetcheckwaitgroupreportsAddcalled inside the goroutine, where it is too late.sync.Onceruns initialisation exactly once;sync.OnceValue(Go 1.21) also returns its result.sync/atomictypes (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.Poolreuses 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.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.