Lesson 14 / 14
Race Detector
The detector is a member of the language's own distribution and is called from within the program: a fabricated source is written to a temporary directory and run with the detector option, and only the exit code is read. Of three setups, it finds the unprotected share, and does not find the locked share or the transfer through a channel. The limiting measurement — the course's final and most important boundary — shows that the same unprotected code, placed inside a branch that never runs, is also not found by the detector: the detector sees only the path that runs.
Published
In this lesson
- Calls the detector from within the program across three setups (unprotected share, locked share, transfer through a channel) and measures only the found/not-found answer through the exit code
- Shows, by placing the same unprotected share inside a branch that never runs, that the detector cannot find the race this time
- Gathers the course's fourteen lessons into a single closing table, fabricating no cell for a lesson not yet on disk
Contents
Across three lessons, shared state was protected with a lock, a wait group, once, and atomic operations — but every measurement knew from the start that the protection existed, because we wrote the source. In a real codebase, who says whether protection was forgotten, without reading the source? This final lesson asks this question of the race condition detector that ships with the language’s own distribution.
This course’s second hard constraint required, across three lessons, that a race condition
never actually be run — excluded either by modeling it or by separate memory. In this final
lesson, this constraint reverses: measuring the detector itself requires giving it a
fabricated race it can genuinely catch. What is measured is still not the race’s
outcome — what value counter reaches is never read anywhere — only the detector’s
single-bit answer: found, or not found.
How the Detector Is Called from Within the Program
The detector is a member of the language’s own distribution, like the compiler, the formatter, and the inspector measured in the Go Command Family lesson, and it is called through the same procedure: fabricated source is written to a temporary module, the command runs in that directory, the result is read. The difference is in what the result being read is — here neither an output text nor a format difference is read, only the exit code. When the detector finds a race, the exit code is nonzero; when it does not, the program closes normally with zero.
Unlike the compiler and the inspector, the detector does not just read the source and make a decision; it runs the source and, while running, watches every memory read and write. This watching is given to the call itself as an option — the command normally runs the program it executes without watching it at all, and when given the detector option it runs with an observer attached to every goroutine’s every memory access. This observation comes at a cost: the same program runs slower than normal and uses more memory while running with the detector, because who accessed what, and when, is separately recorded for every access. This lesson does not measure that cost at all — the course’s ban on duration still applies here — it only reads the binary answer the detector gives.
// tools/tools.go — a shared routine that calls the race detector from within the program
package tools
import (
"os"
"os/exec"
"path/filepath"
)
// DetectorFound runs the given source in a temporary module with the detector; the
// detector's exit code is nonzero when it finds a race. The detector's own output is
// written nowhere.
func DetectorFound(source string) bool {
dir, _ := os.MkdirTemp("", "trial")
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
os.WriteFile(filepath.Join(dir, "main.go"), []byte(source), 0o644)
cmd := exec.Command("go", "run", "-race", ".")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
return cmd.Run() != nil
}
DetectorFound deliberately never reads its own output: cmd.Stdout and cmd.Stderr are
never assigned, so everything the detector writes goes to the null device. This rests on the
same reasoning as the panic text not being printed in previous lessons — the detector’s
output carries real memory addresses, real goroutine numbers, and a stack trace; the only
thing measured is the error cmd.Run() returns, that is, whether the command’s exit code
was zero or not. The subprocess is called with GOTOOLCHAIN=local, exactly as in the Go
Command Family lesson — so that no toolchain gets pulled down from the network.
Three Setups, the Detector’s Answer
// main.go — the detector's answer across three setups, and the invisibility of a race in a path that never runs
package main
import (
"fmt"
"lesson/tools"
)
func main() {
unprotected := `package main
func main() {
var counter int
done := make(chan struct{}, 2)
go func() { counter++; done <- struct{}{} }()
go func() { counter++; done <- struct{}{} }()
<-done
<-done
}
`
locked := `package main
import "sync"
func main() {
var counter int
var lock sync.Mutex
done := make(chan struct{}, 2)
go func() {
lock.Lock()
counter++
lock.Unlock()
done <- struct{}{}
}()
go func() {
lock.Lock()
counter++
lock.Unlock()
done <- struct{}{}
}()
<-done
<-done
}
`
viaChannel := `package main
func main() {
channel := make(chan int)
go func() { channel <- 1 }()
<-channel
}
`
unreachablePath := `package main
func main() {
var counter int
done := make(chan struct{}, 2)
unreachableBranch := false
if unreachableBranch {
go func() { counter++; done <- struct{}{} }()
go func() { counter++; done <- struct{}{} }()
<-done
<-done
}
_ = counter
}
`
fmt.Println("-- the detector's answer across three setups --")
fmt.Printf("unprotected share : found=%v\n", tools.DetectorFound(unprotected))
fmt.Printf("locked share : found=%v\n", tools.DetectorFound(locked))
fmt.Printf("channel transfer : found=%v\n", tools.DetectorFound(viaChannel))
fmt.Println()
fmt.Println("-- limiting measurement: a race in a path that never runs is invisible --")
fmt.Printf("race in the unreachable path : found=%v\n", tools.DetectorFound(unreachablePath))
}
-- the detector's answer across three setups -- unprotected share : found=true locked share : found=false channel transfer : found=false -- limiting measurement: a race in a path that never runs is invisible -- race in the unreachable path : found=false
SS12. The three sources differ from each other only in the form the protection takes.
unprotected is two goroutines writing to the same counter with no protection at all —
the unlocked version of the first lesson’s ProtectedCounter, except this time it genuinely
writes to a single genuinely shared memory location at the same time. locked wraps the
same write with a sync.Mutex. viaChannel touches no shared memory at all, passing the
value through a channel instead. The detector gives true only for the first — the locked
share and the channel transfer, through two separate methods, both come out clean in the
detector’s eyes.
viaChannel coming out clean confirms a pattern established since this course’s first
lesson: a value crossing a channel is copied, and the sending side never touches it again
after sending, so a situation where two goroutines access the same memory at the same time
is never set up at all. locked coming out clean is a direct confirmation of the first
lesson as well: the single line taken between a Lock/Unlock pair genuinely prevents two
goroutines from running at the same time, as the detector itself now confirms. What was
described in writing across three lessons is confirmed here by the detector’s own
observation.
These three results are the one place where the race excluded by modeling it or by separate
memory across three lessons finally, genuinely exists. Unlike the first lesson’s unlocked
counter, the unprotected setup uses only two goroutines instead of thirty separate ones,
and both enter the same counter++ operation at the same time — the three separate steps of
reading, incrementing, and writing can be interrupted in between. The detector catches this
interruption by watching every memory access at runtime; because no such interruption ever
happens in the locked and channel setups, the detector finds nothing, reporting not that the
source is “clean,” but that it saw no violation in this run.
Limiting Measurement: The Detector Sees the Path That Runs
SS13. unreachablePath carries exactly the same race as unprotected — the same two
goroutines, the same unprotected counter++. The only difference is that these goroutines
are placed inside a condition named unreachableBranch that is never true. The detector
answers found=false for this source too — the source does carry a race, but the
detector cannot find it, because those goroutines never started in this run. The
detector does not read the source and perform a hazard analysis; it only observes the
running program, and it cannot see a memory access it never observed, in any way at all.
This is the course’s closing boundary: the detector is not a tool of proof, but of
observation. A source passing the detector with found=false does not say there is no
race in that source, only that no race was seen in any of the paths that ran in that
run. unreachablePath shows this difference most clearly — the source carries exactly the
same hazard, but the answer flips, because the hazardous branch was never selected in this
run. In a codebase, a rarely entered branch, an error-handling path, or a branch tied to a
configuration can carry a race the detector never sees — for as long as tests never run that
branch.
This boundary does not make the detector unnecessary, it only narrows what it guarantees.
When the detector says found=true, this is definite proof — as with the fabricated
unprotected source, a violation was genuinely observed. But found=false is not a
guarantee, only a report of that one run. This falls under the same boundary as testing’s
general nature: a test passing does not prove the code is correct, only that the paths
tested gave the expected behavior in that run — the detector establishes this same boundary
once more for concurrency: a branch that never runs, no matter how well it is written, has
never been tested at all.
Course Wrap-Up
Across fourteen lessons, this course had a single question: how does a goroutine stop? The shared definition’s three claims gave three answers to this question. First: starting a goroutine does not mean it will finish — only two of twelve goroutines stopped on their own, the rest depended on a step taken by another goroutine. Second: not stopping produces no error — all three leaks were silent, the program closed with a normal exit. Third, the one specific to this course: what stops a goroutine is not the channel itself, but a step taken by someone — the lock, the wait group, once, all of them build the same dependency with a different mechanism.
A limiting reading and a blind spot complete these three claims. The limiting reading: “stopping on its own” is also a contract, because a goroutine whose body has finished does not announce this to anyone on its own — announcing it still requires a channel or a counter. The blind spot: the count of a leak was never measured in any lesson, only its presence, as a boolean, because the runtime’s own goroutine count could be compared against a baseline value, not measured as a true count.
This final lesson adds a fourth observation: it is hard to say whether protection exists by reading the source, but the detector does not say this exactly either — it only says whether it saw a violation on the path it ran. The course closes without overreaching: these fourteen lessons do not cover the whole of Go’s concurrency model, only give measurable answers, through four mechanisms and one detection tool, to how a goroutine stops and how a share gets protected.
| Lesson | Way of stopping measured | Class (on its own · waited-for-arrived · cancelled · never stopped) | Limiting measurement |
|---|---|---|---|
| Goroutines | Twelve goroutines, four ways of stopping (2/5/2/3 distribution) | on its own · waited-for-arrived · cancelled · never stopped (all) | “Stopped on its own” is also a contract — announcing it requires something |
| Channels | Send-receive rendezvous on an unbuffered channel | waited-for-arrived | A channel is not a queue, the value is copied |
| Buffered and Unbuffered Channels | Blocking deferred according to capacity | waited-for-arrived | The buffer defers synchronization, it does not remove it |
select Statement |
Selecting the ready one among multiple channels | waited-for-arrived | Selection among simultaneously ready branches is random, modeled without running |
| Channel Closing and Ranging | A receive from a closed channel giving the zero value and false |
waited-for-arrived | Sending to a closed channel and closing it a second time both panic, only panic=true/false is written |
| Worker Pools | Workers stopping once the job channel closes | waited-for-arrived | Adding more workers does not split the work; a channel that never closes leaks |
| Fan-Out and Fan-In | Fanned-out work rejoining on the fan-in channel, the reason the collector stops | waited-for-arrived | Only one goroutine can safely close the fan-in channel; if it is never closed, the collector never stops |
| Pipelines | Each stage keeping the chain going by opening and closing its own channel | waited-for-arrived | When the consumer gives up early, the stages ahead of it never stop |
| The Context Package | A single cancellation reaches every goroutine waiting on ctx.Done() at the same time |
cancelled | Context does not forcibly stop anyone, it only notifies; a goroutine that never checks Done() keeps doing its work |
| Timeouts and Deadlines | A timeout is a select branch choice; what is measured is the branch chosen, not the duration |
cancelled (the ready branch) | It rescues the waiter but does not stop the waited-for work; the worker goroutine keeps running, and the leak boolean shows this |
| Mutexes | The waiter continuing once the lock is released | waited-for-arrived | The lock protects only its scope; anything outside the scope stays unprotected |
| Wait Groups | Wait returning once the counter reaches zero |
waited-for-arrived | Order is not guaranteed, only that all of them finished is guaranteed |
| Concurrency Primitives | Once running the body exactly one time | waited-for-arrived | An atomic operation protects a single variable; the two together were modeled without running |
| Race Detector | The detector’s answer across three setups | on its own (unprotected/locked) · waited-for-arrived (channel) | The detector sees only the path that runs, it cannot find a race in a path that does not run |
Summary
- The detector is a member of the language’s own distribution and is called from within the program; a fabricated race was run in a temporary module with the detector option, and only the exit code was read.
- Of the three setups, only the unprotected share gave
found=true; the locked share and the transfer through a channel both came out clean in the detector’s eyes — directly confirming the distinction described in writing across three lessons. - When the same unprotected source was placed inside a branch that never runs, the detector could not find it: the detector is not proof, only an observation of the paths that ran in that run.
- The shared definition’s three claims — stopping on one’s own is the minority, not stopping is silent, what stops a goroutine is a step taken by someone — were confirmed across fourteen lessons through four separate mechanisms.
- The course gathered the answers measured by all fourteen lessons into a single table; every row was read from its own source lesson.
To keep your progress and take notes, Log in
My notes
Log in to take notes.