skip to content

Does a goroutine finishing establish any happens-before edge in a Go program?

level: middleimportance: should knowfreq 42%

answer

  1. starting is an edge, finishing is not
  2. the join does the ordering
  3. sleeping is not a synchronization operation
  4. channel, WaitGroup or mutex — pick one
  5. main returning kills the rest

basics

~20 s

No. Go's memory model states that a goroutine's exit is not synchronized before any event. To observe what it wrote you need an explicit edge: a channel receive, a Wait unblocked by Done, or a mutex handoff.

solid answer

~40 s

The memory model is explicit: a goroutine's exit is not guaranteed to be synchronized before any event in the program. So a goroutine that computes a result into a shared variable and returns has established nothing — another goroutine reading that variable is in a data race, and may see a stale value or a value the compiler cached in a register. The ordering comes from the *join*, not the exit: receive from a channel the goroutine sent on or closed, let `sync.WaitGroup.Wait` be unblocked by its `Done`, or acquire a mutex the goroutine unlocked. `time.Sleep` is not a synchronization operation, however long it is. And when `main` returns, the program exits immediately — goroutines still running are killed where they stand, and their deferred calls never run.

code

go · 11 lines
go
var result string

func main() {
	done := make(chan struct{})
	go func() {
		result = "ready"
		close(done) // this, not the return, creates the ordering
	}()
	<-done
	fmt.Println(result) // prints: ready
}

go deeper

for a junior

Remember that you cannot just let a goroutine finish and then read what it wrote. Learn the two standard joins: receive from a channel it closed, or wait on a WaitGroup.

for a middle

Be able to say why the exit orders nothing and to list the operations that do create an edge — channel send and receive, close, Done before Wait returns, Unlock before the next Lock.

for a senior

Demonstrate that you recognise sleep-based synchronization in review and can name the failure mode: compiler caching of the load, not just unlucky timing. Tie it to shutdown paths that must actually drain.

for a principal

Own the shutdown contract for services in your codebase: every background goroutine has a named owner that joins it, so process exit is deterministic rather than a race between main returning and work finishing.

## The rule, stated plainly The Go memory model lists the operations that create ordering between goroutines — the `go` statement, channel sends and receives, `sync.Mutex` and `sync.RWMutex`, `sync.WaitGroup`, `sync.Once`, `sync/atomic` — and then adds an explicit non-guarantee: **the exit of a goroutine is not guaranteed to be synchronized before any event in the program.** Finishing is not an event other goroutines can observe. This is the mirror image of the `go` statement, which *is* an edge. Data flows into a goroutine for free at launch. Data flowing back out has to be carried by something. ## Why the difference matters in practice Consider a goroutine that assigns a result to a shared variable and returns, while another goroutine reads that variable "later". There is no happens-before relation between the write and the read, so the program has a data race. Three things can go wrong, and only one of them is about hardware: 1. **Timing.** The reader may simply run first. Obvious, and the one people design around. 2. **The compiler.** Because the program is race-free by assumption, the compiler may hoist the load out of a loop and keep the value in a register. A spin loop on an unsynchronized flag can therefore never terminate — on any architecture, including x86. 3. **The hardware.** On a weakly ordered machine, a plain store may become visible to other cores late, or out of order with respect to earlier stores. "It worked when I tested it" distinguishes none of these. ## What actually creates the edge The join operation is the synchronization, not the goroutine's death: - **Channel receive.** A send is synchronized before the completion of the corresponding receive, and a `close` is synchronized before a receive that returns the zero value because the channel is closed. Both carry every write the goroutine made beforehand. - **`sync.WaitGroup`.** The `Done` call that unblocks a `Wait` is synchronized before that `Wait` returns — so after `Wait`, all the workers' prior writes are visible. - **A mutex.** An `Unlock` is synchronized before the next `Lock` returns, so a goroutine that wrote under the lock publishes to whoever takes it next. All three are ordinary Go and all three are cheap. There is no correct fourth option built out of sleeping. ## The `time.Sleep` trap Sleeping is not on the list of synchronization operations, so it creates no edge at all. A sleep that is "long enough" only makes the race unlikely to be observed on the machine you tried; it leaves the compiler free to do the register caching above, and it converts a deterministic bug into an intermittent one that will resurface under load, on different hardware, or after an unrelated optimisation. If you find yourself choosing a sleep duration, you are choosing how rarely the bug will appear. ## The other half: `main` does not wait A separate but related consequence of the same design: when `main.main` returns, the program exits. The runtime does not wait for other goroutines, does not run their deferred functions, and gives them no chance to flush anything. So a background goroutine writing a file or draining a queue needs the same explicit join — usually a channel the goroutine closes when it is finished, or a `WaitGroup` the shutdown path waits on. Relying on "it usually gets there first" is the same bug wearing different clothes. ## The compressed answer *Starting a goroutine is an edge; finishing one is not.* If you want to see what a goroutine produced, you must join it through a channel, a `WaitGroup`, or a mutex — and if you want it to finish at all before the process ends, the same join is what makes that true.

  • What happens to goroutines still running when main returns?
    The program exits immediately. The runtime does not wait for them, their deferred functions do not run, and any buffered output they were about to flush is lost. If a background goroutine must finish, the shutdown path has to join it explicitly — typically a channel it closes when done, or a `sync.WaitGroup` that `Wait` blocks on.
  • Does adding a long enough time.Sleep before the read make the program correct?
    No. `time.Sleep` is not a synchronization operation, so it creates no happens-before edge; the read is still racy. It only shrinks the window on the machine you tested. Worse, because the program is racy the compiler may cache the variable in a register, so no amount of sleeping helps.
  • Which edge does sync.WaitGroup.Wait give you?
    The `Done` call that unblocks a `Wait` is synchronized before that `Wait` returns. So every write a worker made before calling `Done` is guaranteed visible to the goroutine that was waiting, once `Wait` has returned. That is what makes a fan-out/collect pattern safe without further locking.

saying these in an interview costs you the question

  • Says a long enough sleep makes the write visible
  • Claims main waits for running goroutines to finish
  • Thinks reading after the goroutine has returned is safe
  • Believes exiting flushes the goroutine's writes to memory
  • Assumes a single-word value is safe without an ordering edge