Free lesson · Concurrency Interview Mastery
The Go Memory Model
Every language in this module has a memory model, and Go's is shaped by a philosophy the language states out loud: "Do not communicate by sharing memory; instead, share memory by communicating." That sentence is the whole design. Go would rather you pass data between goroutines over channels - establishing ordering as a side effect - than share variables and guard them with locks. But Go also gives you locks and atomics for when you do share, and it gives you something no other language in this module has built in: a race detector that catches data races at runtime. This lesson covers the happens-before rules that make Go concurrency correct, and the race detector that makes Go concurrency debuggable.
The same problem, Go's framing
The underlying reality is the one from Module 1: without synchronization, one goroutine's writes may not be visible to another, and operations may appear reordered. Go formalizes correctness through happens-before: if event A happens-before event B, then A's effects are visible to B. Within a single goroutine, program order gives you happens-before. Across goroutines, you only get happens-before through explicit synchronization - channel operations, mutexes, or atomics. Anything else is a data race, and a racy Go program has undefined behavior.
var data int
var ready bool
// goroutine A:
data = 42
ready = true // NO synchronization - racy signal
// goroutine B:
for !ready { } // may loop forever, or see ready before data
fmt.Println(data)
This is the same broken flag pattern from every language. Without a happens-before relationship between the write of ready in A and the read in B, goroutine B may never see ready become true, or may see it true before data's write is visible. Go's answer is to establish happens-before properly - and its preferred way is channels.
Channels establish happens-before
The central rule, and the one that makes Go's "share by communicating" philosophy actually work: a send on a channel happens-before the corresponding receive completes. So when goroutine A sends a value and goroutine B receives it, everything A did before the send is guaranteed visible to B after the receive. The channel carries not just the value but the memory-ordering guarantee.
var data int
ch := make(chan bool)
// goroutine A:
data = 42
ch <- true // send happens-before the receive below
// goroutine B:
<-ch // receive: now data=42 is guaranteed visible
fmt.Println(data) // correctly prints 42
This is the elegant part of Go's model: by passing a signal over a channel, you get correct memory visibility for free, without ever thinking about barriers or volatile. The channel send/receive pair is the synchronization. This is why idiomatic Go leans on channels - they solve the coordination and the memory-visibility problems together.
flowchart LR
subgraph A[goroutine A]
W["write data = 42"] --> S["ch <- true (send)"]
end
subgraph B[goroutine B]
R["<-ch (receive)"] --> RD["read data -> sees 42"]
end
S ==happens-before==> R
This diagram shows how a channel establishes happens-before ordering between two goroutines in Go. In goroutine A, a value is written to data, and then a send happens on the channel. In goroutine B, a receive happens on the same channel, and then data is read. The key relationship, shown by the bold arrow, is that the send in A happens-before the receive in B completes. Because of that, everything A did before the send - including writing data equals forty-two - is guaranteed visible to B after the receive. So the channel carries both the signal and the memory-visibility guarantee.
Mutexes and atomics also establish ordering
Channels are preferred, but Go's sync package gives you the familiar tools too, and they establish happens-before as well. For a sync.Mutex, the unlock of the mutex happens-before any subsequent lock of the same mutex - so data protected by a mutex is correctly published from one goroutine to the next, exactly as in Java or C#. The sync/atomic package provides atomic operations with their own ordering guarantees for single-variable lock-free access.
var mu sync.Mutex
var data int
// goroutine A:
mu.Lock(); data = 42; mu.Unlock() // unlock happens-before...
// goroutine B:
mu.Lock(); fmt.Println(data); mu.Unlock() // ...this lock; sees 42
So Go gives you both styles: communicate over channels (preferred for passing ownership of data), or share with mutexes/atomics (for genuinely shared state like a counter or cache). Both establish the happens-before relationships that make access correct; the choice is about which fits the problem, a theme we'll return to.
Data races and undefined behavior
A word on severity. Go documentation describes data races as producing undefined behavior - a racy program's results are not guaranteed, and a race on certain values can even corrupt memory or crash. So while Go feels safer and more ergonomic than C++, a data race is still a serious bug, not a benign quirk. You must eliminate races, not tolerate them. The difference from C++ is not that races are safe in Go - it's that Go gives you a tool to find them.
The race detector: Go's standout feature
Here's what genuinely sets Go apart for concurrency debugging. Go ships a built-in race detector that you enable with a flag:
go run -race main.go // run with race detection
go test -race ./... // test with race detection
go build -race // build a race-detecting binary
With -race, the runtime instruments memory accesses and reports data races as they happen at runtime, printing the conflicting accesses and the goroutines and stack traces involved. It catches races that would otherwise be invisible - the kind that pass code review and testing and only surface in production on other hardware. No other language in this module has this built into the standard toolchain; it's one of Go's best features.
Tip: Run your tests with
-racein CI, always. The race detector is the single most effective tool for catching concurrency bugs in Go, and it costs you nothing but some runtime overhead during testing. It only detects races that actually occur during the run (it's a dynamic detector, not a static one), so it's most powerful when your tests exercise concurrent paths - but for races it does observe, it pinpoints them precisely, turning the hardest-to-find category of bug into a clear report. "I run the race detector in CI" is the correct, experienced answer to "how do you catch data races in Go," and the habit will save you from production incidents that other languages leave you to discover the hard way.
Goroutine creation and completion ordering
Two more happens-before rules worth knowing precisely. The go statement that starts a goroutine happens-before that goroutine begins executing - so any setup you do before go f() is visible inside f. But the completion of a goroutine does not happen-before anything in the program unless you add explicit synchronization. This is exactly why main returning kills goroutines and why you need a WaitGroup or channel to know a goroutine finished: there's no implicit ordering from a goroutine's end back to other goroutines. You must establish it.
Why "share by communicating" is more than a slogan
The proverb is easy to recite, but its real content is a claim about where concurrency bugs come from and how to design them away. The hardest concurrency bugs - races, torn reads, lost updates, the visibility failures this lesson opened with - all stem from shared mutable state: two goroutines touching the same memory, where the interleaving and the visibility rules determine correctness. Go's insight is that if you restructure your program so data is passed between goroutines rather than shared among them, those bugs become impossible, because there's no shared location for two goroutines to corrupt.
Concretely, the channel-based style gives each piece of data a single owner at any moment. A goroutine produces a value and sends it on a channel, relinquishing it; another goroutine receives it and now owns it exclusively. At no instant are two goroutines simultaneously reading and writing the same data, so there's nothing to race on. Compare this to the shared-memory style, where you have a variable both goroutines access and you must carefully guard every access with the right lock and hope you never miss one. The channel style makes correct concurrency the structural default rather than something you achieve through vigilant locking discipline. That's why Go's memory model leads with channels: they're not just a communication mechanism, they're a way to design out the conditions that produce concurrency bugs in the first place.
This connects back to the models from Module 1. "Share memory by communicating" is essentially the CSP model elevated to a language philosophy - coordinate through message passing rather than shared state. Go doesn't forbid shared memory (it gives you mutexes and atomics), but it nudges you, idiomatically and through its standard library design, toward the message-passing style as the default, reserving shared-memory tools for the cases where they genuinely fit. Understanding this is understanding why Go code looks the way it does.
The two styles, and when each fits
That said, Go is pragmatic, and the mature view is not "always channels." Both styles establish happens-before correctly; the question is which expresses your problem more naturally, and choosing wrongly produces awkward, buggy code regardless of which you pick. Here's the judgment.
Reach for channels when your problem is about transferring ownership of data or coordinating goroutines - a pipeline of processing stages, distributing work to a pool of workers, signaling events, or passing results back from goroutines. When the data naturally flows from one goroutine to another, channels express that flow directly and give you the ownership-handoff safety for free. This is the larger share of concurrency problems in typical Go code, which is why channels feel central.
Reach for mutexes and atomics (the sync and sync/atomic tools) when your problem is genuinely shared state that multiple goroutines read and modify in place - an in-memory cache, a shared counter, a map that many goroutines update, a configuration accessed concurrently. Trying to force these through channels often produces a clumsy design (funneling every access through a single goroutine that owns the state), where a simple sync.Mutex around the shared structure is clearer and faster. The Go team itself notes that a mutex is sometimes the right, idiomatic tool, and that the proverb is guidance, not dogma. The skill is diagnosing whether your concurrency is about moving data (channels) or guarding shared state (mutexes), and reaching for the tool that matches - a judgment we'll keep sharpening through the sync lesson and beyond.
A practical approach to race-free Go
Pulling the memory model into actionable habits, here's how to actually keep Go programs race-free in practice rather than just understanding the theory. The discipline comes down to a few reinforcing practices that together make races rare and catchable.
First, default to the channel style for passing data between goroutines, because - as the happens-before rules showed - it gives you correct visibility for free and structures the program so there's less shared state to race on in the first place. Every time you find yourself reaching for a shared variable that multiple goroutines write, pause and ask whether the data could instead flow between goroutines over a channel, with each value owned by one goroutine at a time. Often it can, and the race simply ceases to exist.
Second, for the shared state that remains - the genuinely-shared counter, cache, or map - guard every access with the same mutex or use sync/atomic, with no exceptions. The fatal mistake is protecting most accesses but missing one; a single unguarded read or write of shared data is a race, and the one you forgot is invisible in review. Be systematic: identify each piece of shared mutable state, decide on its protection (a mutex it's always held under, or atomics), and ensure no code path touches it otherwise. Keeping shared state minimal and clearly owned makes this tractable.
Third, and this is the practice that catches what the first two miss, run the race detector continuously - go test -race in CI on every change. Because the detector is dynamic, it only finds races that actually execute during a test run, so the practice has a corollary: write tests that genuinely exercise your concurrent code paths with multiple goroutines, so the detector has something to observe. A race in code that your tests run concurrently will be caught precisely, with stack traces, before it ever reaches production. The combination - prefer channels to avoid sharing, rigorously guard the sharing that remains, and let the race detector verify in CI - is how experienced Go teams keep concurrent code correct. No single practice is sufficient alone; together they turn the hardest category of bug into one that's largely designed-out and otherwise caught automatically. That layered discipline, rather than any one trick, is the real answer to "how do you write correct concurrent Go."
Note: The one-line summary worth keeping: in Go, you don't get cross-goroutine memory visibility by being careful - you get it by synchronizing, and the most ergonomic way to synchronize is to pass the data over a channel, which makes the visibility automatic. When you find yourself reasoning about whether one goroutine will "see" another's write, that's the signal you need an explicit happens-before edge - a channel send/receive, a mutex, or an atomic - not a hopeful assumption. And whatever you build, run it under
-raceto confirm.
The interview angle
Interview: High-value questions: (1) "What's Go's concurrency philosophy?" - "Don't communicate by sharing memory; share memory by communicating" - prefer passing data over channels (which establish happens-before) to sharing variables behind locks. (2) "How does a channel affect memory visibility?" - A send happens-before the corresponding receive completes, so everything before the send is visible after the receive; the channel carries the ordering guarantee. (3) "How do you detect data races in Go?" - The built-in race detector:
go test -race/go run -race, a dynamic detector that reports races observed at runtime; run it in CI. (4) "Are data races safe in Go?" - No; they're undefined behavior and serious bugs - Go just gives you the race detector to find them. (5) "Do mutexes establish ordering too?" - Yes; unlock happens-before the next lock. Citing the channel happens-before rule and the race detector is what marks real Go memory-model understanding.
Quick reference
// happens-before across goroutines requires explicit synchronization:
// - channel SEND happens-before the corresponding RECEIVE completes
// - mutex UNLOCK happens-before the next LOCK of the same mutex
// - sync/atomic operations provide ordering for single-variable access
// - the `go` statement happens-before the goroutine starts
// - goroutine COMPLETION orders nothing unless you synchronize (WaitGroup/channel)
// preferred: pass data over channels -> visibility comes for free
data = 42; ch <- true // A
<-ch; use(data) // B sees 42
// the race detector - Go's standout tool, run it in CI:
go test -race ./...
go run -race main.go
// data races = UNDEFINED BEHAVIOR. Eliminate them; the detector finds them.
Next, how the Go runtime actually pulls off running millions of goroutines on a few OS threads - the scheduler, the GMP model, and why goroutines are so cheap.