Data Races and -race

A data race is two goroutines touching the same variable with no ordering between them; -race finds them at runtime.

Syntaxgo test -race ./...

A data race happens when two goroutines access the same variable at the same time, at least one of them writes, and nothing orders the two accesses. The result is not a wrong number you can reason about. The program's behaviour is undefined: it can lose updates, read half-written values, or corrupt a map and crash.

Races are the hardest kind of bug to find by reading code, because the program usually works. It fails on a busier machine, or once a week in production.

The detector

Go ships a race detector. Add the -race flag to go test, go run or go build, and the compiler instruments every memory access. When two goroutines touch the same address without ordering, the program prints a report with both stacks and the goroutine that started them.

go test -race ./...
go run -race .

Instrumented binaries run several times slower and use much more memory, so you build them for tests and debugging, not for release. go test -race ./... belongs in CI: the cost is one slower job, and the alternative is finding the race from a customer's crash.

What trips people up

The detector reports only races that actually happen during that run. A clean run proves nothing about code the test never exercised. Races are found by tests that run concurrent paths, not by the flag alone.

Example

Example · go
package main

import (
	"fmt"
	"sync"
)

func main() {
	nums := []int{3, 5, 7, 9}

	// Each goroutine writes only to partial[i]. Different elements are
	// different memory addresses, so there is nothing shared to race on
	// and `go run -race .` reports nothing.
	partial := make([]int, len(nums))

	var wg sync.WaitGroup
	for i, n := range nums {
		wg.Go(func() {
			partial[i] = n * 2
		})
	}
	wg.Wait()

	// The combining step runs in one goroutine, after Wait, so the
	// total is the same every run.
	total := 0
	for _, p := range partial {
		total += p
	}
	fmt.Println("total:", total)
}

// Output:
// total: 48

When to use it

  • Add -race to the test job in CI so unsynchronised shared state fails the build.
  • Reproduce an intermittent production wrong-total bug by running the handler's test under -race.
  • Check that a cache you just made concurrent is actually safe before shipping it.

More examples

The race this replaces

Run this with go run -race . and the detector prints "WARNING: DATA RACE", the line doing the write, the line doing the conflicting read, and where each goroutine was created. Without -race the program usually prints a plausible-looking wrong number. The real fixes are a mutex, an atomic counter, or per-goroutine slots as in the main example.

Example · go
package main

import (
	"fmt"
	"sync"
)

func main() {
	total := 0 // one variable, written by every goroutine

	var wg sync.WaitGroup
	for i := 1; i <= 1000; i++ {
		wg.Go(func() {
			// Read, add, write back. Two goroutines can read the same
			// old value and one update is lost.
			total += i
		})
	}
	wg.Wait()

	// Prints 500500 only by luck; often less, and never reliably.
	fmt.Println(total)
}

A race test worth running in CI

The assertion catches lost updates; the -race flag catches the unsynchronised access even on a run where the total happens to come out right. In CI use go test -race -count=1 ./... so a cached pass from an earlier commit cannot stand in for a real run.

Example · go
package counter

import (
	"sync"
	"testing"
)

// TestConcurrentAdd exercises the concurrent path on purpose, because
// -race can only report a race it actually observes.
func TestConcurrentAdd(t *testing.T) {
	c := New()

	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Go(func() {
			c.Add(1)
		})
	}
	wg.Wait()

	if got := c.Value(); got != 100 {
		t.Fatalf("Value() = %d, want 100", got)
	}
}

Maps crash with or without the flag

The runtime has its own cheap detector for concurrent map access, and unlike a race report it kills the process immediately and cannot be recovered. If a map is reachable from more than one goroutine it needs a mutex around every access, or each goroutine needs its own map that you merge at the end.

Example · go
package main

import "sync"

func main() {
	seen := map[string]int{}

	var wg sync.WaitGroup
	for _, k := range []string{"a", "b", "c"} {
		wg.Go(func() {
			// Two goroutines writing one map can abort the program with
			// "fatal error: concurrent map writes". This check is in the
			// runtime itself, so it fires in production builds too.
			seen[k]++
		})
	}
	wg.Wait()
}

Discussion

  • Be the first to comment on this lesson.