Recommended resources
The freely available resources this lesson will direct you to.
Welcome back. In the previous module, you built benchmarks that measure a defined workload and learned to compare repeated results without mistaking noise for an improvement. This module shifts to concurrent code: before measuring it, you need confidence that its goroutines access shared state safely.
Today you’ll create a small shared-memory race, run Go’s race detector, and use its report to identify the conflicting accesses. Allow about 35–40 minutes, including the commands.
What makes an access racy?
A data race involves two goroutines accessing the same memory without an ordering that makes the accesses safe, where at least one access writes. “Concurrent” here does not require two CPU instructions to execute at precisely the same instant. The problem is that the accesses are not properly ordered by synchronization.
A shared counter is enough to demonstrate it. The expression count++ reads the counter and writes a new value. If two goroutines execute it without coordinating those accesses, they can interfere—even if a particular run prints the count you expected.
A Race Detector Unfurled -- Kavya Joshi
Watch “A Race Detector Unfurled” by Kavya Joshi, published by Systems We Love, for a short counter example and an introduction to the detector’s warning.
Watch the counter example to see why an execution order that appears harmless in one run is not a guarantee. Continue through the race report; the report, rather than an unexpected output value, is the evidence you will seek below.
The official documentation provides the commands and explains what information a report contains.
Read the Go documentation’s introduction, usage instructions, and report-format example before running the test.
In “Introduction,” read the explanation and the shared-map example immediately below it. In “Usage,” inspect the commands, particularly go test -race. In “Report Format,” read the report guide and examine the example below it through both goroutine-creation stacks. Notice the difference between a stack showing an access and one showing where its goroutine was created.
Reproduce a race in a focused test
In a disposable directory, initialize a small module:
mkdir raceprobe
cd raceprobe
go mod init example.com/raceprobe
Create counter_test.go with this test:
package raceprobe
import "testing"
func TestConcurrentCount(t *testing.T) {
var count int
start := make(chan struct{})
done := make(chan struct{}, 2)
for worker := 0; worker < 2; worker++ {
go func() {
<-start
for i := 0; i < 10000; i++ {
count++
}
done <- struct{}{}
}()
}
close(start)
<-done
<-done
t.Logf("count: %d", count)
}
The start channel releases both workers, giving them an opportunity to run together. Each worker repeatedly accesses the same count. The two receives from done ensure both workers have finished before the test logs the result; they do not order the workers’ counter accesses relative to each other. This distinction matters: waiting for work to finish is not the same as protecting shared state during that work.
First run the ordinary test, then enable the detector:
go test -run '^TestConcurrentCount$' -count=1 -v
go test -race -run '^TestConcurrentCount$' -count=1 -v
The first command may pass and may even log 20000. Neither observation establishes that the increments are safe. The second command should report WARNING: DATA RACE and fail the test. -count=1 asks Go to run the test afresh rather than use a cached successful result.
If one race-enabled run does not expose the conflict, try several fresh runs:
go test -race -run '^TestConcurrentCount$' -count=5 -v
The repeated increments make this race likely to appear, but a detector cannot promise to expose every possible execution schedule.
Diagnose the report, not just the failure
Find the first WARNING: DATA RACE in your output. The exact goroutine numbers, addresses, and line numbers will vary. Read that report in this order:
- Find the two access blocks. Look for lines beginning
Read at,Write at,Previous read at, orPrevious write at. At least one will describe a write. In this test, their stacks should lead tocount++incounter_test.go. An increment contains both a read and a write, so do not expect every report to label the pair identically. - Check what memory they concern. The report gives the accessed address. Matching addresses and the source location help establish that the goroutines conflict over the shared
count, rather than merely executing the same function on separate data. - Find where the goroutines were created. The
created atstacks lead back to thego func()statement. They explain how the concurrent work began; they are not substitutes for the access stacks that locate the bug.
“Previous” describes an access recorded earlier by the detector. It does not mean you must catch two instructions running at the exact same instant. Nor does the warning depend on the final count being wrong. The diagnosis is that the workers performed conflicting, unsynchronized accesses.
The test’s final t.Logf is not the conflicting access: each worker sends on done after its increments, and the test receives both completion signals before reading count. The race is between the workers. This is a useful habit when reading a report: identify the particular pair of accesses it names instead of assuming every use of the shared variable is equally suspect.
Know what a clean run can tell you
The detector instruments code that executes. It cannot inspect an untested path and certify it race-free.
Return to the Go documentation for the detector’s coverage limit and its practical requirements.
In “How To Use,” read the usage guidance, from the suggested test command through the advice about realistic workloads. If -race could not run on your machine, consult “Requirements,” especially the build requirements.
For a larger package, go test -race ./... is a useful check, but its reach still depends on which tests and concurrent paths execute. A race-enabled program run under a representative workload may reveal a conflict that the tests miss. Conversely, a clean run is evidence about that run, not a proof that all schedules are safe.
Keep race-detection runs separate from the performance baselines you built earlier. Instrumentation changes execution cost, so timings from -race are not comparable to ordinary benchmark timings. Also, a data race is narrower than every possible concurrency bug: a program can be free of shared-memory races and still coordinate its work incorrectly.
Takeaways
You reproduced a race by having two goroutines increment one shared counter without protecting the accesses. go test -race identified the conflicting read/write activity; its access stacks located count++, while its creation stacks showed where the workers began. An ordinary passing test—or even the expected final count—could not provide that diagnosis.
In the next lesson, you’ll protect shared mutable state with a mutex and use the detector again to check the change.