How Go's Race Detector Works: Shadow Memory and Vector Clocks
The Go race detector is a port of LLVM's ThreadSanitizer shipped as a prebuilt blob: the compiler inserts read and write hooks, the runtime tells the blob where memory lives, and vector clocks decide which accesses actually race.
TL;DR:
go test -racecompiles in hooks into LLVM’s ThreadSanitizer, which ships as a prebuilt.sysoblob. The compiler addsraceread/racewritearound every memory access, the runtime tells the blob where the heap lives, and vector clocks decide which accesses were actually unordered.
Data races are the bugs that don’t reproduce on your machine, don’t reproduce
in CI, and then reproduce for a user at 3am. Go’s answer to them is -race,
and most people use it as a black box: push the flag, get a stack trace if you
are unlucky, feel good.
It is worth understanding what is actually happening, because the design explains both why the detector is so effective and why it is not a proof.
Where the race detector actually lives
The first surprise is that the Go source tree does not contain a race detector.
src/runtime/race/doc.go is four lines long:
// src/runtime/race/doc.go:5
// Package race implements data race detection logic.
// No public interface is provided.
// For details about the race detector see
// https://golang.org/doc/articles/race_detector.html
package raceAnd src/runtime/race/README explains where the code went:
// src/runtime/race/README:1
runtime/race package contains the data race detector runtime library.
It is based on ThreadSanitizer race detector, that is currently a part of
the LLVM project (https://github.com/llvm/llvm-project/tree/main/compiler-rt).
To update the .syso files use golang.org/x/build/cmd/racebuild.
internal/amd64v1/race_darwin.syso built with LLVM 51bfeff0e4b0757ff773da6882f4d538996c9b04 with patch internal/amd64v1/race_darwin.patch and Go a61fd428974822a8c57a2b2840fc237e6711b24d.So the shadow memory machinery, the vector clock implementation, the happens-before
analysis — none of that is in the Go tree. It is compiled C code from LLVM’s
compiler-rt, linked into the Go binary as an object file. The Go tree
contains the hooks into it.
That is worth internalising, because it means the Go tree can only show you the interface, not the algorithm. Let’s look at the interface, because it is where the interesting Go-specific decisions live.
The compile-time instrumentation
With -race, the compiler turns on two things: memory instrumentation and
function-entry instrumentation.
// src/cmd/compile/internal/ssagen/ssa.go:347
if base.Flag.Cfg.Instrumenting && fn.Pragma&ir.Norace == 0 && !fn.Linksym().ABIWrapper() {
if !base.Flag.Race || !objabi.LookupPkgSpecial(fn.Sym().Pkg.Path).NoRaceFunc {
s.instrumentMemory = true
if base.Flag.Race {
s.instrumentEnterExit = true
}
}
}instrumentEnterExit means every function gets a call at its entry with the
caller’s program counter, so the detector can attribute an access to a source
line rather than to “somewhere in this function”:
// src/cmd/compile/internal/ssagen/ssa.go:577
// Convert the AST-based IR to the SSA-based IR
if s.instrumentEnterExit {
s.rtcall(ir.Syms.Racefuncenter, true, nil, s.newValue0(ssaop.OpGetCallerPC, types.Types[types.TUINTPTR]))
}instrumentMemory is the read/write hook, and it has two flavours. For a
scalar, one address is enough:
// src/cmd/compile/internal/ssagen/ssa.go:1558
} else if base.Flag.Race {
// for non-composite objects we can write just the start
// address, as any write must write the first byte.
switch kind {
case instrumentRead:
fn = ir.Syms.Raceread
case instrumentWrite:
fn = ir.Syms.Racewrite
default:
panic("unreachable")
}But for a composite — a struct, an array — a write to any subobject has to be reported, so the whole range is instrumented:
// src/cmd/compile/internal/ssagen/ssa.go:1545
} else if base.Flag.Race && t.NumComponents(types.CountBlankFields) > 1 {
// for composite objects we have to write every address
// because a write might happen to any subobject.
// composites with only one element don't have subobjects, though.
switch kind {
case instrumentRead:
fn = ir.Syms.Racereadrange
case instrumentWrite:
fn = ir.Syms.Racewriterange
default:
panic("unreachable")
}
needWidth = trueThe symbols those calls point at are declared in the runtime:
// src/runtime/race.go:424
func racefuncenter(callpc uintptr)
func racefuncenterfp(fp uintptr)
func racefuncexit()
func raceread(addr uintptr)
func racewrite(addr uintptr)
func racereadrange(addr, size uintptr)
func racewriterange(addr, size uintptr)
func racereadrangepc1(addr, size, pc uintptr)
func racewriterangepc1(addr, size, pc uintptr)
func racecallbackthunk(uintptr)And on amd64 they are thin assembly thunks that switch to the g0 stack and call straight into the C runtime:
// src/runtime/race_amd64.s:43
// func runtime·raceread(addr uintptr)
// Called from instrumented code.
// Defined as ABIInternal so as to avoid introducing a wrapper,
// which would render sys.GetCallerPC ineffective.
TEXT runtime·raceread<ABIInternal>(SB), NOSPLIT, $0-8
MOVQ AX, RARG1
MOVQ (SP), RARG2
// void __tsan_read(ThreadState *thr, void *addr, void *pc);
MOVQ $__tsan_read(SB), AX
JMP racecalladdr<>(SB)That file’s header comment explains the whole design in three bullets:
// src/runtime/race_amd64.s:13
// The following thunks allow calling the gcc-compiled race runtime directly
// from Go code without going all the way through cgo.
// First, it's much faster (up to 50% speedup for real Go programs).
// Second, it eliminates race-related special cases from cgocall and scheduler.
// Third, in long-term it will allow to remove cyclic runtime/race dependency on cmd/go.“Up to 50% speedup for real Go programs” — that is a number from the tree, and it is a hint at how much of the race build’s cost is cgo overhead rather than detection.
There is also a fast-path filter: if the address being accessed is not in the heap arena or the data segment, there is no shadow for it, so the call is dropped immediately.
// src/runtime/race_amd64.s:149
// If addr (RARG1) is out of range, do nothing.
// Otherwise, setup goroutine context and invoke racecall. Other arguments already set.
TEXT racecalladdr<>(SB), NOSPLIT, $0-0
MOVQ g_racectx(R14), RARG0 // goroutine context
// Check that addr is within [arenastart, arenaend) or within [racedatastart, racedataend).
CMPQ RARG1, runtime·racearenastart(SB)
JB data
CMPQ RARG1, runtime·racearenaend(SB)
JB call
data:
CMPQ RARG1, runtime·racedatastart(SB)
JB ret
CMPQ RARG1, runtime·racedataend(SB)
JAE ret
call:
MOVQ AX, AX // w/o this 6a miscompiles this function
JMP racecall<>(SB)
ret:
RETShadow memory
The detector needs to remember, for every byte of program memory, who last wrote it and what they knew at the time. It does that with a shadow region: a parallel address range holding one byte per eight bytes of application memory (the eight-to-one granularity is a constant inside the prebuilt C runtime, not in this tree — but it is why the detector can afford to instrument every single access, and why a one-byte write to an eight-byte integer is still caught).
The Go tree’s job is to tell the runtime where that shadow should exist. There are exactly two places that matters: the program’s global data, and the heap.
The global data segment is mapped once, at startup:
// src/runtime/race.go:484
// Use exact bounds for boundary check in racecalladdr. See issue 73483.
racedatastart = start
racedataend = end
// Round data segment to page boundaries for race detector (TODO: still needed?)
start = alignDown(start, _PageSize)
end = alignUp(end, _PageSize)
racecall(&__tsan_map_shadow, start, end-start, 0, 0)And the heap is mapped as it grows:
// src/runtime/race.go:524
//go:nosplit
func racemapshadow(addr unsafe.Pointer, size uintptr) {
if racearenastart == 0 {
racearenastart = uintptr(addr)
}
if racearenaend < uintptr(addr)+size {
racearenaend = uintptr(addr) + size
}
racecall(&__tsan_map_shadow, uintptr(addr), size, 0, 0)
}racemapshadow is called from the allocator every time the heap arena grows:
// src/runtime/malloc.go:926
racemapshadow(v, size)That’s the whole of the Go-side shadow management. Everything else — how many shadow bytes, how they’re allocated, how they’re reclaimed — is the C runtime’s business.
Vector clocks: how “happens before” is decided
The shadow byte for a given location holds a shadow clock: the vector clock of the last write to that location. Each goroutine (each OS thread, in TSan’s model) carries its own vector clock. A release bumps the writer’s clock and merges it into the shadow clock of the address; an acquire merges the shadow clock of the address into the reader’s clock. When a read finds that the shadow clock’s write and the reader’s clock are concurrent — neither happens before the other — that is a data race.
Again: the implementation is in the blob. The Go tree shows you the verbs, and the verbs are the whole story. Here is the complete vocabulary the Go runtime uses:
// src/runtime/race.go:317
// Race runtime functions called via runtime·racecall.
//
//go:linkname __tsan_init __tsan_init
var __tsan_init byte
//go:linkname __tsan_fini __tsan_fini
var __tsan_fini byte
//go:linkname __tsan_proc_create __tsan_proc_create
var __tsan_proc_create byte
//go:linkname __tsan_proc_destroy __tsan_proc_destroy
var __tsan_proc_destroy byte
//go:linkname __tsan_map_shadow __tsan_map_shadow
var __tsan_map_shadow byte
//go:linkname __tsan_finalizer_goroutine __tsan_finalizer_goroutine
var __tsan_finalizer_goroutine byte
//go:linkname __tsan_go_start __tsan_go_start
var __tsan_go_start byte
//go:linkname __tsan_go_end __tsan_go_end
var __tsan_go_end byte
//go:linkname __tsan_malloc __tsan_malloc
var __tsan_malloc byte
//go:linkname __tsan_free __tsan_free
var __tsan_free byte
//go:linkname __tsan_acquire __tsan_acquire
var __tsan_acquire byte
//go:linkname __tsan_release __tsan_release
var __tsan_release byte
//go:linkname __tsan_release_acquire __tsan_release_acquire
var __tsan_release_acquire byte
//go:linkname __tsan_release_merge __tsan_release_merge
var __tsan_release_merge byteFour happens-before verbs: acquire, release, release_acquire,
release_merge. Each maps to a Go-side helper:
// src/runtime/race.go:611
//go:nosplit
func raceacquire(addr unsafe.Pointer) {
raceacquireg(getg(), addr)
}
//go:nosplit
func raceacquireg(gp *g, addr unsafe.Pointer) {
if getg().raceignore != 0 || !isvalidaddr(addr) {
return
}
racecall(&__tsan_acquire, gp.racectx, uintptr(addr), 0, 0)
}
//go:nosplit
func raceacquirectx(racectx uintptr, addr unsafe.Pointer) {
if !isvalidaddr(addr) {
return
}
racecall(&__tsan_acquire, racectx, uintptr(addr), 0, 0)
}
//go:nosplit
func racerelease(addr unsafe.Pointer) {
racereleaseg(getg(), addr)
}
//go:nosplit
func racereleaseg(gp *g, addr unsafe.Pointer) {
if getg().raceignore != 0 || !isvalidaddr(addr) {
return
}
racecall(&__tsan_release, gp.racectx, uintptr(addr), 0, 0)
}
//go:nosplit
func racereleaseacquire(addr unsafe.Pointer) {
racereleaseacquireg(getg(), addr)
}
//go:nosplit
func racereleaseacquireg(gp *g, addr unsafe.Pointer) {
if getg().raceignore != 0 || !isvalidaddr(addr) {
return
}
racecall(&__tsan_release_acquire, gp.racectx, uintptr(addr), 0, 0)
}
//go:nosplit
func racereleasemerge(addr unsafe.Pointer) {
racereleasemergeg(getg(), addr)
}isvalidaddr is the same “is this address in the heap or the data segment”
test the assembly does, but in Go, for the paths that are not assembly:
// src/runtime/race.go:441
//go:nosplit
func isvalidaddr(addr unsafe.Pointer) bool {
return racearenastart <= uintptr(addr) && uintptr(addr) < racearenaend ||
racedatastart <= uintptr(addr) && uintptr(addr) < racedataend
}The four kinds of event the detector understands
The Go tree funnels everything the detector knows about into four categories.
1. Data races
Ordinary memory accesses, instrumented by the compiler. raceread and
racewrite for scalars, racereadrange/racewriterange for composites.
This is the category that produces the reports you have seen.
2. Goroutine creation
A goroutine is a thread as far as the detector is concerned, so starting one creates a new clock, and ending one retires it:
// src/runtime/race.go:545
//go:nosplit
func racegostart(pc uintptr) uintptr {
gp := getg()
var spawng *g
if gp.m.curg != nil {
spawng = gp.m.curg
} else {
spawng = gp
}
var racectx uintptr
racecall(&__tsan_go_start, spawng.racectx, uintptr(unsafe.Pointer(&racectx)), pc, 0)
return racectx
}The runtime calls this from newproc1, and the returned context is stored on
the new goroutine so every subsequent access in that goroutine is attributed
to it:
// src/runtime/proc.go:5470
// Set up race context.
if raceenabled {
newg.racectx = racegostart(callerpc)
newg.raceignore = 0
if newg.labels != nil {
// See note in proflabel.go on labelSync's role in synchronizing
// with the reads in the signal handler.
racereleasemergeg(newg, unsafe.Pointer(&labelSync))
}
}This is also where the “goroutine creation” race shows up: the parent writes
to a variable and then starts the goroutine, and the child reads it. Because
go start establishes a happens-before edge from the parent’s clock to the
child’s, that pattern is not a race. A race is reported when the child
touches the variable and the parent writes it later without an edge.
3. Channel operations
Channels are the runtime’s own synchronisation primitive, so the runtime teaches the detector about them directly. Every channel gets a synthetic address used purely as a synchronisation object:
// src/runtime/chan.go:900
func (c *hchan) raceaddr() unsafe.Pointer {
// Treat read-like and write-like operations on the channel to
// happen at this address. Avoid using the address of qcount
// or dataqsiz, because the len() and cap() builtins read
// those addresses, and we don't want them racing with
// operations like close().
return unsafe.Pointer(&c.buf)
}And the actual edges are created in racenotify:
// src/runtime/chan.go:916
// Notify the race detector of a send or receive involving buffer entry idx
// and a channel c or its communicating partner sg.
// This function handles the special case of c.elemsize==0.
func racenotify(c *hchan, idx uint, sg *sudog) {
// We could have passed the unsafe.Pointer corresponding to entry idx
// instead of idx itself. However, in a future version of this function,
// we can use idx to better handle the case of elemsize==0.
// A future improvement to the detector is to call TSan with c and idx:
// this way, Go will continue to not allocating buffer entries for channels
// of elemsize==0, yet the race detector can be made to handle multiple
// sync objects underneath the hood (one sync object per idx)
qp := chanbuf(c, idx)
// When elemsize==0, we don't allocate a full buffer for the channel.
// Instead of individual buffer entries, the race detector uses the
// c.buf as the only buffer entry. This simplification prevents us from
// following the memory model's happens-before rules (rules that are
// implemented in racereleaseacquire). Instead, we accumulate happens-before
// information in the synchronization object associated with c.buf.
if c.elemsize == 0 {
if sg == nil {
raceacquire(qp)
racerelease(qp)
} else {
raceacquireg(sg.g, qp)
racereleaseg(sg.g, qp)
}
} else {
if sg == nil {
racereleaseacquire(qp)
} else {
racereleaseacquireg(sg.g, qp)
}
}
}racereleaseacquire is the important one: a send releases and a receive
acquires, which is precisely the happens-before edge the Go memory model
promises for channels.
4. Synchronisation primitives
sync.Mutex does not use channels, so it teaches the detector about itself
directly. On lock:
// src/internal/sync/mutex.go:61
func (m *Mutex) Lock() {
// Fast path: grab unlocked mutex.
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// Slow path (outlined so that the fast path can be inlined)
m.lockSlow()
}and on unlock:
// src/internal/sync/mutex.go:187
func (m *Mutex) Unlock() {
if race.Enabled {
_ = m.state
race.Release(unsafe.Pointer(m))
}The mutex’s own address is the synchronisation object. Release on unlock,
Acquire on lock, and everything the lock holder wrote before unlocking is
visible to whoever locks next.
sync.WaitGroup is more subtle, because a WaitGroup is a counter and a
semaphore, and the detector needs to model both. Look at how Add handles
the first increment:
// src/sync/waitgroup.go:104
state := wg.state.Add(uint64(delta) << 32)
if state&waitGroupBubbleFlag != 0 && !bubbled {
// Add has been called from within a synctest bubble (and we aren't in one).
fatal("sync: WaitGroup.Add called from inside and outside synctest bubble")
}
v := int32(state >> 32)
w := uint32(state & 0x7fffffff)
if race.Enabled && delta > 0 && v == int32(delta) {
// The first increment must be synchronized with Wait.
// Need to model this as a read, because there can be
// several concurrent wg.counter transitions from 0.
race.Read(unsafe.Pointer(&wg.sema))
}And how Wait models the last decrement:
// src/sync/waitgroup.go:185
if race.Enabled && w == 0 {
// Wait must be synchronized with the first Add.
// Need to model this is as a write to race with the read in Add.
// As a consequence, can do the write only for the first waiter,
// otherwise concurrent Waits will race with each other.
race.Write(unsafe.Pointer(&wg.sema))
}The comments are the interesting part: the WaitGroup deliberately fabricates
a read and a write on the same semaphore address so that two concurrent
Wait calls, or an Add racing a Wait, are modelled the way the
implementation actually behaves.
And atomics
Atomic operations are special-cased in a way that is easy to miss. Under
-race, the compiler refuses to intrinsify sync/atomic calls:
// src/cmd/compile/internal/ssagen/intrinsics.go:2607
if base.Flag.Race && pkg == "sync/atomic" {
// The race detector needs to be able to intercept these calls.
// We can't intrinsify them.
return nil
}So under -race, atomic.LoadInt32 becomes a real call to the assembly in
src/runtime/race_amd64.s, which forwards to the detector’s own
implementation:
// src/runtime/race_amd64.s:196
// Atomic operations for sync/atomic package.
// Load
TEXT sync∕atomic·LoadInt32(SB), NOSPLIT|NOFRAME, $0-12
GO_ARGS
MOVQ $__tsan_go_atomic32_load(SB), AX
CALL racecallatomic<>(SB)
RET
TEXT sync∕atomic·LoadInt64(SB), NOSPLIT|NOFRAME, $0-16
GO_ARGS
MOVQ $__tsan_go_atomic64_load(SB), AX
CALL racecallatomic<>(SB)
RETCompare-and-swap goes through the same door:
// src/runtime/race_amd64.s:357
// CompareAndSwap
TEXT sync∕atomic·CompareAndSwapInt32(SB), NOSPLIT|NOFRAME, $0-17
GO_ARGS
MOVQ $__tsan_go_atomic32_compare_exchange(SB), AX
CALL racecallatomic<>(SB)
RETThis is how the detector learns that an atomic read observed an atomic write: the detector implements the atomic operation itself, on its own shadow cells, so it knows exactly which write it read.
GORACE
The detector is configured through the GORACE environment variable. The
runtime’s documentation is one line:
// src/runtime/extern.go:247
The GORACE variable configures the race detector, for programs built using -race.
See the [Race Detector article] for details.The parsing itself happens in the C runtime, so the Go tree only shows the
options in use. From src/runtime/race/output_test.go:
// src/runtime/race/output_test.go:201
{"halt_on_error", "run", "", "atexit_sleep_ms=0 halt_on_error=1", `
package main
func main() {
done := make(chan bool)
x := 0; _ = x
go func() {
x = 42
done <- true
}()
x = 43
<-done
}
`, []string{`
==================
exit status 66
`}},Three things to notice.
halt_on_error=1 stops at the first race and exits with status 66. By
default the detector reports every race it finds and exits with 66 at the
end. With halt_on_error=1 you get the first one and stop — much faster, and
what you want in CI.
atexit_sleep_ms=0 disables a sleep the detector performs at exit. That
sleep exists because other goroutines may still be running and might produce
reports; setting it to zero makes the process exit immediately instead. Every
test in that file sets it, precisely so the tests are deterministic and fast.
Other options that show up in the tree include exitcode=13 (change the
exit status) and strip_path_prefix=/main. (rewrite paths in the report).
The one everybody meets first is history_size, which controls how many
previous accesses the detector remembers per address; raising it lets the
detector report the other access that conflicted, not just the current one.
Its default is set inside the prebuilt runtime, not in this tree.
The testing package picks the detector’s findings up and turns them into test failures:
// src/testing/testing.go:1869
c.Errorf("race detected during execution of test")What it costs
The widely-quoted figures are a 5–10x slowdown and a large memory overhead. Those numbers come from the race detector article, not from this tree, and they depend enormously on what your program does: a program that spends its life in the allocator sees a much bigger relative hit than one that spends it in tight numeric loops.
What the tree does tell you is that a large fraction of the overhead is
cgo, not detection — hence the hand-written thunks and the “up to 50%
speedup” comment in src/runtime/race_amd64.s:13. The other half of the cost
is the instrumentation itself: every memory access becomes a function call
into the detector.
There is a cheaper way to run the detector’s checks without paying the
instrumentation cost everywhere: sync/atomic’s own implementation. The
runtime’s internal/runtime/atomic package has relaxed-ordering variants that
the detector understands, and internal/runtime/atomic.Uint32 documents the
trade-off:
// src/internal/runtime/atomic/types.go:197
// LoadAcquire is a partially unsynchronized version
// of Load that relaxes ordering constraints. Other threads
// may observe operations that precede this operation to
// occur after it, but no operation that occurs after it
// on this thread can be observed to occur before it.
//
// WARNING: Use sparingly and with great care.
//
//go:nosplit
func (u *Uint32) LoadAcquire() uint32 {
return LoadAcq(&u.value)
}Trade-offs
What the detector gives you. It finds the races you actually execute. It understands channels, mutexes, wait groups, atomics, and goroutine creation, so it will not cry wolf about a correctly synchronised program. It prints both stacks — the conflicting access and the previous conflicting access — with source lines. That is an enormous improvement over “it crashed once”.
What it costs. Memory and time, both significant. It is a debugging tool, not something you leave on in production.
What it cannot do. It is a dynamic detector. It only sees the accesses that actually happened on that run, on that machine, with that scheduling. A race that requires a particular interleaving to manifest may never be observed.
Why the algorithm is in a blob. Because ThreadSanitizer is a decade-old C++ codebase with a decade of tuning. Reimplementing it in Go would have been a large project with no obvious benefit, and would have to be re-validated against the same corpus of real programs. The Go team’s job was to write the hooks, and that is where the Go-specific design decisions live.
Caveats
The detector only sees races it observes. This is the big one. A race
that did not happen during the run is not reported. If the bug only shows up
once a week under production load, -race in CI may never see it.
Memory outside the Go heap and globals is not tracked. The shadow mapping
covers the heap arena and the data/BSS segments. Memory obtained from cgo or
from mmap outside those ranges has no shadow, and accesses to it are
dropped by the address filter in racecalladdr.
unsafe and hand-written assembly defeat it. The detector instruments
what the compiler sees. A pointer chase through unsafe.Pointer in a loop
that the compiler cannot see is invisible to it.
//go:norace disables instrumentation entirely for a function. It exists
for code that would otherwise deadlock or crash the detector, and it is a
blank cheque, not a hint.
The detector’s own goroutines are excluded. race.Disable() and
race.Enable() bracket regions that must not be reported — see
internal/race/norace.go for the no-op versions that are compiled into
non-race builds:
// src/internal/race/norace.go:14
const Enabled = false
func Acquire(addr unsafe.Pointer) {
}
func Release(addr unsafe.Pointer) {
}Note that these are real functions with empty bodies, not absent symbols. That
is deliberate: the call sites in sync and internal/sync stay identical in
both builds, so the if race.Enabled branch is a predictable one and the code
paths do not diverge.
GORACE is read at startup. Changing it after the process starts has no
effect on the parts of the configuration that were parsed already.
Wrapping up
The one thing to remember: go test -race compiles in hooks, not a
detector. The compiler adds a raceread/racewrite around every memory
access and a racefuncenter at every function entry; the runtime tells
LLVM’s ThreadSanitizer where the heap and globals live; the detector then uses
shadow memory and vector clocks to decide whether the accesses it saw were
actually unordered.
The part people get wrong: a clean -race run proves nothing about the
races you did not hit. The detector reports the races it observed, on that
run, with that scheduler. A program that races only under contention may pass
-race in CI for years.
What to go do about it: run -race in CI with GORACE="halt_on_error=1"
so the first race fails the build fast, add -count so the tests run more
than once, and read src/internal/race/race.go and src/internal/sync/mutex.go
next time you wonder how the detector learns about your mutex.
Further reading
src/runtime/race/README— the one file that explains where the detector actually is: LLVM’s compiler-rt, built into.sysoblobs.src/runtime/race/doc.go— the whole public surface of theruntime/racepackage.src/runtime/race_amd64.s— the amd64 thunks into the C runtime, including thesync/atomicinterception and the address-range fast path.src/runtime/race.go— the Go side:raceinit,racemapshadow,racegostart,raceacquire,racerelease, and the__tsan_*symbol declarations.src/cmd/compile/internal/ssagen/ssa.go— the compiler instrumentation:instrumentMemory,instrumentEnterExit,raceread/racewriteand the range variants.src/cmd/compile/internal/ssagen/intrinsics.go— the rule thatsync/atomicis not intrinsified under-race, so the detector can intercept it.src/runtime/chan.go—raceaddr,racesyncandracenotify: how channels become synchronisation objects.src/runtime/proc.go—racegostartinnewproc1: goroutine creation as a happens-before edge.src/internal/sync/mutex.go—race.Acquire/race.Releasearound mutex lock and unlock.src/sync/waitgroup.go— the fabricated read and write that model aWaitGroup’s counter and semaphore together.src/internal/race/race.goandsrc/internal/race/norace.go— the manual instrumentation API, and its no-op twin for non-race builds.src/runtime/race/output_test.go— theGORACEoptions in practice:halt_on_error,atexit_sleep_ms,exitcode,strip_path_prefix.src/runtime/extern.go— theGORACEdocumentation line.