Chapter 8: Data Races and the Memory Model
Chapters 3 through 7 taught channel-based coordination—goroutines communicate by passing data through channels, and channel operations provide synchronization. When a pipeline stage sends a value downstream, ownership transfers with it. This discipline prevents conflicts: only one goroutine accesses data at a time.
But some problems naturally require many goroutines accessing the same data concurrently. Passing every counter increment or cache lookup through a channel would be heavyweight—the synchronization overhead exceeds the actual work.
Where shared memory is the practical choice:
// Illustrative snippet — not a complete program
// HTTP request metrics—every handler increments counters
type Server struct {
totalRequests int64
errorCount int64
}
func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
s.totalRequests++ // Multiple handlers run concurrently
// ...
}
// Illustrative snippet — not a complete program
// Configuration cache—many readers, occasional writer
type Cache struct {
config Config
}
func (c *Cache) Get() Config { return c.config }
func (c *Cache) Update(cfg Config) { c.config = cfg }
// Illustrative snippet — not a complete program
// Worker pool statistics—multiple workers update shared counters
type Pool struct {
jobsProcessed int
jobsFailed int
}
A two-part contrast. Above, channel communication from Part 2: goroutine A passes a value through a channel to goroutine B, with ownership transferring alongside the data, the runtime enforcing safety, and the result race-free by construction. Below, shared memory in Part 3: goroutines A and B both point at one shared variable, accessing it directly with no ownership transfer, so safety is the programmer's job and data races become possible without synchronization.
When goroutines share memory directly, a new class of bug becomes possible—one that doesn’t just produce wrong answers, but renders your program’s behavior undefined. This chapter teaches you to recognize these bugs and understand why they’re dangerous. Later chapters teach you to prevent them.
Recall the Four Questions from §2.1. Before creating any goroutine, you should answer:
- How does this goroutine exit?
- How does it communicate results?
- How are errors handled?
- What data does it access?
Question 4 is where data races live. If two goroutines access the same data and at least one writes, you have a potential data race. This chapter teaches you to recognize that situation and understand why it’s dangerous.
- What constitutes a data race and why it causes undefined behavior
- The difference between data races and race conditions
- How to use Go’s race detector effectively
- The Go memory model’s happens-before relationships
- Strategies for preventing data races
-
sync.Mutexmechanics and patterns—Chapter 9 -
sync.RWMutexand other sync package types—Chapter 9 - Atomic operations—Chapter 11
- Channel-based alternatives to shared memory—already covered in Chapters 3–7
You should understand goroutine creation and the Four Questions from Chapter 2 (especially Q4: “What data does it access?”). You’ve mastered channel-based coordination in Chapters 3–7; this chapter teaches when and how to use shared memory instead.
8.1 What Is a Data Race?
A data race occurs when three conditions are all true simultaneously:
- Two or more goroutines access the same memory location
- At least one access is a write
- The accesses are not synchronized
If any condition is false, there’s no data race. Remove any one, and the race disappears.
Three conditions bracketed together into one outcome: the same memory location, at least one write, and no synchronization together make a data race. Below, the same three read as an escape route — different memory locations means independent access, all reads with no writes is safe, and synchronized access is ordered. Removing any one condition removes the race.
Let’s examine each condition.
Condition 1: Same Memory Location
Two goroutines must access the same variable, struct field, slice element, or map entry:
// Illustrative snippet — not a complete program
var counter int // Shared variable
go func() {
counter++ // Access 1: reads and writes counter
}()
go func() {
counter++ // Access 2: reads and writes counter
}()
Both goroutines access counter—the same memory
location.
What counts as “same location”:
x and x
s.field and s.field
arr[0] and arr[0]
arr[0] and arr[1]
*p and *q where p == q
m[k] and m[k] (same key)
m[k1] and m[k2] (different keys)
Condition 2: At Least One Write
If all accesses are reads, there’s no race—reading doesn’t modify memory, so concurrent reads cannot conflict:
// Illustrative snippet — not a complete program
var config = Config{Timeout: 30} // Initialized before goroutines start
// Safe: concurrent reads only
go func() { fmt.Println(config.Timeout) }() // Read
go func() { fmt.Println(config.Timeout) }() // Read
The danger arises when at least one goroutine writes:
// Illustrative snippet — not a complete program
var config = Config{Timeout: 30}
// DATA RACE: one read, one write
go func() { fmt.Println(config.Timeout) }() // Read
go func() { config.Timeout = 60 }() // Write
What counts as a write:
x = 5
x++, x--
x += 1
s = append(s, v)
m[k] = v
x
Two scenarios. Concurrent reads are safe: memory holds 42, both goroutines read 42, no conflict. A read racing a write is not: memory moves from 42 through an indeterminate state to 99 while one goroutine reads and the other writes, so the reader may see the old value, the new one, or something partial.
Condition 3: No Synchronization
If accesses are ordered by synchronization primitives, there’s no race—even with concurrent goroutines and writes:
// Illustrative snippet — not a complete program
var (
counter int
mu sync.Mutex
)
// Safe: mutex synchronizes access
go func() {
mu.Lock()
counter++
mu.Unlock()
}()
go func() {
mu.Lock()
counter++
mu.Unlock()
}()
The mutex ensures accesses never overlap. At any moment, exactly one
goroutine accesses counter.
Production code typically uses
defer mu.Unlock() immediately after
Lock() to ensure unlock on all exit paths. We use
explicit calls here for clarity.
Synchronization mechanisms that prevent races:
sync.Mutexsync.RWMutexsync/atomicsync.WaitGroupsync.OnceThe Counter Example
Here’s the canonical data race:
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
counter++ // DATA RACE
})
}
wg.Wait()
fmt.Println("Counter:", counter)
}
The counter variable is captured by the
closure—a common pattern where races occur. Each goroutine
shares access to the same local variable.
Expected: Counter: 1000
Actual (varies each run):
-race, whose bookkeeping widens the
window in which an update can be lost. A race that sometimes gives
the right answer is real, but this is not the program that shows
it.
The varying output is a symptom of the data race, but the varying output isn’t the actual problem. The problem is that the program’s behavior is undefined.
Run the counter example with the race detector:
The race detector immediately identifies the problem, showing exactly which goroutines accessed the variable and where in the code. We’ll cover the race detector in detail in §8.3.
Why counter++ Races
counter++ looks like one operation but compiles to three:
The single source line `counter++` expanded into its three machine steps: read the value from memory into a temporary, add one to the temporary, write the temporary back. The caption notes that another goroutine can act between any two of those steps.
When two goroutines execute counter++ concurrently, their
operations can interleave:
A six-row timeline of two goroutines both incrementing a counter that starts at zero. A reads 0, B reads 0, A computes 1, B computes 1, A writes 1, B writes 1. The counter ends at 1 where 2 was expected, because both goroutines read the same starting value and both wrote the same result. One increment was lost, and with a thousand goroutines hundreds are.
This is called a lost update—one of many symptoms of data races. But lost updates are the benign case. Data races cause problems far worse than wrong counts.
What a Data Race Actually Costs You
A program with a data race does not merely produce “wrong answers sometimes.” But Go is more specific than “anything can happen,” and the specifics are worth knowing, because they tell you which races merely corrupt a number and which corrupt memory.
The
Go memory model
says three things about a program that races. First, an implementation
is always allowed to detect the race, report it and halt — which
is exactly what -race builds do. Second, for a value
no larger than a machine word, a read must observe
some value actually written by a preceding or concurrent write;
acausal and “out of thin air” values are forbidden. Third
— and this is where it gets dangerous — reads of anything
larger than a word are only encouraged to
behave that way. An implementation may treat them as several
word-sized operations in an unspecified order.
Possible outcomes include:
- Wrong values (lost updates, stale reads)
- Torn reads/writes (seeing partial multi-word updates)
- Crashes (nil pointer panic, invalid memory access)
- Corrupted data structures (inconsistent internal state)
- Correct behavior (the most dangerous—hides the bug)
The last outcome is the most dangerous—it convinces you the code works.
The compiler and runtime assume your code is race-free and optimize accordingly—reordering operations, eliminating redundant memory accesses, caching values in registers. When that assumption is violated, these optimizations produce behavior that looks impossible from a reading of the source. The program is broken even when it appears to work.
You will read, often, that a data race in Go is “undefined behavior—the program can do anything.” That is the C and C++ rule, and Go deliberately does not adopt it. The memory model puts it directly:
“These implementation constraints make Go more like Java or JavaScript, in that most races have a limited number of outcomes, and less like C and C++, where the meaning of any program with a race is entirely undefined, and the compiler may do anything at all.”
Race a single int and you will read
some value that was really written — the wrong one,
but a real one. Race a struct, an
interface, a slice, a string or a map and you can
observe a value that was never written at all, because the
word-sized halves came from different updates. When those halves
are a (pointer, length) or
(pointer, type)
pair, the result is arbitrary memory corruption.
That distinction is the reason the next two examples use a struct
and an interface rather than a counter. It is also why
“it’s only an int, it’ll be
fine” is a bad argument rather than a wrong one: the failure
is smaller, but the bug is identical and the race detector will
still refuse to let it through.
Torn Reads: Seeing Impossible States
Consider a data race on a struct:
// Illustrative snippet — not a complete program
type Config struct {
Host string
Port int
}
var config Config // Shared, unsynchronized
// Goroutine A: updates config
func updateConfig(host string, port int) {
config.Host = host
config.Port = port
}
// Goroutine B: reads config
func connect() {
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
dial(addr)
}
If updateConfig("newhost", 8080) runs while
connect() reads, goroutine B might see:
-
Host = "newhost",Port = 8080(fully updated) ✓ -
Host = "oldhost",Port = 80(fully old) ✓ -
Host = "newhost",Port = 80(half old, half new) ✗ -
Host = "oldhost",Port = 8080(half old, half new) ✗
The last two are torn reads—seeing a state that never existed as a complete, coherent value.
A four-row timeline of a torn read on a two-field struct. The writer sets Host to the new value, the reader then reads Host and gets the new value, the reader reads Port and gets the old one, and only afterwards does the writer set Port. The reader ends up with a Host and Port combination that was never simultaneously true, and dials the wrong server.
Strings, slices, interfaces, and maps are all multi-word values internally:
string[]T (slice)interface{}map[K]VA data race on any of these can produce torn reads where the components are inconsistent with each other—reading a string’s new pointer with its old length, for example.
Pointer and Interface Races: Crashes
Data races on pointers or interfaces can cause crashes:
// Illustrative snippet — not a complete program
var handler http.Handler // Shared, unsynchronized
// Goroutine A: replaces handler
func updateHandler(h http.Handler) {
handler = h
}
// Goroutine B: uses handler
func handleRequest(w http.ResponseWriter, r *http.Request) {
handler.ServeHTTP(w, r) // May crash
}
In Go, an interface value is two words: a type pointer and a data pointer. Without synchronization, a reader might see a torn value:
An interface value shown as its two words: a type pointer and a data pointer. A writer replaces both, moving from type A with data A to type B with data B. A reader can observe the halves from different updates — type B paired with data A. Calling a method then dispatches through type B's method table against data A's memory, and the program crashes.
The Race You Can’t See: Reordering
The most dangerous races are the ones that don’t manifest during development:
// Illustrative snippet — not a complete program
var ready bool
var data int
func setup() {
data = 42 // Write 1
ready = true // Write 2
}
func use() {
if ready {
fmt.Println(data) // DATA RACE: may print 0!
}
}
// Called from separate goroutines:
go setup()
go use()
This looks reasonable: set data, then set
ready. But without synchronization:
- CPU reordering: The CPU may execute or make visible the writes in different order
-
Cache effects: Another CPU core may see
ready = truebefore seeing the updateddata - Compiler optimization: Independent writes may be reordered for efficiency
Two columns. On the left, the order you wrote: assign data, then set ready. On the right, the order another core may observe: ready set first, data second. The explanation is that the compiler and CPU reorder independent operations for pipelining, cache and register reasons, and from their point of view these two writes have no dependency. A reader can therefore see ready true while data is still zero.
This race might never trigger on your development machine but fail consistently on a different CPU architecture or under production load. This is why race bugs discovered in production are often impossible to reproduce locally—the reordering that exposes the race only happens under specific CPU, compiler, or load conditions.
Test Your Understanding
Example 1: Shared counter
// Illustrative snippet — not a complete program
var count int
for i := 0; i < 100; i++ {
go func() { count++ }()
}
Race? Yes—multiple goroutines write
count without synchronization.
Example 2: Goroutine-local variables
// Illustrative snippet — not a complete program
for i := 0; i < 10; i++ {
go func() {
localCount := 0
localCount++
fmt.Println(i, localCount)
}()
}
Race? No—each goroutine has its own
localCount. No shared memory.
Example 3: Read-only shared data
// Illustrative snippet — not a complete program
var config = loadConfig() // Package level; never modified after
go func() { fmt.Println(config.Timeout) }() // Read
go func() { fmt.Println(config.Timeout) }() // Read
Race? No—config is initialized
before goroutines start (package-level initialization), and only read
afterward. No writes after initialization means no race.
Example 4: Channel communication
// Illustrative snippet — not a complete program
ch := make(chan int)
go func() { ch <- 42 }()
go func() { fmt.Println(<-ch) }()
Race? No—channel operations are synchronized by the runtime.
Example 5: Mutex-protected counter
// Illustrative snippet — not a complete program
var mu sync.Mutex
var count int
go func() { mu.Lock(); count++; mu.Unlock() }()
go func() { mu.Lock(); count++; mu.Unlock() }()
Race? No—mutex ensures only one goroutine
accesses count at a time.
Example 6: Map with concurrent writes
// Illustrative snippet — not a complete program
var cache = make(map[string]string)
for i := 0; i < 100; i++ {
go func() { cache[fmt.Sprint(i)] = "value" }()
}
Race? Yes—this is a data race. Go’s
runtime usually detects concurrent map writes and panics with
fatal error: concurrent map writes, but detection is not
guaranteed.
Go’s built-in maps have no internal locking:
-
Concurrent writes: Runtime detects and panics
with
fatal error: concurrent map writes - Concurrent read + write: Undefined—may corrupt silently, panic, or appear to work
Don’t rely on the panic as a safety
mechanism—it’s a symptom, not protection. Always
synchronize map access using
sync.Mutex, or use sync.Map (Chapter 12)
for concurrent access patterns.
The Four Questions Connection
Chapter 2 introduced the Four Questions for every goroutine. Question 4—“What data does it access?”—directly addresses race prevention:
Unlock() or miss
synchronization can introduce races
When you answer Q4 and find that multiple goroutines access the same data with at least one write, you’ve identified a potential data race. The follow-up question: “How is that access synchronized?”
Common Mistakes
atomic.Bool or mutex
x++ is one operation”
atomic.Int64 or mutex
-race)
Go provides a built-in race detector that finds these bugs
automatically. §8.3 covers
go run -race and
go test -race—essential tools for any
concurrent Go development.
Key Takeaways
- Three conditions define a race—same memory, at least one write, no synchronization. Remove any one to eliminate the race.
-
A race is worse than a wrong number—a
word-sized value reads as some real write, but structs,
interfaces, slices, strings and maps can tear into values that
were never written, and a torn
(pointer, length)pair corrupts memory -
counter++is not atomic—it’s read-modify-write (three operations that can interleave) - Multi-word values tear—strings, slices, interfaces, and maps can show inconsistent internal state
- Reordering is invisible—compiler and CPU may reorder operations, breaking assumptions that only exist in your mind
- Maps always need synchronization—concurrent writes usually panic, but read+write may corrupt silently
-
Use the race detector—“works on my
machine” proves nothing;
go run -racecatches races that testing misses (§8.3)
Next: §8.2 distinguishes data races from race conditions—a crucial distinction that even experienced developers often confuse.
8.2 Data Races vs Race Conditions
§8.1 defined data races: concurrent memory access with at least one write and no synchronization. But there’s another term you’ll encounter—race condition—that sounds similar but describes a fundamentally different problem.
These terms are often used interchangeably. This is a mistake. They have different causes, different consequences, and different solutions.
A side-by-side comparison. A data race is a memory-safety violation caused by unsynchronized concurrent access with at least one write; it produces memory-unsafe results, is found by the race detector, and is fixed with synchronization. A race condition is a logic bug where correctness depends on the timing of operations; it produces wrong but well-defined behavior, is found by testing and code review, and is fixed with a correct algorithm. The closing point: you can have either without the other, and adding mutexes removes data races without fixing race conditions.
Understanding this distinction matters because:
- The race detector finds data races, not race conditions
- Fixing a data race doesn’t automatically fix a race condition
- Code can have one without the other
Definitions
Data race: Two goroutines access the same memory location concurrently, at least one is a write, and there’s no synchronization. The value you read is not predictable, and for anything wider than a machine word it may be a value that was never written — see §8.1.
Race condition: Program correctness depends on the relative timing or ordering of operations. The outcome varies based on which operation “wins the race.” This produces wrong but defined behavior—the program does something predictable, just not what you wanted.
Race Condition Without Data Race
The most important insight: you can have race conditions in perfectly synchronized code.
Consider a bank account with properly synchronized methods:
// Illustrative snippet — not a complete program
type Account struct {
mu sync.Mutex
balance int
}
func (a *Account) Balance() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.balance
}
func (a *Account) Withdraw(amount int) bool {
a.mu.Lock()
defer a.mu.Unlock()
if a.balance >= amount {
a.balance -= amount
return true
}
return false
}
func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += amount
}
Each method is properly synchronized. There are
no data races—the mutex ensures exclusive
access to balance. The race detector will report nothing.
But this code has a race condition when used like this:
// Illustrative snippet — not a complete program
// ✗ WRONG: Check-then-act with gap
func transferUnsafe(from, to *Account, amount int) bool {
if from.Balance() >= amount { // Check
from.Withdraw(amount) // Act (return value ignored!)
to.Deposit(amount)
return true
}
return false
}
The bug: Between checking Balance() and
calling Withdraw(), another goroutine can withdraw funds:
A two-goroutine timeline of a check-then-act bug in a money transfer, starting with 100 dollars in one account and zero in the other. Both goroutines check the balance and both see 100, so both decide 80 is affordable. A's withdrawal succeeds and leaves 20; B's withdrawal fails against the reduced balance. But both go on to deposit 80, so the destination ends at 160. There is no data race — every method is synchronized — but the transfer ignored the withdrawal's return value, and money was created out of nothing.
The race detector won’t catch this because there’s no data
race—every memory access is properly synchronized. But the
program is still wrong: the check in
transferUnsafe became stale before the action occurred.
This pattern appears everywhere:
// Illustrative snippet — not a complete program
if condition() { // Check
act() // Act—condition may have changed!
}
Examples:
- “If file doesn’t exist, create it”
- “If key not in map, insert it”
- “If balance sufficient, withdraw”
- “If seat available, book it”
- “If counter below limit, increment”
Rule: Check-then-act must be atomic. The lock must cover both operations.
Fixing the race condition:
// Illustrative snippet — not a complete program
// ✓ CORRECT: Check and act atomically under same lock
func (a *Account) TransferTo(to *Account, amount int) bool {
if a == to {
return true // Self-transfer is a no-op
}
a.mu.Lock()
defer a.mu.Unlock()
if a.balance < amount {
return false
}
a.balance -= amount
to.mu.Lock()
to.balance += amount
to.mu.Unlock()
return true
}
Now the balance check and withdrawal happen while holding the lock—no other goroutine can see or modify the balance in between.
The code above demonstrates atomic check-and-act. The self-transfer guard prevents the self-deadlock, but one issue remains:
-
Cross-transfer: If goroutine 1 calls
a.TransferTo(b, x)while goroutine 2 callsb.TransferTo(a, y), they acquire locks in opposite order—potential deadlock
Production code requires consistent lock ordering. Chapter 10 covers deadlock prevention strategies in depth, including lock ordering and deadlock detection.
The original transferUnsafe had two bugs:
- Check-then-act gap: Balance could change between check and withdrawal
-
Ignored return value: Proceeded with deposit
even if
Withdraw()returned false
The corrected TransferTo addresses both: the check
and balance modification happen atomically under the same lock,
and we return false immediately if the check
fails—no separate Withdraw call whose return
value could be ignored.
Adding Mutex Alone Isn’t Enough
A common mistake: adding a mutex but releasing it between check and act.
// Illustrative snippet — not a complete program
// ✗ WRONG: Mutex but gap between check and act
func increment() {
mu.Lock()
belowLimit := counter < 100
mu.Unlock() // Released here!
if belowLimit {
mu.Lock()
counter++ // Another goroutine may have incremented!
mu.Unlock()
}
}
// ✓ CORRECT: Check and act under same lock
func increment() {
mu.Lock()
defer mu.Unlock()
if counter < 100 {
counter++
}
}
The first version has no data race—every access is protected.
But it has a race condition because belowLimit can become
stale before we act on it.
Why “Benign” Data Races Aren’t Benign
Can you have a data race where the program logic appears correct? Some developers call these “benign races”—though as we’ll see, they’re anything but benign.
// Illustrative snippet — not a complete program
var done bool // Unsynchronized
func worker() {
processData()
done = true // DATA RACE: write without synchronization
}
func coordinator() {
go worker()
for !done { // DATA RACE: read without synchronization
time.Sleep(time.Millisecond)
}
fmt.Println("Complete")
}
Data race? Yes—concurrent read and write to
done without synchronization.
Race condition? Also yes—due to caching and visibility rules, the coordinator may spin forever, never observing the write. The program’s correctness depends on timing and hardware memory behavior.
Why this specific race breaks:
Three scenarios showing why a race on a boolean flag is not harmless. First, register caching: the compiler may load the flag once into a register and spin on the register, never seeing the memory write, so the loop never exits. Second, reordering: a worker may make the done flag visible before the work it announces, so the coordinator proceeds against unprocessed data. Third, cache visibility: on a multi-core machine one core's write may not reach another for an unbounded time without synchronization. The caption notes these happen in production, not just in theory.
The term “benign race” should be avoided entirely. What appears benign today becomes malicious tomorrow when:
- A compiler update introduces new optimizations
- Code runs on a different CPU architecture (ARM, RISC-V)
- Load patterns shift, exposing race windows that were too narrow before
The Go memory model makes no guarantees about racy code. Rule: Fix all data races. If the race detector reports it, fix it.
Memory model perspective: Without synchronization, there’s no happens-before relationship (§8.4) between the write and read. The memory model makes no guarantees about visibility—the read may never observe the write.
Correct implementation:
// Illustrative snippet — not a complete program
var done atomic.Bool
func worker() {
processData()
done.Store(true)
}
func coordinator() {
go worker()
for !done.Load() {
time.Sleep(time.Millisecond)
}
fmt.Println("Complete")
}
Atomic operations establish happens-before relationships (§ 8.4),
so when Load() returns true, the coordinator
is guaranteed to see processData()’s side effects.
This fixes both the caching and the reordering problems—the
compiler cannot move Store(true) before
processData().
Both Data Race AND Race Condition
Many real-world bugs combine both problems:
// Illustrative snippet — not a complete program
var counter int // Unsynchronized
func increment() {
if counter < 100 { // DATA RACE: unsynchronized read
counter++ // DATA RACE: unsynchronized read-modify-write
}
}
// Multiple goroutines
for i := 0; i < 200; i++ {
go increment()
}
Data race: Multiple goroutines access
counter without synchronization. Undefined behavior.
Race condition: Even with synchronization, the check-then-act pattern would still be buggy if the lock is released between check and act.
Both bugs must be fixed:
// Illustrative snippet — not a complete program
var (
counter int
mu sync.Mutex
)
func increment() {
mu.Lock()
defer mu.Unlock()
// Check and act atomically under same lock
if counter < 100 {
counter++
}
}
Now there’s no data race (mutex protects access) and no race condition (check-then-act is atomic).
The Four Combinations
A two-by-two matrix of data race against race condition. No race and no race condition is correct code, the goal. No data race but a race condition is a subtle logic bug the race detector will not catch. A data race with correct logic gives memory-unsafe results and the detector will catch it. Both together is the worst case and common in buggy code.
TOCTOU: Time-of-Check to Time-of-Use
TOCTOU is the formal name for check-then-act race conditions, particularly common in file operations. The vulnerability exists in the gap between checking a condition and using the result.
// Illustrative snippet — not a complete program
// ✗ TOCTOU: File may change between check and use
func processFile(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.Size() > maxSize {
return errors.New("file too large")
}
// Gap: file can change here!
data, err := os.ReadFile(path) // May read different content
if err != nil {
return err
}
return process(data)
}
Between Stat and ReadFile:
- The file could grow larger than
maxSize - The file could be replaced with different content
- The file could be deleted
Not a data race (no shared memory between goroutines), but still a race condition—the check becomes stale before use. Race conditions aren’t limited to goroutines; they can occur anywhere timing matters.
Fix: Act atomically, handle errors
// Illustrative snippet — not a complete program
// ✓ No TOCTOU: Open once, use the handle
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
info, err := f.Stat() // Stat the open file, not the path
if err != nil {
return err
}
if info.Size() > maxSize {
return errors.New("file too large")
}
data, err := io.ReadAll(f) // Read from same handle
if err != nil {
return err
}
return process(data)
}
For file creation, use atomic flags:
// Illustrative snippet — not a complete program
// ✗ TOCTOU: File may be created between check and create
if _, err := os.Stat(path); os.IsNotExist(err) {
os.Create(path) // Another process might create it first
}
// ✓ Atomic: O_EXCL fails if file exists
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
In concurrent systems, checking before acting creates race windows. Instead:
- Don’t: Check if file exists, then create it
- Do: Try to create exclusively, handle “already exists” error
- Don’t: Check if key in map, then insert
- Do: Lock around both check and insert as a single operation
Combine check and act into a single atomic operation when possible.
Test Your Understanding
Example 1: Atomic counter with check
// Illustrative snippet — not a complete program
var counter atomic.Int64
func incrementIfBelow(max int64) bool {
current := counter.Load()
if current < max {
counter.Add(1)
return true
}
return false
}
Data race? No—atomic operations are synchronized.
Race condition? Yes—another goroutine might
increment between Load() and Add(), causing
counter to exceed max.
Use compare-and-swap (CAS) in a loop to atomically check and increment—covered in Chapter 11.
Example 2: Map with mutex
// Illustrative snippet — not a complete program
var (
cache = make(map[string]string)
mu sync.Mutex
)
// No defer — intentionally release lock before expensive computation
func getOrCompute(key string) string {
mu.Lock()
if val, ok := cache[key]; ok {
mu.Unlock()
return val
}
mu.Unlock()
// Expensive; lock released so other callers are not blocked
computed := expensiveCompute(key)
mu.Lock()
cache[key] = computed
mu.Unlock()
return computed
}
Data race? No—mutex protects all map access.
Race condition? Yes—two goroutines may both compute for the same key if they both see “not found” before either stores.
This is a performance bug, not a correctness bug—both
goroutines compute the same value, wasting resources. For
expensive operations, use singleflight pattern or
per-key synchronization.
Example 3: Channel select
// Illustrative snippet — not a complete program
func queryFirst(ch1, ch2 <-chan int) int {
select {
case v := <-ch1:
return v
case v := <-ch2:
return v
}
}
Data race? No—channels are synchronized by runtime.
Race condition? Depends on intent. If either result is acceptable (first-response-wins pattern), this is correct by design. If a specific channel’s result is required, this is a bug.
Common Mistakes
Mutex prevents data races, not race conditions
Ensure check-then-act is atomic
Detector finds data races, not race conditions
Manual analysis for logic races
Sequences of atomic operations aren’t atomic
Make the entire sequence atomic
Channels prevent data races, not race conditions
Logic can still depend on timing
Different problems, different solutions
Data race = memory; Race condition = logic
Summary
-race)
Key Takeaways
- Data races and race conditions are different problems—data races cause undefined behavior; race conditions cause logic bugs. The race detector finds only data races.
- Synchronized code can still have race conditions—adding mutexes prevents data races but doesn’t fix algorithmic bugs
- Check-then-act must be atomic—hold the lock across both the check and the action; the gap between them is the vulnerability
- There are no “benign” data races—compiler optimizations, CPU reordering, and cache visibility can break “harmless” races
- TOCTOU bugs survive synchronization—each individual operation can be synchronized yet the sequence still races
- Act atomically, handle errors—don’t check then act; combine them into a single atomic operation when possible
-
Passing
-raceis necessary but not sufficient—correct algorithm design is still required
Next: §8.3 covers Go’s race detector in depth—how to use it, interpret its output, and integrate it into your development workflow.
8.3 The Race Detector
§§8.1 and 8.2 taught you what data races are and how they differ from race conditions. But recognizing data races in code is hard—they’re timing-dependent, often invisible during development, and can lurk undetected for months. You need a tool.
Go provides one: the race detector. It’s built into the toolchain, requires no setup, and finds data races that would take humans hours to identify.
A summary card for the race detector. It is a dynamic analysis tool built into the toolchain that finds races at runtime, invoked with the -race flag on run, test or build. It detects concurrent unsynchronized access and therefore data races. It does not detect race conditions, deadlocks, goroutine leaks, or races on code paths that the run never executed. The cost is roughly 2 to 20 times slower and 5 to 10 times more memory: worth it in testing, not in production.
The detector reports races that actually occur during your test
run. Code paths not exercised won’t be checked. A race that
doesn’t execute during testing won’t be detected, even
if it exists in the code. This is why you need good test coverage
and should run with -race in CI on realistic
workloads.
Basic Usage
Enable the race detector with the -race flag:
The detector instruments your code at compile time, tracking all memory accesses and synchronization operations. When it detects concurrent access to the same memory location with at least one write and no synchronization, it reports a race.
-race STANDARD IN DEVELOPMENT
Running with -race during development catches bugs
immediately, when the context is fresh.
Your First Race Report
Let’s trigger and examine a race report:
package main
import (
"fmt"
"sync"
)
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Go(func() {
counter++ // DATA RACE
})
}
wg.Wait()
fmt.Println("Counter:", counter)
}
Run with -race:
counter++ is actually three operations: read the
current value, add 1, write the result back. The race detector
caught goroutine 7’s read overlapping with
goroutine 6’s write. If you run again, you
might see “Write” and “Write”
instead—it depends on which conflicting accesses the
detector observed.
Anatomy of a Race Report
Every race report has the same structure:
An annotated race report. The first boxed section is the current access that triggered the report, showing the memory address, the goroutine id and a stack trace. The second is the conflicting previous access, at the same address from a different goroutine, at least one of the two being a write. The third gives the goroutine's creation site, which is what lets you trace the bug back to where it was spawned. The final line is the exit status printed by go run.
Key information in each report:
counter++ is
read-modify-write)
go
The “previous” access is simply one that the detector tracked and observed conflicting with the “current” access. Both accesses race with each other—neither is more “at fault.” If you run again, the roles might reverse.
66 is the race detector’s own exit code, and only a binary
you built with -race and ran directly reports it to
your shell. go run and go test both wrap
that binary and exit 1, the way they do for any
failure.
For CI this is a distinction without a difference — any non-zero status fails the step. It matters only if you planned to key on 66 specifically, which would silently never match:
// Illustrative snippet — not a complete program
- name: Test with race detector
run: go test -race ./...
# go test exits 1 on a detected race, which fails the step.
# Do not match on 66 here: that is the inner binary's code.
Reading Complex Reports
Real-world races often involve deeper call stacks:
// Illustrative snippet — not a complete program
type Cache struct {
data map[string]string
}
func (c *Cache) Get(key string) string {
return c.data[key] // Line 8
}
func (c *Cache) Set(key, value string) {
c.data[key] = value // Line 12
}
func main() {
cache := &Cache{data: make(map[string]string)}
var wg sync.WaitGroup
wg.Go(func() {
cache.Set("foo", "bar") // Line 22
})
wg.Go(func() {
_ = cache.Get("foo") // Line 27
})
wg.Wait()
}
Those first frames are inside the runtime’s map implementation.
Since Go 1.24 that lives in
internal/runtime/maps/ (the Swiss-table rewrite); on an
older toolchain the same frames point at
runtime/map_faststr.go. Either way they are noise —
the frames you want are the two below them, in your own package.
Reading strategy:
-
Skip runtime internals. Ignore
runtime.mapassign_faststr—that’s the map implementation. -
Find your code. Look for your package name:
main.(*Cache).Set()on line 12. -
Identify the conflict.
Set()writes to the map,Get()reads from it—both without synchronization. - Trace goroutine creation. Lines 22 and 27 show where the goroutines were spawned.
Common runtime functions in reports:
runtime.mapassign*
m[k] = v)runtime.mapaccess*
v := m[k])runtime.growslice
runtime.typedmemmove
These are normal—look at the line above them in your code for the actual source.
Multiple Races in One Run
The detector reports each distinct race it finds:
// Illustrative snippet — not a complete program
var (
counter1 int
counter2 int
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Go(func() {
counter1++ // Race 1
counter2++ // Race 2
})
}
wg.Wait()
}
When you see multiple race reports:
- Fix the first one
- Re-run with
-race - See if other races remain or new ones appear
One fix might eliminate others (same root cause) or expose new ones (changed timing). Don’t try to fix all races simultaneously.
What the Race Detector Catches
The detector finds races on all Go memory types:
// Illustrative snippet — not a complete program
// ✓ DETECTED: Variables
var count int
go func() { count++ }()
go func() { count++ }()
// ✓ DETECTED: Struct fields
type Data struct{ value int }
var d Data
go func() { d.value = 1 }()
go func() { fmt.Println(d.value) }()
// ✓ DETECTED: Map access
var m = make(map[string]int)
go func() { m["a"] = 1 }()
go func() { _ = m["a"] }()
// ✓ DETECTED: Slice modification
var s []int
go func() { s = append(s, 1) }()
go func() { fmt.Println(len(s)) }()
The race detector recognizes Go’s synchronization
primitives: sync.Mutex, sync.RWMutex,
sync.WaitGroup, sync.Once, channels, and
sync/atomic
operations. Properly synchronized code won’t trigger false
positives.
The race detector has essentially no false positives. If it reports a race, you have a race—always fix it. The only question is whether that code path executes in production.
What the Race Detector Misses
The detector has fundamental limitations. Understanding them prevents false confidence.
1. Races in Unexecuted Code Paths
The detector only finds races that actually occur during execution:
// Illustrative snippet — not a complete program
// Given: var cache map[string]string; var key, value string
func process(useCache bool) {
if useCache {
// Race here—but only if useCache is true
go func() { cache[key] = value }()
_ = cache[key]
}
}
func TestProcess(t *testing.T) {
process(false) // Race never triggered—not detected!
}
The race detector has:
- No false positives: Every reported race is real—always fix them
- False negatives: Races in unexecuted code paths are invisible
The only way to reduce false negatives is to increase test coverage of concurrent code paths and test with production-like workloads.
2. Race Conditions (Logic Bugs)
From §8.2—synchronized code with timing-dependent correctness:
// Illustrative snippet — not a complete program
// Given: var mu sync.Mutex; var balance int
// ✗ NOT DETECTED: Race condition with proper synchronization
func withdraw(amount int) bool {
mu.Lock()
hasEnough := balance >= amount
mu.Unlock() // Gap between check and act!
if hasEnough {
mu.Lock()
balance -= amount
mu.Unlock()
return true
}
return false
}
The race detector sees properly synchronized memory access. It can’t detect that the logic is wrong.
3. Deadlocks and Goroutine Leaks
// Illustrative snippet — not a complete program
// ✗ NOT DETECTED: Deadlock
var mu1, mu2 sync.Mutex
go func() { mu1.Lock(); mu2.Lock() }()
go func() { mu2.Lock(); mu1.Lock() }()
// ✗ NOT DETECTED: Goroutine leak
ch := make(chan int)
go func() { ch <- 1 }() // Blocks forever, no receiver
Performance Overhead
Race detection isn’t free:
This means:
- Always use in development and testing—the cost is worth finding bugs
- Avoid in production—consider staging/canary environments if runtime detection is needed
-
CI/CD should always run
-race—automated testing can absorb the cost
The 2–20× overhead makes race-enabled binaries
unsuitable for production traffic. When
go test -race detects a race it fails the run and
exits 1; 66 is the code of the race-enabled binary underneath.
Standalone binaries built with go build -race log
race reports to stderr but continue running by default
(configurable via GORACE="halt_on_error=1"). If you
need runtime detection, consider running race-enabled builds in
staging or canary environments where the overhead is acceptable.
CI/CD Integration
Make the race detector part of your continuous integration pipeline:
// Illustrative snippet — not a complete program
# GitHub Actions
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable' # Or pin to your project's version
- name: Test with race detector
run: go test -race -timeout 10m ./...
// Illustrative snippet — not a complete program
# GitLab CI
test:
image: golang:latest # Or pin to your project's version
script:
- go test -race -timeout 10m ./...
Best practices for CI:
-race on every PR
Environment Variables
Control race detector behavior with the GORACE
environment variable:
halt_on_error
log_path
history_size
atexit_sleep_ms
halt_on_error=1
Use halt_on_error=1 when debugging a specific
race—it stops immediately, making it easier to examine
state. Use the default (report all) in CI to see the full scope of
race problems in one run.
Strategies for Better Detection
Since the detector only finds races in executed code, maximize coverage:
1. Write Concurrent Tests
Tests that only use one goroutine won’t detect races:
// Illustrative snippet — not a complete program
// ✗ BAD: No concurrency, won't detect races
func TestCache(t *testing.T) {
c := NewCache()
c.Set("key", "value")
_ = c.Get("key")
}
// ✓ GOOD: Concurrent access exposes races
func TestCacheConcurrent(t *testing.T) {
c := NewCache()
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Go(func() {
for j := 0; j < 100; j++ {
c.Set(fmt.Sprintf("key-%d", j), "value")
}
})
wg.Go(func() {
for j := 0; j < 100; j++ {
_ = c.Get(fmt.Sprintf("key-%d", j))
}
})
}
wg.Wait()
}
2. Run Tests Multiple Times
Races are non-deterministic. Running tests multiple times increases detection probability:
3. Stress Test Concurrent Code
// Illustrative snippet — not a complete program
func TestCacheStress(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}
c := NewCache()
var wg sync.WaitGroup
// 100 concurrent workers, each doing 1000 operations
for i := 0; i < 100; i++ {
wg.Go(func() {
for j := 0; j < 1000; j++ {
c.Set(fmt.Sprint(i), fmt.Sprint(j))
}
})
wg.Go(func() {
for j := 0; j < 1000; j++ {
_ = c.Get(fmt.Sprint(i))
}
})
}
wg.Wait()
}
Common Patterns and Fixes
Pattern 1: Shared Counter
// Illustrative snippet — not a complete program
// ✗ RACE
var count int
go func() { count++ }()
// ✓ FIX: Use atomic
var count atomic.Int64
go func() { count.Add(1) }()
Pattern 2: Shared Map
// Illustrative snippet — not a complete program
// ✗ RACE: concurrent write + read on map (undefined behavior)
var cache = make(map[string]string)
go func() { cache["a"] = "1" }()
go func() { _ = cache["b"] }()
// ✓ FIX: Use mutex
var (
cache = make(map[string]string)
mu sync.Mutex
)
go func() { mu.Lock(); cache["a"] = "1"; mu.Unlock() }()
go func() { mu.Lock(); _ = cache["b"]; mu.Unlock() }()
Go’s map implementation detects some concurrent writes and
panics with
fatal error: concurrent map writes. This is separate
from the race detector—it’s the map protecting itself
from corruption. Don’t rely on this panic as protection;
always synchronize access.
Pattern 3: Done Flag
// Illustrative snippet — not a complete program
// ✗ RACE
var done bool
go func() { doWork(); done = true }()
for !done { time.Sleep(time.Millisecond) }
// ✓ FIX: Use channel
done := make(chan struct{})
go func() { doWork(); close(done) }()
<-done
Pattern 4: Lazy Initialization
// Illustrative snippet — not a complete program
// ✗ RACE
var config *Config
func getConfig() *Config {
if config == nil {
config = loadConfig()
}
return config
}
// ✓ FIX (Go 1.21+): sync.OnceValue owns both the guard and the
// value, so there is no package-level variable left to race on.
var getConfig = sync.OnceValue(loadConfig)
// ✓ Same guarantee the long way, if you need to do more than
// return one value.
var (
config *Config
configOnce sync.Once
)
func getConfigSlow() *Config {
configOnce.Do(func() { config = loadConfig() })
return config
}
Debugging Tips
Reproduce Flaky Races
Races are timing-dependent. If a race appears intermittently:
When Races Aren’t Reported
If you suspect a race but the detector is silent:
- Check test coverage. Does the test exercise the racy code path?
-
Run multiple times. Races are probabilistic:
go test -race -count=100 - Widen race windows. Add artificial delays during debugging to increase overlap probability.
- Check feature flags. Is the racy code behind a flag that’s disabled in tests?
Races in Third-Party Code
If a race report points to library code:
- Check if you’re using the library correctly (most likely)
- Check if there’s a known issue in the library
- Report the bug if it’s genuinely in the library
Common Mistakes
-race”
-race in testing
-race later”
-race from project start
-race in production
-race in testing only
Key Takeaways
-
Always use
-racein development and CI—it’s your best defense against data races - The detector finds executed races only—high test coverage is essential for reducing false negatives
-
It detects data races, not race conditions—passing
-racedoesn’t prove correctness - Fix every reported race—there are no false positives and no “acceptable” data races
- Read reports from your code outward—skip runtime internals, focus on your functions
- Multiple reports may share one cause—fix the first, re-run, and see which others disappear
- Avoid race-enabled builds in production—use them in development, CI, and staging/canary environments
Next: §8.4 covers the Go memory model—the formal rules that define when one goroutine’s writes become visible to another, and why synchronization is necessary for correct concurrent programs.
8.4 The Go Memory Model
§§8.1–8.3 established that data races cause undefined behavior and showed you how to detect them. But why is unsynchronized access so dangerous? Why can’t goroutines just read the latest value written by another goroutine?
The answer lies in Go’s memory model—a set of formal rules that define when a write in one goroutine is guaranteed to be visible to a read in another. Without synchronization, you have no guarantees about what values a goroutine will see. Not “probably correct,” not “eventually consistent”—no guarantees at all.
A statement of what the memory model is for: it answers when a write in one goroutine is guaranteed visible to a read in another, and the answer is when a happens-before relationship exists between them. Without one, writes may not become visible, reads may see stale or partial values, and word-sized reads still see some real write while wider values can tear. With one, writes are guaranteed visible to subsequent reads and behavior is predictable — and establishing that relationship is exactly what synchronization does.
The Go memory model is a contract between you and the Go runtime:
- You promise: To use proper synchronization when sharing data
- Go promises: To make writes visible according to happens-before rules
Break your promise (write racy code), and Go’s promises are void—undefined behavior ensues.
The Go memory model was significantly revised in Go 1.19 (2022) to
formally document guarantees that were previously only informally
understood—particularly for sync/atomic
operations. This section reflects the current (post-1.19)
specification. If you encounter older resources that say atomics
don’t provide ordering guarantees, they are outdated.
Why Memory Models Exist
You might think: “If I write to a variable, then another goroutine reads it, it should see my write.” This intuition is wrong for three reasons:
1. Compiler Reordering
Recall the reordering example from §8.1—now we can explain why it happens. The compiler optimizes code by reordering operations that appear independent:
// Illustrative snippet — not a complete program
// What you wrote:
data = 42
ready = true
// What the compiler might generate:
ready = true
data = 42 // Reordered—no dependency between them
Within a single goroutine, this reordering is invisible—your
code behaves as if operations happened in order. But another goroutine
observing these variables might see
ready = true before data = 42.
The compiler may also:
- Cache variables in registers (never reading from memory)
- Eliminate “redundant” reads
- Hoist loop-invariant loads outside loops
2. CPU Reordering
Modern CPUs execute instructions out of order for performance. Even if the compiler preserved your order, the CPU might not:
// Illustrative snippet — not a complete program
// Compiled order:
data = 42
ready = true
// CPU execution on a weakly-ordered machine (arm64, ppc64):
Store ready = true
Store data = 42 // Reordered; x86-64 is TSO and will not
// do this, which is why the same racy
// program can pass on a laptop and fail
// on an arm64 server.
3. Cache Visibility
Each CPU core has its own cache. A write might sit in one core’s cache without being visible to other cores:
Two CPU cores, each with its own L1 cache, both sitting above main memory. Core 1's cache holds the new values; core 2's cache still holds the old ones and is marked stale; main memory may reflect neither. Core 2 can keep reading its own stale cache while core 1's writes have not left core 1.
The memory model exists because modern hardware and compilers optimize aggressively. These optimizations are invisible within a single goroutine but become visible—and dangerous—when multiple goroutines share data.
The Happens-Before Relationship
The memory model is built on one core concept: happens-before.
Definition: If event A happens before event B, then A’s memory effects are guaranteed to be visible to B.
Key insight: Happens-before is established by synchronization operations, not by wall-clock time. Two operations can occur at different times but still have no happens-before relationship.
Two scenarios contrasting happens-before with clock time. In the first, two goroutines write at the same instant, then one sends on a channel and the other receives; the send-receive pair creates happens-before, so the receiver is guaranteed to see the other's write despite there being no time gap. In the second, one goroutine writes and another reads a full second later with no synchronization between them; there is no happens-before, so the reader may see either value. Wall-clock ordering guarantees nothing.
Key properties of happens-before:
- Transitive: If A happens-before B, and B happens-before C, then A happens-before C
- Within a goroutine: Each statement happens-before the next (program order)
- Across goroutines: Only synchronization creates happens-before edges
What Establishes Happens-Before
Go’s memory model defines exactly which operations create happens-before relationships:
1. Within a Single Goroutine
Statements execute in program order. Each statement happens-before the next:
// Illustrative snippet — not a complete program
func singleGoroutine() {
x := 1 // A
y := x + 1 // B: A happens-before B
z := y + 1 // C: B happens-before C
}
This is the only case where “earlier in code” means “happens before.”
2. Goroutine Creation
The go statement happens before the new goroutine starts:
// Illustrative snippet — not a complete program
x := 42
go func() {
fmt.Println(x) // Guaranteed to see x = 42
}()
Happens-before chain: x = 42 →
go statement → goroutine’s first instruction.
3. Channel Operations
Send and receive: A send on a channel happens before the corresponding receive completes. This applies to both buffered and unbuffered channels.
// Illustrative snippet — not a complete program
var data int
ch := make(chan struct{})
go func() {
data = 42 // (1)
ch <- struct{}{} // (2) send
}()
<-ch // (3) receive—happens after (2)
fmt.Println(data) // (4) guaranteed to see 42
Channel close: Close happens before a receive that returns the zero value due to closure.
// Illustrative snippet — not a complete program
var data int
ch := make(chan struct{})
go func() {
data = 42
close(ch) // Close happens before receive of zero value
}()
<-ch // Returns immediately (channel closed)
fmt.Println(data) // Guaranteed to see 42
Unbuffered channels (additional guarantee): For unbuffered channels, send and receive must rendezvous—neither completes until both are ready. This gives an additional guarantee: the receive happens-before the send completes. In practice, this means both sides can observe each other’s prior writes after the rendezvous:
// Illustrative snippet — not a complete program
ch := make(chan int) // Unbuffered
var data int
go func() {
data = 42
<-ch // (1) Receive happens before send completes
}()
ch <- 1 // (2) Send completes after (1)
fmt.Println(data) // Guaranteed to see 42
This bidirectional synchronization doesn’t apply to buffered channels, where sends complete immediately if buffer space is available.
4. Mutex Operations
Unlock() happens before any subsequent
Lock() on the same mutex:
// Illustrative snippet — not a complete program
var (
mu sync.Mutex
data int
)
mu.Lock() // (1) Main acquires first
go func() {
mu.Lock() // (3) Blocks until (2)
fmt.Println(data) // Guaranteed to see 42
mu.Unlock()
}()
data = 42 // (1b) Write while holding lock
mu.Unlock() // (2) Unlock happens-before goroutine's Lock
A timeline with two rows. The main goroutine locks, writes, and unlocks. An arrow labeled happens-before runs from that unlock down to a second goroutine's lock, after which it reads. The unlock-lock sequence is what creates the edge, and every write before the unlock is visible after the subsequent lock.
5. sync.Once
The function passed to Do() completes before any
Do() call returns:
// Illustrative snippet — not a complete program
var (
once sync.Once
config *Config
)
func getConfig() *Config {
once.Do(func() {
config = loadConfig() // Happens before any Do() returns
})
return config // Guaranteed to see initialized config
}
Since Go 1.21 there is a shorter spelling of exactly this.
sync.OnceValue(loadConfig) returns a function with the
same happens-before guarantee and no package-level variable to protect
— the thing being guarded stops being reachable without going
through the guard. sync.OnceFunc does the same for a
function that returns nothing. Use the explicit
sync.Once when the initializer has to set several things
at once; use OnceValue the rest of the time.
6. sync.WaitGroup
Done() happens before Wait() returns:
// Illustrative snippet — not a complete program
var (
wg sync.WaitGroup
data int
)
wg.Add(1)
go func() {
data = 42
wg.Done() // Happens before Wait returns
}()
wg.Wait()
fmt.Println(data) // Guaranteed to see 42
This example keeps Add and Done visible
because they are what the rule is stated in terms of. In your own
code, wg.Go(func(){…}) — used everywhere
else in this book since Go 1.25 — does the pair for you and
carries the identical guarantee: whatever the function writes is
visible after Wait returns.
7. Atomic Operations
Operations in sync/atomic establish happens-before. An
atomic store happens before any atomic load that observes the stored
value:
// Illustrative snippet — not a complete program
var (
data int
ready atomic.Bool
)
go func() {
data = 42 // (1) Program order within goroutine
ready.Store(true) // (2) Atomic store
}()
for !ready.Load() { // (3) Atomic load—loops until (2) observed
time.Sleep(time.Microsecond) // Brief sleep to avoid busy-spinning
}
fmt.Println(data) // (4) Guaranteed to see 42
Happens-before chain:
- Step 1 → Step 2: Program order within goroutine
- Step 2 → Step 3: Atomic store happens-before the load that observes it
- Step 3 → Step 4: Program order within goroutine
- Therefore: Step 1 → Step 4 (by transitivity)
Atomic operations provide:
- Atomicity: The operation is indivisible (no torn reads/writes)
- Memory ordering: Establishes happens-before relationships
The second guarantee is why the ready/data
pattern works—the atomic store of ready makes
all preceding writes (including data = 42) visible to
any goroutine that observes ready == true via atomic
load.
Without both guarantees, you’d need a mutex even for simple flags.
8. Package Initialization
Package init() functions and variable initialization
happen before main() starts:
// Illustrative snippet — not a complete program
var config = loadConfig() // Happens before main()
func main() {
use(config) // Guaranteed to see initialized config
}
A goroutine finishing does not create a
happens-before relationship with any other goroutine. To observe a
goroutine’s results, you must use explicit
synchronization—a channel, sync.WaitGroup, or
another primitive from this list.
Happens-Before Summary
go statement → goroutine starts
Unlock() → subsequent Lock()
f() completes → all Do() calls
return
Done() → Wait() returns
init() completes → main()
starts
What the Memory Model Does NOT Guarantee
Understanding the limits is as important as understanding the guarantees.
No Guarantee: Time-Based Ordering
// Illustrative snippet — not a complete program
x := 0
go func() {
time.Sleep(time.Second)
fmt.Println(x) // NOT guaranteed to see 1
}()
x = 1
time.Sleep(2 * time.Second) // "Surely it's visible by now"—WRONG
Sleep is not synchronization. There’s no happens-before relationship.
No Guarantee: Busy-Wait on Regular Variables
// Illustrative snippet — not a complete program
// ✗ BROKEN: Regular bool has no ordering guarantee
var done bool
var result int
go func() {
result = compute()
done = true // DATA RACE
}()
for !done { // DATA RACE
// spin
}
fmt.Println(result)
This is undefined behavior. The compiler might:
-
Cache
donein a register, never seeing the update - Optimize the loop to
if !done { for {} } -
Reorder
done = truebeforeresult = compute()
Fixes:
// Illustrative snippet — not a complete program
// ✓ FIX 1: Atomic operations
var done atomic.Bool
var result int
go func() {
result = compute()
done.Store(true) // Atomic store establishes happens-before
}()
for !done.Load() {
time.Sleep(time.Microsecond) // Brief sleep to avoid busy-spinning
}
fmt.Println(result) // Guaranteed to see computed result
// Illustrative snippet — not a complete program
// ✓ FIX 2: Channel (preferred—blocking is better than polling)
var result int
done := make(chan struct{})
go func() {
result = compute()
close(done)
}()
<-done
fmt.Println(result) // Guaranteed to see computed result
- Atomic: Use when you need to poll a flag frequently
- Channel: Use when a one-time signal is sufficient (preferred in most cases—blocking is better than polling)
No Guarantee: Observing Partial Updates
Without synchronization, you might observe writes in a different order than they occurred:
// Illustrative snippet — not a complete program
var a, b int
go func() {
a = 1
b = 2
}()
go func() {
if b == 2 {
fmt.Println(a) // Might print 0!
}
}()
This is a data race—both goroutines access a and
b without synchronization. Even observing
b == 2 doesn’t guarantee a == 1 is
visible:
-
Compiler might reorder the writes:
b = 2thena = 1 -
Compiler might reorder the reads: read
a, then checkb == 2 -
CPU caches might make
b = 2visible beforea = 1
All three scenarios are legal—no synchronization means no happens-before, which means no guarantees.
The Double-Checked Locking Anti-Pattern
A common mistake is attempting “double-checked locking” for lazy initialization:
// Illustrative snippet — not a complete program
// ✗ BROKEN: Double-checked locking
var instance *Singleton
var mu sync.Mutex
func GetInstance() *Singleton {
if instance == nil { // First check—no synchronization!
mu.Lock()
if instance == nil {
instance = &Singleton{}
}
mu.Unlock()
}
return instance
}
The first nil check has no happens-before relationship
with the write inside the lock. A goroutine might:
- See a non-nil pointer to a struct whose fields aren’t yet visible
- See stale nil even after initialization completes
- Observe inconsistent values across multiple reads
Fix: Use sync.Once
// Illustrative snippet — not a complete program
// ✓ CORRECT: sync.Once handles all synchronization
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
})
return instance
}
Why this works: The function in Do() happens-before
Do() returns in any goroutine. All callers see the fully
initialized instance.
Connecting the Memory Model to Data Races
Now we can precisely state why data races cause undefined behavior:
A reconciliation of two definitions. Section 8.1 defined a data race as the same memory plus a write plus no synchronization; the memory model defines it as the same memory plus a write plus no happens-before. They are the same statement, because synchronization *is* the establishing of happens-before. Below, how the race detector uses that: it tracks every memory access and every synchronization operation, builds the happens-before graph, and reports a race wherever two concurrent accesses have no path between them.
Why the Compiler Can’t “Just Be Safe”
A common question: “Why doesn’t the compiler insert memory barriers everywhere?”
Answer: Performance. Memory barriers and synchronization are expensive.
// Illustrative snippet — not a complete program
// Hot loop—runs billions of times
for i := 0; i < 1_000_000_000; i++ {
localSum += data[i]
}
globalSum = localSum
The compiler optimizes this aggressively:
-
Keep
localSumin a register (never write to memory) - Reorder and vectorize loads from
data[] - Unroll the loop
If the compiler assumed every operation could be observed by another goroutine, these optimizations would be impossible. Performance would drop by 10–100× for some workloads.
Go’s contract: You mark shared data with synchronization; Go optimizes everything else aggressively. This is why data races cause undefined behavior—the compiler assumes no races exist.
Test Your Understanding
Example 1: Package-level initialization
// Illustrative snippet — not a complete program
var config = loadConfig()
func Handler(w http.ResponseWriter, r *http.Request) {
use(config)
}
Safe? Yes—package-level variable initialization
happens before main() starts, which happens before any
HTTP handlers run. The handlers (which run in goroutines spawned by
the server) are guaranteed to see the initialized config.
Example 2: Atomic flag guarding data
// Illustrative snippet — not a complete program
var ready atomic.Bool
var data int
go func() {
data = 42
ready.Store(true)
}()
for !ready.Load() {
time.Sleep(time.Microsecond)
}
fmt.Println(data)
Safe? Yes—the atomic store happens before the
load that observes true. Since
data = 42 happens before the store (program order),
it’s visible after the load.
Example 3: Goroutine exit without sync
// Illustrative snippet — not a complete program
var x int
go func() {
x = 1
}()
time.Sleep(time.Second)
fmt.Println(x)
Safe? No—this is a data race. There’s no
happens-before between the write (x = 1) and the read
(fmt.Println(x)). Sleep doesn’t establish
synchronization, and goroutine completion alone doesn’t create
happens-before.
Fixes:
// Illustrative snippet — not a complete program
// ✓ FIX: Use WaitGroup
var x int
var wg sync.WaitGroup
wg.Go(func() {
x = 1
})
wg.Wait()
fmt.Println(x) // Guaranteed to see 1
// ✓ FIX: Use channel
var x int
done := make(chan struct{})
go func() {
x = 1
close(done)
}()
<-done
fmt.Println(x) // Guaranteed to see 1
Example 4: Transitive happens-before
// Illustrative snippet — not a complete program
var a, b int
ch1 := make(chan struct{})
ch2 := make(chan struct{})
go func() { a = 1; close(ch1) }()
go func() { <-ch1; b = 2; close(ch2) }()
<-ch2
fmt.Println(a, b)
Safe? Yes—happens-before chains compose through transitivity:
// Illustrative snippet — not a complete program
a=1 → close(ch1) → <-ch1 → b=2 → close(ch2) → <-ch2 → read a,b
Both values are guaranteed visible.
Common Mistakes
time.Sleep for sync
bool flag
atomic.Bool or channel
sync.Once
Key Takeaways
- Happens-before defines visibility—not wall-clock time, not source code order across goroutines
- Across goroutines, only synchronization creates ordering—without it, compilers reorder, CPUs reorder, and caches hide writes
- Channel send happens-before receive completes—channels synchronize naturally
- Mutex unlock happens-before subsequent lock—protecting data and establishing order
- Atomics provide atomicity AND memory ordering—both guarantees matter (formalized in Go 1.19)
- sync.Once is for lazy initialization—never hand-roll double-checked locking
- Goroutine exit is not synchronization—use channels, WaitGroup, or other primitives to observe results
Next: §8.5 explores strategies for preventing data races—confinement, immutability, and synchronization—giving you a toolkit for designing race-free concurrent programs.
8.5 Preventing Data Races
§§8.1–8.4 explained what data races are, how they differ from race conditions, how to detect them, and why the memory model makes them dangerous. Now we turn to prevention: how do you design concurrent programs that are race-free by construction?
Recall the three conditions for a data race from §8.1:
- Two or more goroutines access the same memory location
- At least one access is a write
- The accesses are not synchronized
Remove any condition, and there’s no race. This gives us three prevention strategies:
A mapping from each of the three race conditions to the strategy that eliminates it. Same memory location is eliminated by confinement, at least one write by immutability, and no synchronization by adding synchronization.
The easiest data race to fix is the one that never exists. Before reaching for mutexes or channels, ask: “Does this data really need to be shared?” Often the answer is no.
Strategy 1: Confinement
Confinement means restricting data access to a single goroutine. If only one goroutine can access a variable, concurrent access is impossible—no race can occur.
Lexical Confinement
Variables scoped to a single goroutine are automatically confined:
// Illustrative snippet — not a complete program
func handler(w http.ResponseWriter, r *http.Request) {
// Each handler call runs in its own goroutine
userID := r.URL.Query().Get("user") // Confined to this goroutine
data := fetchData(userID) // Confined to this goroutine
render(w, data)
}
Variables userID and data are local to each
handler invocation. Even though handlers run concurrently, each has
its own copy. No shared state = race-free by construction.
Lexical confinement is the default in Go. Unless you explicitly share data via pointers, package variables, or closure captures, data is automatically confined.
When passing data to a goroutine, pass by value to create a confined copy:
// Illustrative snippet — not a complete program
// ✗ WRONG on Go <1.22: one shared loop variable
for _, item := range items {
go func() {
process(item) // DATA RACE on Go <1.22
}()
}
// ✓ CORRECT on this book's Go 1.25 baseline: item is already
// per-iteration, so the plain closure confines it.
for _, item := range items {
go func() {
process(item)
}()
}
// Also correct, and portable to pre-1.22 toolchains: pass it in.
for _, item := range items {
go func(it Item) {
process(it)
}(item)
}
Which one to write. Go 1.22 made the loop variable per-iteration, so on this book’s baseline the middle form is confined and is what chapters 1–7 use throughout. Reach for the parameter form when you must build on a pre-1.22 toolchain, or when the value you want to confine is not the loop variable — passing it in is still the clearest way to say “this goroutine gets its own copy.”
Ownership Transfer via Channels
Channels naturally implement confinement through ownership transfer. When you send a value on a channel, you transfer ownership to the receiver:
// Illustrative snippet — not a complete program
func producer(out chan<- *Buffer) {
for {
buf := &Buffer{}
buf.Fill() // Producer owns buf
out <- buf // Ownership transfers to receiver
// Producer must NOT use buf after this point
}
}
func consumer(in <-chan *Buffer) {
for buf := range in {
// Consumer now owns buf exclusively
buf.Process()
buf.Clear()
}
}
An ownership-transfer timeline across three columns. The producer allocates a buffer and fills it, then sends it on the channel; ownership transfers with the value. The producer is marked as no longer permitted to touch it. The consumer receives, acquires ownership, and processes and clears it. At any moment exactly one goroutine owns the buffer, so there is no concurrent access and no race.
Go doesn’t enforce ownership transfer—it’s a
design discipline. The compiler won’t stop you from using
buf after sending it. You must design your code so
that senders relinquish access.
// Illustrative snippet — not a complete program
// The compiler allows this, but it's a DATA RACE:
buf := &Buffer{}
ch <- buf
buf.Fill() // Violates ownership—but compiles!
Enforce ownership through code review, clear documentation, and the race detector.
Slices and maps are reference types. Passing them to a goroutine shares the underlying data:
// Illustrative snippet — not a complete program
data := []int{1, 2, 3}
go func() {
data[0] = 100 // Modifies shared backing array
}()
fmt.Println(data[0]) // DATA RACE
To truly confine, either:
-
Copy the slice:
go func(d []int) { ... }(append([]int{}, data...)) - Ensure the original is never accessed after spawning the goroutine
Ad-Hoc Confinement
Sometimes confinement is maintained by convention rather than compiler enforcement. In the example below, the channels themselves provide synchronization—the ad-hoc part is the convention about which goroutine sends and which receives:
// Illustrative snippet — not a complete program
type WorkerPool struct {
// jobs is only written by Submit(), read only by workers
jobs chan Job
// results is only written by workers, read only by Collect()
results chan Result
}
Nothing prevents another goroutine from accessing confined data. Document confinement assumptions clearly:
// Illustrative snippet — not a complete program
type Worker struct {
// stats is confined to the worker goroutine.
// Do not access from other goroutines.
stats WorkerStats
}
Strategy 2: Immutability
Immutability means data doesn’t change after initialization. If there are no writes, concurrent reads are always safe.
Initialize-Once Pattern
The most common approach: initialize data before any concurrent access, then only read:
// Illustrative snippet — not a complete program
// Package-level configuration—initialized before main()
var config = loadConfig() // Happens before any goroutine starts
func Handler(w http.ResponseWriter, r *http.Request) {
// Safe: config is never modified after init
timeout := config.Timeout
endpoint := config.Endpoint
// ...
}
Package initialization happens before main() (§
8.4), establishing happens-before with all subsequent code.
Immutable Value Types
Design types that cannot be modified after creation:
// Illustrative snippet — not a complete program
// Point is immutable—all fields are private, no mutating methods
type Point struct {
x, y float64
}
func NewPoint(x, y float64) Point {
return Point{x: x, y: y}
}
func (p Point) X() float64 { return p.x }
func (p Point) Y() float64 { return p.y }
// "Mutation" returns a new Point—original unchanged
func (p Point) Translate(dx, dy float64) Point {
return Point{x: p.x + dx, y: p.y + dy}
}
Using value receivers (not pointer receivers) helps maintain immutability—methods receive a copy and cannot mutate the original. Returning new values is the only way to “change” state.
Two versions of the same counter type. The mutable one has a pointer receiver whose Increment method mutates the field in place, which is race-prone. The immutable one has a value receiver whose Increment returns a new Counter rather than modifying the old, so it can be shared freely — at the cost of having to propagate the new value somewhere.
Copy-on-Read for Safe Sharing
When readers need shared data that occasionally changes, return copies:
// Illustrative snippet — not a complete program
type ConfigManager struct {
mu sync.RWMutex
config Config
}
func (cm *ConfigManager) Get() Config {
cm.mu.RLock()
defer cm.mu.RUnlock()
return cm.config // Returns a copy—caller gets immutable snapshot
}
func (cm *ConfigManager) Update(new Config) {
cm.mu.Lock()
defer cm.mu.Unlock()
cm.config = new
}
Callers receive a copy they own—they can use it freely without synchronization.
Returning a struct creates a shallow copy. If
Config contains slices, maps, or pointers, those
fields still reference shared data:
// Illustrative snippet — not a complete program
type Config struct {
Timeout time.Duration // Safe: value type
Servers []string // Danger: backing array shared!
}
For reference types, return defensive copies:
// Illustrative snippet — not a complete program
func (cm *ConfigManager) GetServers() []string {
cm.mu.RLock()
defer cm.mu.RUnlock()
result := make([]string, len(cm.config.Servers))
copy(result, cm.config.Servers)
return result
}
Copy-on-Write
For data read frequently but written rarely, copy the entire structure on write:
// Illustrative snippet — not a complete program
type Registry struct {
mu sync.RWMutex
services map[string]Service
}
func (r *Registry) Get(name string) (Service, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
svc, ok := r.services[name]
return svc, ok
}
func (r *Registry) Register(name string, svc Service) {
r.mu.Lock()
defer r.mu.Unlock()
// Copy-on-write: create new map with added entry
newServices := make(map[string]Service, len(r.services)+1)
for k, v := range r.services {
newServices[k] = v
}
newServices[name] = svc
r.services = newServices
}
// List returns a consistent snapshot—this is the CoW payoff.
// A concurrent Register() replaces the map pointer, but this
// iteration continues safely on the old (immutable) copy.
func (r *Registry) List() []string {
r.mu.RLock()
snapshot := r.services
r.mu.RUnlock()
names := make([]string, 0, len(snapshot))
for name := range snapshot {
names = append(names, name)
}
return names
}
This example combines mutex synchronization with copy-on-write. Each serves a different purpose:
- Mutex synchronization: Coordinates access (prevents simultaneous reads during write)
- Copy-on-write: Ensures readers who obtained a map reference before this write still see a consistent (old) snapshot
For truly lock-free reads (no mutex on read path), use atomic pointer swap (shown next).
A copy-on-write timeline in three steps. At T0 a map version 1 exists and two readers take references to it. At T1 a writer builds version 2 as a copy plus the new entry and swaps the pointer; the two existing readers keep using version 1 as a consistent snapshot while a third reader picks up version 2. At T2 the first two readers finish and version 1 becomes garbage. Every reader sees a complete, consistent snapshot.
Pros: Readers see consistent snapshots; old references remain valid
Cons: Write cost is O(n) where n = number of entries; 2× memory during writes
Best for: Read-heavy workloads (reads >> writes), small to medium collections
Atomic Pointer Swap for Lock-Free Reads
The mutex-protected copy-on-write above requires acquiring a read lock for every access. For extremely read-heavy workloads (thousands of reads per write), you can eliminate read-side locking entirely using atomic pointer swap:
// Illustrative snippet — not a complete program
type Server struct {
config atomic.Pointer[Config]
}
func NewServer(cfg *Config) *Server {
s := &Server{}
s.config.Store(cfg)
return s
}
func (s *Server) Config() *Config {
return s.config.Load() // Lock-free read
}
func (s *Server) UpdateConfig(new *Config) {
s.config.Store(new) // Atomic swap
}
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
cfg := s.Config() // Get immutable snapshot
// Use cfg freely—even if UpdateConfig is called, our pointer
// still points to the old config which remains valid
timeout := cfg.Timeout
// ...
}
When using atomic.Pointer[T], the pointed-to value
must be immutable. If callers can mutate the
value, you’ve traded one race for another:
// Illustrative snippet — not a complete program
cfg := server.Config()
cfg.Timeout = 100 // DATA RACE if another goroutine reads cfg
Make T immutable by design: unexported fields, no setter
methods, no mutable reference-type fields.
Strategy 3: Synchronization
When data must be shared and mutable, use synchronization primitives to coordinate access. This establishes the happens-before relationships covered in §8.4.
Choosing the Right Primitive
sync.Mutex
sync.RWMutex
sync/atomic
sync.Once
sync.WaitGroup
Brief Examples
// Illustrative snippet — not a complete program
// Channels: Communication and ownership transfer
results := make(chan Result)
go func() { results <- process(item) }()
result := <-results
// Mutex: Complex shared state
var mu sync.Mutex
var balance int
mu.Lock()
balance += 100
mu.Unlock()
// Atomic: Simple counters (lower overhead than mutex)
var counter atomic.Int64
counter.Add(1)
// Once: Lazy initialization
var once sync.Once
var instance *Service
once.Do(func() { instance = newService() })
The shorter the critical section, the better the concurrency:
// Illustrative snippet — not a complete program
// ✗ BAD: Holding lock during slow I/O
func (c *Cache) GetOrLoad(key string) string {
c.mu.Lock()
defer c.mu.Unlock()
if val, ok := c.data[key]; ok {
return val
}
val := loadFromDatabase(key) // Slow! Lock held entire time
c.data[key] = val
return val
}
// ✓ GOOD: Only lock for map access
// Note: Two goroutines may both miss the cache and load the same key.
// This is a race condition (duplicate work), not a data race.
// For most caches, duplicate loads are acceptable.
func (c *Cache) GetOrLoad(key string) string {
c.mu.Lock()
val, ok := c.data[key]
c.mu.Unlock()
if ok {
return val
}
val = loadFromDatabase(key) // No lock during I/O
c.mu.Lock()
c.data[key] = val
c.mu.Unlock()
return val
}
Detailed synchronization patterns are covered in Chapters 9–12.
Choosing a Strategy
A decision flowchart. Ask first whether the data can be confined to a single goroutine; if yes, use confinement via goroutine-local variables, ownership transfer or lexical scoping. If not, ask whether it is read-only after initialization; if yes, use immutability via initialize-before-start, copy-on-read snapshots or an atomic pointer swap. If neither, synchronize — channels for communication, a mutex for complex state, an RWMutex when reads dominate, atomics for simple values, and sync.Once for one-time initialization.
Combining Strategies
Real programs combine all three strategies, choosing the right approach for each piece of data:
// Illustrative snippet — not a complete program
type Server struct {
config *Config // IMMUTABLE after construction
addr string // IMMUTABLE after construction
shutdownCh chan struct{} // SYNCHRONIZED via channel ops
totalReqs atomic.Int64 // SYNCHRONIZED with atomics
mu sync.RWMutex // Protects sessions below
sessions map[string]*Session // SYNCHRONIZED with mutex
}
func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) {
// Atomic: simple increment
s.totalReqs.Add(1)
// Immutable: config never changes after construction
timeout := s.config.Timeout
// Confined: request data is local to this handler
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
// Synchronized: session map requires mutex
s.mu.RLock()
session := s.sessions[getSessionID(r)]
s.mu.RUnlock()
// Process request...
}
One server split into three pieces of state, each with a different strategy: an immutable config, an atomic request counter, and a mutex-guarded session map. Each component uses whatever suits its access pattern, and the three compose without interfering.
The Four Questions Revisited
Chapter 2 introduced the Four Questions for every goroutine. Question 4—“What data does it access?”—now has a complete framework:
If you can’t answer how shared mutable data is protected, you have a potential race.
Anti-Patterns to Avoid
Shared-state signaling: As we saw in §8.4,
polling a regular bool variable is a data race—use
a channel or atomic.Bool instead.
Common Mistakes
Ownership transferred—you no longer own it
Never access after sending
Pointed-to data can still change
Deep copy or use value types
Partial protection is no protection
One strategy per data item
Even single reads can race
Always synchronize shared mutable data
Performance cost, complexity
Try confinement/immutability first
Verify Your Strategy
After implementing any of these strategies, run your tests with
-race to verify there are no hidden races:
The race detector (§8.3) is your safety net—use it to confirm your design is correct.
Test Your Understanding
Example 1
// Illustrative snippet — not a complete program
func handleConnection(conn net.Conn) {
defer conn.Close()
buffer := make([]byte, 4096)
for {
n, err := conn.Read(buffer)
if err != nil {
return
}
process(buffer[:n])
}
}
Confinement. buffer is local to
handleConnection—each connection handler has
its own.
Example 2
// Illustrative snippet — not a complete program
var config = &Config{Timeout: 30 * time.Second}
func getTimeout() time.Duration {
return config.Timeout
}
Immutability (by convention).
config is initialized at package level and never
modified, only read. This is safe as long as no code ever writes
to config after initialization.
Example 3
// Illustrative snippet — not a complete program
type RateLimiter struct {
mu sync.Mutex
tokens int
}
func (r *RateLimiter) Allow() bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.tokens > 0 {
r.tokens--
return true
}
return false
}
Synchronization (mutex). Rate limiter state must be shared and mutated by multiple goroutines.
Example 4
// Illustrative snippet — not a complete program
var totalProcessed int
func worker(items <-chan Item) {
for item := range items {
process(item)
totalProcessed++
}
}
// Launch 10 workers
for i := 0; i < 10; i++ {
go worker(items)
}
✗
None—this is a DATA RACE. Multiple
workers write totalProcessed without
synchronization. Fix with atomic.Int64 (preferred
for simple counters—lower overhead than mutex):
// Illustrative snippet — not a complete program
var totalProcessed atomic.Int64
func worker(items <-chan Item) {
for item := range items {
process(item)
totalProcessed.Add(1)
}
}
Summary
Key Takeaways
- Three strategies map to three conditions—confinement (no sharing), immutability (no writes), synchronization (coordinated access)
- Prefer confinement first—if only one goroutine accesses data, no race is possible and there’s zero runtime cost
- Ownership transfer via channels—the idiomatic Go way to move data between goroutines safely
- Beware shallow copies—slices, maps, and pointers share underlying data even after struct copy
- Synchronization is the last resort—necessary for shared mutable state, but keep critical sections small
- Combine strategies per component—config (immutable), counters (atomic), sessions (mutex), requests (confined)
-
Verify with the race detector—always run
go test -raceto confirm your design
Next: Test your understanding with the chapter self-check below.
Chapter 8 Self-Check
Test your understanding of the complete chapter with these questions. Click each question to reveal the answer.
Three conditions: (1) Two or more goroutines access the same memory location, (2) At least one access is a write, (3) The accesses are not synchronized. All three must be true simultaneously.
sync.Mutex to protect all reads
and writes to a map. The race detector reports no races. Can the
code still have a race condition?
Yes, it can still have a race condition. A mutex prevents data races (unsynchronized memory access) but not race conditions (timing-dependent correctness). For example, check-then-act patterns can have race conditions even when each individual operation is mutex-protected—if the mutex is released between the check and the act, another goroutine can intervene.
Wall-clock time means one event occurred earlier in real time. Happens-before is a formal guarantee from the memory model that one operation’s effects are visible to another. Wall-clock ordering provides no visibility guarantees—a write at T=0 is not guaranteed to be visible to a read at T=1 without synchronization. Happens-before requires explicit synchronization (channels, mutexes, atomics).
4. You see this pattern in code review. What’s wrong, and how would you fix it?
// Illustrative snippet — not a complete program
var ready bool
var data int
go func() {
data = 42
ready = true
}()
for !ready {}
fmt.Println(data)
Two problems: (1) Data race on both
ready and data—unsynchronized
concurrent access. (2) The compiler may cache
ready in a register, causing an infinite loop.
Fixes: Use atomic.Bool for
ready (which also establishes happens-before for
data), or use a channel to signal completion
(preferred—blocking beats polling).
One read + one write = race. A data race requires at least one write and one read (or two writes) to the same location without synchronization. It doesn’t matter how many times each goroutine accesses the variable—even a single unsynchronized read racing with a single write is undefined behavior.
6. Which of these establishes a happens-before relationship?
- a)
time.Sleep(time.Second) - b) Channel send/receive
- c) Earlier wall-clock time
- d)
sync.MutexLock/Unlock
b) and d) Channel send/receive and mutex
Lock/Unlock establish happens-before relationships.
time.Sleep does not create
happens-before—it’s just a delay, not
synchronization. Earlier wall-clock time provides no memory
model guarantees.
Immutability with atomic pointer swap. Since
reads vastly outnumber writes (every request vs. once per hour),
use atomic.Pointer[Config] to allow lock-free
reads. On update, create a new Config struct and
atomically swap the pointer. The pointed-to config must be
immutable after creation.
No, it doesn’t prove absence of races.
The race detector only finds races that actually execute during
the test run. Races in untested code paths, races that require
specific timing, or races that only manifest under production
load won’t be detected. Passing -race is
necessary but not sufficient—it proves no races were
observed, not that none exist.
Exit code 0: tests passed, no races detected.
Exit code 66: the race detector’s own
code, reported when you run a go build -race binary
directly. go test -race and
go run -race wrap that binary and exit
1 instead — go run prints
exit status 66 on its way out, which is where the
confusion comes from. Standalone -race binaries log
races to stderr and keep running by default, so they reach 66
only at exit. Have CI fail on any non-zero status rather than on
66.
10. For each scenario, identify the best prevention strategy (confinement, immutability, or synchronization):
- a) Request-scoped variables in an HTTP handler
- b) Application version string set at startup
- c) Active user session count
- a) Request-scoped variables: Confinement—each handler goroutine has its own local variables
- b) Application version string: Immutability—set once at startup, never modified, read everywhere
-
c) Active user session count:
Synchronization—multiple goroutines
increment/decrement, use
atomic.Int64
Chapter 8 Complete. You now understand data races comprehensively:
- 8.1: What data races are (three conditions)
- 8.2: How they differ from race conditions
- 8.3: How to detect them (race detector)
- 8.4: Why they’re dangerous (memory model)
- 8.5: How to prevent them (confinement, immutability, synchronization)
Next chapter: Chapter 9 covers
sync.Mutex and sync.RWMutex—protecting
shared state with mutual exclusion, patterns for correct usage, and
common pitfalls to avoid.
Exercise 8.1 — Make the Handover Real
Give the two writes an edge the reader can stand on
Publisher hands one value from a producer to a
consumer using a plain bool and a plain
int. Nothing in the file establishes happens-before
between the two writes and the two reads, so a reader can see
done without seeing data.
This is the first exercise in the book whose starter passes
go test.
Run it and it will tell you everything is fine, ten times out of
ten. Run go test -race and it fails every time. That
gap is the chapter in one command — a green test suite says
nothing about whether your code is race-free unless the detector
was switched on.
package ch08
// TODO(reader): Publisher hands one value from a producer to a
// consumer goroutine. It works, most of the time, on most machines
// — which is the whole problem.
//
// `done` is a plain bool and `data` is a plain int, and nothing in
// this file establishes a happens-before relationship between the two
// writes in Publish and the two reads in Wait. §8.4 lists everything
// that would: a channel, a mutex, sync.Once, a WaitGroup, an atomic.
// This file uses none of them.
//
// Two consequences, and the tests below check for both:
//
// 1. `go test -race` reports a data race. That gate does not care
// how lucky your timing was.
// 2. Wait can observe `done == true` while `data` is still 0,
// because nothing orders the two writes against the two reads.
//
// Fix it with any mechanism from §8.4 that gives you a happens-before
// edge. Do not add a sleep: sleeping is not synchronization (§8.4,
// "No Guarantee: Time-Based Ordering"), and the race detector will
// still flag it.
type Publisher struct {
data int
done bool
}
func (p *Publisher) Publish(v int) {
p.data = v
p.done = true
}
// Wait blocks until Publish has been called, then returns the value.
func (p *Publisher) Wait() int {
for !p.done {
// spin
}
return p.data
}
package ch08
import (
"testing"
"time"
)
// The value handed over must be the value published. A torn handover
// shows up here as a zero: Wait saw done before it saw data.
func TestPublisherHandsOverTheValue(t *testing.T) {
for round := 0; round < 200; round++ {
p := &Publisher{}
got := make(chan int, 1)
go func() { got <- p.Wait() }()
go p.Publish(42)
select {
case v := <-got:
if v != 42 {
t.Fatalf("round %d: Wait returned %d, want 42. "+
"Wait saw done before it saw data — nothing "+
"ordered the writes against the reads. See §8.4.",
round, v)
}
case <-time.After(2 * time.Second):
t.Fatal("Wait never returned: the reader is still " +
"spinning on a value it may never observe. §8.4.")
}
}
}
// Several publishers and readers at once. This is the shape the race
// detector needs to see the conflicting accesses.
func TestPublisherUnderConcurrentLoad(t *testing.T) {
for round := 0; round < 100; round++ {
p := &Publisher{}
done := make(chan int, 4)
for i := 0; i < 3; i++ {
go func() { done <- p.Wait() }()
}
go p.Publish(7)
deadline := time.After(2 * time.Second)
for i := 0; i < 3; i++ {
select {
case v := <-done:
if v != 7 {
t.Fatalf("round %d: got %d, want 7", round, v)
}
case <-deadline:
t.Fatal("a reader never returned. See §8.4.")
}
}
}
}
go test -race ./... in
code/ch08/ reports ok. Use any mechanism
from §8.4 that gives you a happens-before edge. Do not add a
time.Sleep: sleeping is not synchronization, and the
detector will say so.
atomic.Bool flag works too — §8.4’s
“Atomic flag guarding data” explains why the plain
data write rides along with it. Whichever you pick, the
write you want published has to happen before the operation
that creates the edge; put it after and the detector will tell you so
at once.
labs/go-concurrency/code/ch08/. A worked answer sits in
solution/publisher.go.txt.
Further reading
- The Go Memory Model — the source for §8.4, and shorter than you expect. Read the “Implementation Restrictions for Programs Containing Data Races” section in particular: it is the paragraph that separates Go from C on this subject, and the one most summaries of it leave out.
- Russ Cox, Programming Language Memory Models — the three-part series written alongside the Go 1.19 revision. Part 1 covers hardware, part 2 languages, part 3 Go’s own choices. It is the best answer to “why is any of this necessary?” in print.
-
Data Race Detector
— the official guide to the tool in §8.3, including the
full
GORACEoption list and the supported platforms, which is worth checking before you assume CI can run it. -
sync/atomic— the typed API this chapter uses throughout (atomic.Bool,atomic.Int64,atomic.Pointer[T]). The package documentation states the sequential-consistency guarantee that §8.4 relies on. -
sync.OnceValue— added in Go 1.21. It gives thesync.Onceguarantee of §8.4 without leaving a package-level variable exposed, which removes the thing you would otherwise have to protect.
You can now say what a data race is, what Go actually promises about
one, how to make the detector show you where it is, and which of the
three strategies removes it. The third strategy —
synchronization — has so far been a single word covering five
different tools. Chapter 9 opens it up:
sync.Mutex and sync.RWMutex, what the
read-write split actually buys you and when it costs more than it
saves, and the lock-ordering discipline that keeps mutual exclusion
from turning into the deadlocks of chapter 10.