---
title: 'Race Detector'
source: 'https://academia.sh/en/courses/go-concurrency/race-detector'
course: Concurrency
language: en
updated: '2026-09-14T10:59:32+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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"
---

# 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.

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.

```go
// 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

```go
// 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.
