Skip to content
gohacks.dev
Concurrency

Why a Data Race Is Never Benign in Go Programs for Real

The usual excuses for a harmless data race in Go, tested: torn slice reads you can reproduce, a compiler that deletes your store, and double-checked locking.

7 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

"It's only a flag." "It's just a counter for metrics, nobody cares if it's off by a few." "Worst case we compute it twice." These sentences come up in review whenever someone leaves a shared variable unsynchronized on purpose, and they are the reason many races live for years. The trouble is that none of them is a statement about Go. They are statements about one CPU and one compiler version on the day the code was written. The language says something stronger and simpler: a program with a data race has no guaranteed behavior beyond a few narrow rules, and a program that is "mostly fine" is still a program you cannot reason about.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64), 10 cores

This article takes the three usual excuses seriously and tests them. Two of the examples reproduce a failure on demand. It is a companion to data races explained with examples and the race detector, and it belongs to the concurrency patterns cluster. Everything ran on Go 1.25.6.

What the specification allows

The Go memory model is specific about what a racy read may return. For a variable that fits in a single machine word, a read that races with a write observes either the old value or the new one, with no mixing, and the document says implementations should not invent values out of thin air. For anything larger the rule is weaker. The text says that reads of memory locations larger than a single machine word behave as multiple machine-word-sized operations in an unspecified order, so a racy read of a slice, string or interface can observe a mix of two writes. Those are the types Go programs use most.

Beyond reads, the document is clear that programs with data races have limited guarantees. Compilers are allowed to assume the absence of races when they optimize, and that is the part that turns "probably fine" into "unpredictable".

Torn reads you can reproduce

A slice is three words: pointer, length and capacity. Writing a slice variable is three stores, and nothing makes them atomic as a group. Here a writer flips a shared slice between a one-element slice holding 5 and a thousand-element slice holding 7s, while a reader counts any snapshot that does not match: length 1000 with first element 5, or length 1 with first element 7.

main.goGo
package main

import (
	"fmt"
	"time"
)

var shared []int

func main() {
	small := []int{5}
	big := make([]int, 1000)
	for i := range big {
		big[i] = 7
	}
	shared = small

	stop := make(chan struct{})
	go func() {
		for i := 0; ; i++ {
			select {
			case <-stop:
				return
			default:
				if i%2 == 0 {
					shared = big
				} else {
					shared = small
				}
			}
		}
	}()

	torn := 0
	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		snapshot := shared
		if (len(snapshot) == 1000 && snapshot[0] == 5) || (len(snapshot) == 1 && snapshot[0] == 7) {
			torn++
		}
	}
	close(stop)
	fmt.Println("torn reads in one second:", torn)
}
Text
torn reads in one second: 1031678

The reader saw 1,031,678 torn snapshots in one second. Each one is a slice whose pointer came from one value and whose length came from the other. A snapshot with the small slice's pointer and the big slice's length is a one-element array being read as if it had a thousand elements, so indexing the last one reads whatever lives next to it on the heap. Here the reader only looked at the first element, which is why nothing crashed. A reader that wrote through such a slice would corrupt another object. The same applies to strings (pointer and length) and to interface values (type word and data word), where a torn read can pair the type of one concrete type with the data of another and call a method on memory of the wrong shape. That is the failure behind most "impossible" segfaults in Go programs that have no unsafe.

The compiler deletes your store

The first time I wrote that experiment I got zero torn reads, and it was not luck. The writer loop originally read:

Go
s = big
s = small

and the compiler removed the first store. From its point of view, in a single goroutine, the value written by s = big is overwritten before anyone could read it, so the store is dead. This is a legal optimization because no other goroutine is allowed to observe the intermediate value without synchronization. The program, in other words, did not tear because the code that was supposed to cause the tear was gone from the binary. This version proves the point on its own:

main.goGo
package main

import (
	"fmt"
	"time"
)

var shared []int

func main() {
	small := []int{5}
	big := make([]int, 1000)
	for i := range big {
		big[i] = 7
	}
	shared = small

	stop := make(chan struct{})
	go func() {
		for {
			select {
			case <-stop:
				return
			default:
				shared = big // overwritten on the next line, so the compiler may drop it
				shared = small
			}
		}
	}()

	torn := 0
	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		snapshot := shared
		if len(snapshot) == 1000 {
			torn++
		}
	}
	close(stop)
	fmt.Println("reader ever saw the big slice:", torn > 0)
}
Text
reader ever saw the big slice: false

The reader never saw the big slice, because on this compiler the store to it does not exist. Another Go release, an unrelated inlining change or a different architecture could make the answer different, and the program would still be exactly as incorrect. That is the uncomfortable truth behind "benign": the behavior you tested is a property of one compiler's choices, not of the language.

The three usual excuses

"It's only a flag." A bool that one goroutine sets and another polls looks like the safest case, since it is one byte and cannot tear. It still has two problems. Nothing forces the polling loop to reload the variable, so a compiler may hoist the read out of the loop and spin forever on a stale value. And it carries no ordering: setting ready = true after filling a struct does not guarantee that a goroutine that sees ready also sees the struct, since the writes may become visible in either order. Use atomic.Bool, whose store and load carry the ordering (atomics), or close a channel, which is a signal and a synchronization edge in one.

"It's just a metrics counter." An unsynchronized count++ loses updates, as the data race article measured: eight goroutines produced 296,549 of an expected 800,000. If you accept approximate values, atomic.Int64.Add is exact and costs a few nanoseconds more than the racy increment on most hardware, which is a bad price to pay for wrong numbers. The race detector will also flag the racy version in every test run, and the usual response to that noise is to turn the detector off, which is the real cost.

"Worst case we compute it twice." This is lazy initialization without a lock, often called double-checked locking:

Go
var instance *Config

func Get() *Config {
	if instance == nil { // racy read
		mu.Lock()
		if instance == nil {
			instance = load() // racy write
		}
		mu.Unlock()
	}
	return instance
}

The claim is that a goroutine may compute twice and that's all. The problem is the first check, which reads instance with no synchronization. On a weakly ordered processor a goroutine can see the new pointer before it sees the writes that filled in the struct, and then it dereferences a half-built object. The Go memory model contains this exact pattern as an example of a broken program. The fix is sync.Once or, in Go 1.21 and later, sync.OnceValue, which cost one atomic load on the fast path (sync.Once).

The fix costs almost nothing

Every excuse has a replacement that is correct and about as fast. A flag becomes an atomic.Bool or a closed channel. A statistic becomes an atomic.Int64. A lazily built value becomes sync.OnceValue(load). A shared pointer to immutable data becomes an atomic.Pointer[T], so readers always get a complete object (typed atomics). A group of fields that change together goes under a mutex, or into an immutable struct swapped as a whole. In none of these does the correct version cost enough to matter outside a profile that says so, and if a profile does say so, the answer is to restructure the sharing, such as sharding the counter (safe counters).

What to do with a "benign" race report

When the detector reports a race you believe is harmless, the productive response is to ask what the code was trying to say, and write that down with a primitive that says it. Resist //go:build !race around the code, and resist the temptation to run the suite without -race. A suppressed report on a benign variable also hides the next report on a variable that isn't. The compiler and the runtime do not know which races you consider benign; they apply the same rules to all of them. For a deeper look at what orders what, the next article in the series is happens-before in the Go memory model, and the memory model itself is short enough to read in an afternoon. The race detector article on go.dev lists what the tool reports as a race, and none of its categories has a "harmless" exemption.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.