Races
The problem
Two goroutines add to the same counter. You get the wrong answer, and a different wrong answer each time.
See It Work: the same program, ten answers
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println("counter:", counter)
}It should print 1000. Run it ten times:
counter: 946
counter: 1000
counter: 973
counter: 981
counter: 1000
counter: 912
...Sometimes correct, usually not, never predictably. This is the worst kind of bug: it passes your tests, works on your laptop, and fails in production under load.
Why
counter++ looks atomic. It isn't. It's three operations:
1. read counter into a register
2. add 1
3. write it backTwo goroutines can interleave:
goroutine A goroutine B counter
─────────────────────────────────────────────────
read (5) 5
read (5) 5
add → 6 5
add → 6 5
write 6 6
write 6 6 ← should be 7Both read 5, both wrote 6. One increment vanished. Do that a thousand times concurrently and you lose a random number of them.
See It Work: the race detector
Go ships a tool that finds these:
go run -race main.go==================
WARNING: DATA RACE
Read at 0x00c000123456 by goroutine 8:
main.main.func1()
/home/you/race/main.go:15 +0x3c
Previous write at 0x00c000123456 by goroutine 7:
main.main.func1()
/home/you/race/main.go:15 +0x50
Goroutine 8 (running) created at:
main.main()
/home/you/race/main.go:13 +0x8c
==================
counter: 981
Found 1 data race(s)It tells you the memory address, both accesses, the line numbers, and where each goroutine was created.
The race detector is genuinely one of the best tools in any language. It instruments memory access and watches for two goroutines touching the same location without synchronisation - and critically, it finds races even when the run happened to produce the right answer.
Run your tests with `-race`. Always. Put `go test -race ./...` in CI and never take it out.
It makes programs a few times slower, which is why it's off by default. That cost is irrelevant during testing.
Fix one: a mutex
A mutex ensures only one goroutine at a time is in a section of code:
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var mu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
defer mu.Unlock()
counter++
}()
}
wg.Wait()
fmt.Println("counter:", counter) // 1000, every time
}Lock blocks until whoever holds it releases. defer mu.Unlock() guarantees release even if the code panics.
Wrapped into a type, which is how you'd actually write it:
type Portfolio struct {
mu sync.Mutex
positions map[string]float64
}
func (p *Portfolio) Add(symbol string, qty float64) {
p.mu.Lock()
defer p.mu.Unlock()
p.positions[symbol] += qty
}
func (p *Portfolio) Get(symbol string) float64 {
p.mu.Lock()
defer p.mu.Unlock()
return p.positions[symbol]
}Convention: put the mutex directly above the fields it protects. Anyone reading knows what it guards.
Note that Get locks too. It's tempting to skip it for a read-only method - that's a race. A read concurrent with a write is exactly as undefined as two writes.
RWMutex, when reads dominate
type PriceCache struct {
mu sync.RWMutex
prices map[string]float64
}
func (c *PriceCache) Get(symbol string) (float64, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
p, ok := c.prices[symbol]
return p, ok
}
func (c *PriceCache) Set(symbol string, price float64) {
c.mu.Lock()
defer c.mu.Unlock()
c.prices[symbol] = price
}Many readers can hold RLock simultaneously; a writer needs exclusive access. Worth it when reads vastly outnumber writes - a price cache read by many strategies, written by one feed. Measure before assuming it helps; for short critical sections a plain Mutex is often faster.
Fix two: don't share
The Go-preferred answer. Give each goroutine its own data and combine at the end:
func Sweep(bars []Bar, combos []Params, numWorkers int) []Outcome {
results := make(chan Outcome, len(combos))
// each worker sends its own results; nothing is shared
// ...
}That's the design in Chapter 20, and it's why that code needs no mutex anywhere. The best fix for a race is usually not a lock - it's an architecture where the sharing doesn't happen.
Deadlock from locks
Locks bring their own failure. Two goroutines, two mutexes, opposite order:
// goroutine A goroutine B
mu1.Lock() mu2.Lock()
mu2.Lock() // waits for B mu1.Lock() // waits for ABoth wait forever. The fix is a rule: always acquire locks in the same order everywhere. Write the order down as a comment next to the declarations.
Go's runtime detects the simple case where every goroutine is blocked and reports it, but a deadlock between two goroutines while others keep running is not detected. It just hangs.
Atomic operations
For a single counter, a mutex is heavier than necessary:
import "sync/atomic"
var counter atomic.Int64
counter.Add(1)
fmt.Println(counter.Load())Atomics use CPU instructions that perform read-modify-write indivisibly. Fast, and limited to single values - for anything involving two related fields, you need a mutex, because "each field is atomic" doesn't make the pair consistent.
See It Work: find the bug from Chapter 20
Take exercise 20.4 - the sweep sharing one strategy instance - and run it under -race. The detector points straight at the ring buffer's internal fields, naming both goroutines.
That's the payoff. In 20.4 you saw wrong numbers and had to reason about why. With -race the tool names the exact line. Now imagine the bug is in 5,000 lines of backtester and appears in one run out of fifty.
Exercises
21.1 Run the counter program ten times and record the answers. Then run it under -race and read the report.
21.2 Fix it three ways - mutex, atomic, and channel-based collection. Verify each under -race. Benchmark all three.
21.3 Add a mutex to Portfolio and write a test spawning 100 goroutines that each add to the same symbol. Assert the total. Run under -race.
21.4 Remove the lock from a read-only method and see whether -race catches it. Does it catch it every run?
21.5 Take exercise 20.4's shared-strategy sweep and run it under -race. Compare the report against your earlier reasoning.
21.6 Write a deadlock with two mutexes locked in opposite orders. Confirm the runtime does not report it when other goroutines are still running.
21.7 Harder. Build a concurrent order book: multiple goroutines submitting orders, one matching engine. Do it with a mutex, then with a channel where a single goroutine owns the book. Benchmark both and say which you'd ship.
Solutions
21.4 Not every run - that's the point. Races are timing-dependent, so the detector only reports what it actually observes. This is why you run -race on your whole test suite rather than one program: more executions, more chances to catch it.
21.7 The channel version is usually easier to reason about and often competitive on speed, because the book is only touched by one goroutine and needs no locking at all. It's the proverb from Chapter 19 paying off in practice.