Skip to content
advanced Phase 8 · Goroutines and Shared-State Concurrency

Scheduling and the Happens-Before Relation

Reason about goroutine migration and which operations establish memory visibility.

50m
0 problems
Topic Progress 0%

Your Goroutine Is Not Yours Anymore

A background refresher goroutine spins through a work slice while a request handler runs on the same box. Someone writes code that caches a value in a package-level variable keyed by "the current worker" and reads it back two calls later — it works for a week, then corrupts data on a machine with more cores.

The missing mental model is how goroutines are actually run. Go multiplexes goroutines (G) onto operating-system threads (M, for machine) through a set of logical processors (P), one per schedulable context — by default as many as CPUs, bounded by GOMAXPROCS:

 G1   G2   G3   G4   G5     <- goroutines (cheap, stackful)
 |    |    |          |
 [---P0---]        [---P1---]  <- logical processors (run queues)
 |                   |
 M0                  M1         <- OS threads (do the actual running)

Each P owns a local queue; when a P's queue empties it steals from another P's queue. A goroutine that makes a blocking syscall parks its P (another thread may take over the work) and migrates back to a queue when the call returns. Since Go 1.14, the runtime can preempt even a tight loop without a function call, so a goroutine with no channel or lock operations no longer starves its P.

Three consequences follow, and they explain most "how is this racy, it works fine" confusion:

  • Goroutines migrate. A goroutine can run on M0 now and M1 next instruction. There is no goroutine-local storage, no thread affinity to depend on, and no "I am the thread that wrote this" reasoning from other languages.
  • No ordering between independent goroutines. The runtime owes you nothing about which of two runnable goroutines runs first, how long a wait takes, or whether a goroutine runs at all before main exits. Priority is fairness machinery, not a contract.
  • Concurrency is not parallelism. Two goroutines are concurrent (structured to interleave) whether or not GOMAXPROCS > 1 runs them simultaneously; runtime.GOMAXPROCS(0) reports the current setting, runtime.NumGoroutine() the live count — useful for debugging, useless as synchronization.

If execution order is unguaranteed, what makes shared memory correct? Not timing — the answer is a set of ordering edges the memory model defines. Sleeps, yields, and clock time create none of them.

The Edge Catalog: Every Guarantee Has a Construct

The Go memory model says a read of a variable observes the write it is ordered after; the ordering relation is called happens-before. Without an edge between two accesses to the same variable, the compiler and CPU are free to reorder, cache, and delay — and the race detector will call the pair a race. The catalog is small, and each entry maps to code you have already written:

You need this edge Established by
goroutine sees the arguments/state at its start the go statement (its evaluation happens-before the new goroutine runs)
parent knows a goroutine's writes are complete goroutine termination observed via WaitGroup.Wait, a done channel receive, or any receive of its result
data published to another goroutine channel send → corresponding receive completes; or Mutex.Unlock → subsequent Lock of the same mutex
buffered channel reads the sent value receive of that element happens after its send
closed channel wakes a reader with zero values close happens-before receive returning n == 0
one-time initialization visible to all callers sync.Once.Do returns → any later Do returns
atomics are coherent operations on the sync/atomic package behave as sequentially consistent
anything before main runs package initialization happens-before main starts

The complement matters as much: sleeps, runtime.Gosched(), elapsed time, and "it worked in testing" establish nothing. Here is the shape people write and then cannot explain:

var ready bool // set by the goroutine, read by main — no edge

func main() {
	go func() {
		data = load()
		ready = true
	}()
	time.Sleep(50 * time.Millisecond) // hopes for ordering; provides none
	if ready {
		use(data) // may still see stale data — data race
	}
}

The sleep masks the race on a fast machine and loses on a slow one, which is precisely why flaky tests cluster around timing-based "sync." Replace hope with an edge:

func main() {
	done := make(chan struct{})
	go func() {
		data = load()
		close(done)
	}()
	<-done      // receive of the close happens-before this returns
	use(data)   // safe: data's write is ordered before the receive
}

close happens-before a receive of the zero value returns — one line, and the publication is airtight on every machine and every run. The channel details arrive in Channel Fundamentals; the point now is that correctness came from naming the edge, not from waiting longer.

Auditing Code by Asking 'What Is the Edge?'

Reading concurrent Go well is a repeated audit: for every shared variable, name the edge that orders its writes and reads — or admit there is none. Three examples, from most to least obvious.

Example one: package-level lazy config. Two goroutines call config(); without coordination, both may run loadConfig and one overwrite the other. With sync.Once, the edge is built in:

var (
	cfgOnce sync.Once
	cfgVal  *Config
)

func config() *Config {
	cfgOnce.Do(func() { cfgVal = loadConfig() })
	return cfgVal
}

The audit answer: Do's initialization happens-before any Do returns — including the first caller's return after the function completes — so every caller sees a fully constructed cfgVal. No lock in sight, one edge, done. That guarantee is why init-style Once beats the double-checked-locking pattern other languages need.

Example two: a counter read outside its lock. Get() locks, but a hot path caches g.n "just to skip the lock":

if g.n > limit { return ErrBusy } // read with no edge to writers

Audit: the unlocked read shares memory with every Incr. There is no edge — this is a race, and -race proves it. The fix is not a narrower lock, it is taking the lock (or reading an atomic). The shortcut had no edge to give.

Example three: a WaitGroup result write. Goroutines write results[i], and Wait returns after the last Done:

wg.Wait()
return results // every results[i] write happens-before Wait returned

Edge: Done happens-before Wait returns, transitively ordering all goroutine writes before the read. This is why the pre-sized-slice fan-out from Coordinating with sync.WaitGroup is correct without a mutex — the synchronization is real, just spelled WaitGroup instead of Lock.

The audit checklist, in order: list every variable written by more than one goroutine; for each, ask which construct orders writer and reader — channel, same mutex, WaitGroup, Once, atomic; if the answer is "sleep, timing, or nothing," it is a race. Run The Race Detector as the machine-checked version of the same audit: it flags exactly the pairs where the model's edges are missing.

What Scheduling Still Owes You (Almost Nothing)

Once the edge catalog is in hand, the scheduler's remaining lessons are about not over-trusting runtime behavior — plus a few operational facts worth carrying.

No fairness or progress contract. A goroutine blocked on a channel does not burn CPU; a CPU-bound goroutine shares its P with others (and gets preempted since Go 1.14, but "eventually" is the strongest word available). Writing code that requires "my goroutine ran at least once by now" is writing a race with extra steps — the fix is always the same: replace the assumed progress with an observed edge. The same reasoning bans runtime.Gosched() as control flow: it is a hint to the scheduler, not a barrier.

Blocking changes the topology. Channel operations, lock contention, and syscalls park the goroutine and free its P for someone else. This is why a program with 10,000 blocked goroutines is fine and 10,000 spinning ones is not, and why channels give concurrency rather than parallelism while blocked. It also explains why a select waiting on many goroutines scales without threads — a topic the channel phase builds on.

The knobs are few and mostly automatic. GOMAXPROCS defaults to the machine's CPU count; containers have historically required manual tuning to match CPU quotas, and recent Go releases derive the limit from the cgroup automatically — check runtime.GOMAXPROCS(0) rather than assuming. Raising it does not fix races, and lowering it to "make tests deterministic" merely narrows the interleavings your tests see — the bug is still there, wearing a lab coat.

What to say in interviews, and mean it: shared memory requires an edge; the edge comes from a construct (channel, mutex, WaitGroup, Once, atomic), never from time; goroutines migrate across threads, so thread-based intuitions (thread-locals, ordering by creation) do not transfer; and the race detector mechanically checks the same property you are reasoning about by hand. Everything in this phase — spawning, waiting, detecting, locking — is a different spelling of one sentence: make the happens-before edge visible in the code, or accept that there is none. Channels are the most expressive spelling of all, and where the next phase begins with Channel Fundamentals.

Quiz

1. main starts a goroutine that sets a flag, then sleeps 50ms before reading it. What is true?

Question 1 options

2. Which pair is guaranteed to happen-before by the Go memory model?

Question 2 options

3. A goroutine writes data and closes a done channel; main does `<-done` then reads data. Why is the read safe?

Question 3 options

4. Two goroutines call an unprotected lazy loader and both run loadConfig(). What edge-based fix makes every caller see one fully built config?

Question 4 options

Flashcards

Question

What are G, M, and P in Go's scheduler, and why does the mapping matter for correctness?

Answer

G is a goroutine, M an OS thread, P a logical processor with a run queue (count = GOMAXPROCS). Goroutines migrate between Ps and Ms — including during blocking calls and via work stealing — so there is no thread affinity: goroutine-local storage and 'same thread' reasoning from other languages are invalid.

Question

Name four constructs that establish a happens-before edge, and two things that do not.

Answer

Edges: channel send→receive, Mutex.Unlock→subsequent Lock of the same mutex, WaitGroup Done→Wait returns, sync.Once completion→any Do returns (plus atomics' sequential consistency and the go statement→goroutine start). Non-edges: time.Sleep and elapsed clock time, runtime.Gosched() or scheduler hints.

Question

Why is a goroutine that caches 'I'm on the same thread as before' broken by design?

Answer

The scheduler migrates goroutines across Ps and Ms for load balancing, work stealing, and around blocking syscalls — with no affinity contract. Anything thread-locals or TLS would have carried must instead travel through arguments, context, or explicitly synchronized state.

Question

How do you audit a concurrent snippet for correctness?

Answer

List every variable written by more than one goroutine, then name the construct ordering each writer-reader pair — channel, same mutex, WaitGroup, Once, atomic. If the answer is 'sleep, timing, or nothing,' it is a race; run go test -race as the machine-checked version of the same argument.

Question

Why does waiting on a channel give concurrency without parallelism?

Answer

A goroutine blocked in a channel or lock operation is parked — it consumes no CPU and its P can run someone else. Parallelism requires multiple Ps actively executing runnable goroutines. This is why thousands of blocked goroutines are cheap while thousands spinning on a flag are not.

Revision Notes

Key Takeaways

  • 1. Goroutines migrate across Ps and Ms — thread-affinity intuitions and goroutine-local storage do not exist
  • 2. Happens-before edges come only from constructs: channel, mutex, WaitGroup, Once, atomic, the go statement
  • 3. Sleep and Gosched provide no ordering; they hide races until a slow machine exposes them
  • 4. Audit every shared variable for its edge — the race detector is the mechanical version of that audit
  • 5. Edge reasoning is transitive: write → close → receive → read chains correctness across goroutines

Interview Tips

  • • "What does happens-before mean in Go?" — define it as the ordering that lets a read observe a write, then give two constructs and one non-example (sleep); mention transitivity
  • • "How does Go's scheduler affect my code?" — G-M-P summary, migration means no thread-local state, no run-order guarantees; tie it to why synchronization must be explicit
  • • "Your test is flaky — walk me through it" — find the timing-based 'sync', name the missing edge, replace with channel/WaitGroup, verify with -race -count
  • • "Why is sync.Once correct?" — Do's completion happens-before every Do return; contrast with nil-check double-checked locking, which has no edge on the value write

Cheat Sheet

Scheduling and the Happens-Before Relation

Scheduler model: G (goroutine) multiplexed onto M (OS thread) via P (logical processor, GOMAXPROCS of them). Work stealing, syscall parking, async preemption since 1.14. No migration guarantees → no thread-locals, no run-order promises.

Edge catalog:

Need Establish with
goroutine sees start state the go statement itself
know goroutine finished WaitGroup.Wait / receive of its done channel
publish data channel send→receive, or Unlock→Lock (same mutex)
one-time init visible everywhere sync.Once.Do
counter/flag coherence sync/atomic (sequentially consistent)
wake on shutdown close(ch) → receive returns zero value

Not edges: sleep, Gosched, wall-clock elapsed time, start order, GOMAXPROCS, "tested fine."

Audit template:

done := make(chan struct{})
go func() {
	data = load() // write ...
	close(done)   // ... happens-before
}()
<-done          // ... this receive returning
use(data)       // read is ordered — safe

Rules: correctness comes from named edges, never timing; blocked goroutines park (cheap), spinning ones burn CPU; raising GOMAXPROCS never fixes a race.

Continue Learning

Before You Start

  • No prerequisites listed.

Next Topics

Related Reading