skip to content

What does Go's memory model guarantee about writes made before a `go` statement?

level: juniorimportance: should knowfreq 45%

answer

  1. a one-way street
  2. the launch itself is the edge
  3. captured values are visible on entry
  4. nothing orders the way back out
  5. writes after the launch are not covered

basics

~20 s

Everything the starting goroutine wrote before the go statement is guaranteed visible to the new goroutine when it begins running. The guarantee is one-way: the starter is not guaranteed to see anything the new goroutine writes.

solid answer

~50 s

Go's memory model says the `go` statement is synchronized before the start of the new goroutine's execution. So every write the starting goroutine had already made — filling a struct, building a slice, assigning a package-level pointer, and the values the closure captured — is guaranteed visible inside the goroutine body. That is why handing data *into* a goroutine at launch needs no lock. There is no matching edge in the other direction: neither the goroutine's writes nor its exit are ordered with anything the starter does afterwards, so reading a result it produced requires a real edge — receiving from a channel it sent on, or a `sync.WaitGroup.Wait` that its `Done` unblocked. Writes the starter makes *after* the `go` statement are concurrent with the goroutine and are a data race unless something orders them.

code

go · 7 lines
go
func start() {
	cfg := loadConfig()
	go func() {
		serve(cfg) // guaranteed to observe loadConfig's result
	}()
	cfg = nil // racy: nothing orders this against the read above
}

go deeper

for a junior

Be ready to state the rule in one sentence: whatever you set up before starting a goroutine is visible inside it, and getting a value back out needs a channel or a WaitGroup.

for a middle

Explain why the rule is one-way, and name the edge that carries data back. Expect to be asked what happens to a variable the parent keeps writing after the launch.

for a senior

Show you use this deliberately: configuration built before the workers start needs no locking, and you can defend that in review instead of adding a mutex out of habit.

for a principal

Own the convention this implies for shared state across a codebase — publish once before launching readers, or hand it over a channel, and treat any post-launch mutation of shared setup state as a design smell.

## What "happens-before" is doing in Go The Go memory model exists to answer one question: when is a read of a variable *guaranteed* to observe a particular write to it? It answers by defining an ordering. Inside a single goroutine, statements are ordered by the program text (Go calls this *sequenced before*). Across goroutines, only specific synchronization operations create ordering (*synchronized before*). Chain those two together transitively and you get **happens-before**. The payoff rule is simple. If a write to a variable happens-before a read of that variable, and no other write is sandwiched between them, the read is guaranteed to observe that write. If a write and a read of the same variable are **not** ordered by happens-before, they are concurrent, the program contains a **data race**, and Go promises you nothing useful: the compiler is free to keep the variable in a register and never reload it, and the hardware is free to make the store visible late or out of order relative to other stores. ## The `go` statement is one of those synchronization operations The model states it directly: *the `go` statement that starts a new goroutine is synchronized before the start of the goroutine's execution.* Everything sequenced before the `go` statement in the starting goroutine therefore happens-before the new goroutine's very first line. That is worth more than it first appears. It covers: - values the closure captured, and the memory those values point at; - arguments evaluated at the `go` statement (they are evaluated in the starting goroutine, before the goroutine begins); - package-level state the starter had already written; - everything the starter itself learned through *earlier* edges — happens-before is transitive, so if the starter received a value from a channel and then launched a goroutine, the new goroutine also observes whatever that send ordered. So the common shape below is correct without any lock: ```go cfg := loadConfig() go func() { serve(cfg) }() // serve is guaranteed to see loadConfig's result ``` ## The guarantee is one-way, and that is the part people miss There is no rule saying the goroutine's writes are ordered before anything the starter does, and there is explicitly no rule ordering a goroutine's **exit**. Two mistakes follow from forgetting that. **Mistake one: writing after the launch.** Anything the starter writes *after* the `go` statement is concurrent with the goroutine. In: ```go cfg := loadConfig() go func() { serve(cfg) }() cfg = nil // race: nothing orders this against serve's read ``` the assignment races with the closure's read of `cfg`. The goroutine may observe either value, and a racy program is not merely non-deterministic — the compiler was allowed to optimise on the assumption there was no race. **Mistake two: reading the result back.** A goroutine that computes something into a shared variable and returns has established nothing. The starter needs an explicit edge to observe it: receive from a channel the goroutine sent on (or closed), let a `sync.WaitGroup.Wait` be unblocked by its `Done`, or take a mutex the goroutine unlocked. A `time.Sleep` is not a synchronization operation, no matter how long it is. ## Related edges that complete the picture Package initialization is also ordered: all `init` functions and package-level variable initialization complete before `main.main` starts, so anything set up there is visible to every goroutine the program later starts. Combined with the `go`-statement rule, this gives you the cheapest correct pattern for read-mostly configuration: **initialize it before you start the readers**, and never write it again. No lock is needed, because the ordering is supplied by the launch itself. ## How to say this in an interview The compressed version is: *starting a goroutine is an ordering edge; finishing one is not.* Data flows in for free at launch; data flowing back out has to be carried by a channel, a `WaitGroup`, or a mutex. If you can state that, and then point out that writes made after the `go` statement fall outside the guarantee, you have the whole rule.

  • Once that goroutine returns, is the starter guaranteed to see what it wrote?
    No. Go's memory model says a goroutine's exit is not synchronized before any event in the program. You need an explicit edge: receive from a channel the goroutine sent on or closed, let a `sync.WaitGroup.Wait` be unblocked by its `Done`, or acquire a mutex it unlocked. The return statement itself orders nothing.
  • Does the guarantee also cover writes the starter makes after the `go` statement?
    No. Those writes are concurrent with the new goroutine. If the goroutine reads the same variable, that is a data race and it may observe either value — or a value the compiler cached in a register. Only writes sequenced before the `go` statement are ordered.
  • Where does package-level initialization fit into this?
    Package-level variable initialization and all `init` functions complete before `main.main` starts, so anything set up there is visible to every goroutine the program starts afterwards. That is why read-only configuration assigned during initialization needs no lock at all.

saying these in an interview costs you the question

  • Claims the new goroutine needs a mutex to read setup data
  • Says the starter automatically sees results once the goroutine returns
  • Thinks a sleep after the go statement is enough to read a result back
  • Believes writes made after the go statement are ordered too
  • Treats the ordering as symmetric between the two goroutines