Goroutines and channels make concurrent code easy to WRITE. They don't make concurrent code automatically correct — Go's genuine parallelism (lesson 7) opens the door to a real bug class JavaScript's single-threaded model structurally can't produce, and channels introduce a different, equally real failure mode of their own.
3 min read
counter := 0
for i := 0; i < 1000; i++ {
go func() {
counter++ // READ counter, ADD 1, WRITE counter — three separate steps
}()
}
// counter is very likely NOT 1000 — some increments got lostcounter++ is not a single atomic operation — it's read, then increment, then write, as three separate steps. When two goroutines run this concurrently, both can read the same current value before either writes its incremented result back, and one increment is silently lost. This is a data race: two goroutines accessing the same memory location concurrently, with at least one of them writing, and no synchronization enforcing an order between them. Because JavaScript is single-threaded (this curriculum's own event-loop lesson), this specific bug class has no equivalent there at all — it's a real, structural consequence specifically of Go's genuine, multi-core parallelism.
var mu sync.Mutex
counter := 0
for i := 0; i < 1000; i++ {
go func() {
mu.Lock()
counter++
mu.Unlock()
}()
}A sync.Mutex ensures only one goroutine executes the locked section at a time, fixing the race directly — the same fundamental tool any language with real threads provides. Idiomatic Go often prefers restructuring the problem around a channel instead (having one dedicated goroutine own counter entirely, with others sending it increment requests over a channel) — this is the practical expression of lesson 8's "share memory by communicating" principle: instead of protecting shared memory with a lock, avoid sharing it at all.
go run -race main.go
go test -race ./...A data race often doesn't cause a visible, obvious failure — the program might produce a slightly wrong result intermittently, or appear to work correctly for months under light load and then fail under heavier concurrent traffic, which is exactly the kind of bug that's nearly impossible to reliably reproduce by just running the program and watching. The -race flag instruments a program at compile time to detect actual concurrent, unsynchronized memory access as it happens during a real run, and reports the exact file and line of both conflicting accesses — running Go's own test suite with -race enabled in CI is the standard, practical way real projects catch this bug class before it ever reaches production, rather than hoping to notice it by its symptoms.
func leak() {
ch := make(chan int)
go func() {
result := slowComputation()
ch <- result // BLOCKS FOREVER if nothing ever reads from ch
}()
// leak() returns WITHOUT ever reading from ch — the goroutine above
// is now blocked on its send, permanently, and can never be garbage collected
}Because a goroutine's memory is only reclaimed once it actually finishes running, a goroutine permanently blocked on a channel send or receive that will never happen leaks — it sits in memory forever, never finishing, never getting cleaned up. Unlike a data race, this doesn't cause incorrect output; it causes a slow, compounding memory and scheduler-overhead growth as leaked goroutines accumulate over the life of a long-running program, often invisible until the accumulated leaks are severe enough to actually matter — another genuine case for Observability & Monitoring's own lesson on tracking resource usage over time, applied specifically to goroutine count as a metric worth watching.
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What is a data race?
2. What does Go's `-race` flag actually do?
3. How does a goroutine leak differ from a data race in its observable symptoms?
Go