Concurrency6 min read

Go Synchronization Establishes Observation, Not Execution Order

Use Go happens-before guarantees for visibility without assuming goroutine scheduling, lock fairness, or race-detector completeness.

  • concurrency
  • memory model
  • synchronization

Go synchronization creates ordering edges that make writes observable across goroutines. It does not generally promise which runnable goroutine executes first, which waiter acquires a lock next, or how operations without an ordering edge interleave. Prove the observation your program needs with channels, locks, sync.Once, sync.WaitGroup, or atomics; do not infer it from timing.

This article targets Go 1.27 and the June 2022 Go memory model. sync.WaitGroup.Go, used in current examples and preferred by current package documentation, requires Go 1.25; the older Add/Done pattern remains available.

Happens-before makes a read meaningful

Within one goroutine, language sequencing orders operations. Across goroutines, a read is guaranteed to observe another goroutine’s write only when synchronization connects them in the required direction. The memory model calls this the happens-before relation.

A channel close can publish preceding writes:

package main

import "fmt"

func main() {
	ready := make(chan struct{})
	var message string

	go func() {
		message = "configuration loaded"
		close(ready)
	}()

	<-ready
	fmt.Println(message)
}

Closing a channel is synchronized before a receive that returns because the channel is closed. The write to message is sequenced before close; the print is sequenced after the receive. Those relationships compose, so the main goroutine must observe the written string.

Replacing <-ready with time.Sleep would not establish the same guarantee. A delay may make a test appear reliable, but it neither orders memory nor proves the worker ran.

Each primitive promises a specific edge

The memory model and sync package documentation define the edges code may rely on:

  • A channel send is synchronized before completion of its matching receive.
  • Closing a channel is synchronized before a receive that observes the closed state.
  • For a mutex, an Unlock call is synchronized before a later successful Lock returns as specified by the package.
  • The function run by once.Do(f) completes before any once.Do(f) call returns.
  • A WaitGroup.Done is synchronized before the return of a Wait call that it unblocks.
  • Atomic operations behave as though they occur in one sequentially consistent order, with the observation rule defined by sync/atomic.

These are visibility and ordering contracts. A mutex also provides mutual exclusion, but its documentation does not turn arrival time into a general queueing API. If application order matters, represent that order explicitly with a channel, sequence number, queue, or coordinator.

A goroutine start is not a completion edge

A go statement evaluates the function value and arguments in the launching goroutine, then starts an independent goroutine. It does not wait for that function to run or finish. The memory model explicitly warns that goroutine exit has no automatic synchronization effect.

Waiting for work therefore needs a real primitive. On Go 1.25 and later, WaitGroup.Go combines task accounting with goroutine creation:

var wg sync.WaitGroup

for _, task := range tasks {
	task := task
	wg.Go(func() {
		run(task) // f must not panic
	})
}

wg.Wait()

The current WaitGroup.Go contract says the function must not panic. Its return synchronizes before a Wait return that it unblocks.

For Go 1.24 and earlier, increment before launching, not inside the goroutine:

wg.Add(1)
go func() {
	defer wg.Done()
	run(task)
}()

Putting Add(1) inside the new goroutine permits Wait to see a zero counter and return first. Go 1.25 also added a go vet analyzer for misplaced WaitGroup.Add calls, as recorded in the Go 1.25 release notes.

Channels coordinate; they do not imply global fairness

An unbuffered send and its matching receive synchronize. A buffered channel additionally limits how far producers can get ahead of consumers. Neither fact defines a total order for unrelated goroutines.

When several cases in one select can proceed, the language specification requires a uniform pseudo-random choice. That rule chooses one ready communication for that execution of the select; it is not a promise that every producer receives service within a deadline. Build fairness, priority, and admission control as explicit policies when they are requirements.

Likewise, successful Mutex.TryLock has the same synchronization effect as Lock, but failure has no synchronization effect at all. A failed speculative check reveals no safe information about protected state.

Atomics protect a protocol, not just a field

The sync/atomic package supplies low-level primitives and typed values such as atomic.Bool and atomic.Int64. Individual atomic loads and stores avoid a data race on that location, but a correct lock-free design must also define how several fields and state transitions relate.

For example, atomically publishing a ready flag can make earlier writes visible when the protocol follows the atomic ordering rules, but every reader must obey that protocol. Mixing atomic and ordinary access to the same variable is usually a race. Channels or a mutex make multi-field invariants easier to state and review, which is why the package recommends them for most programs.

The race detector observes executions, not all possibilities

Run concurrent tests with:

go test -race ./...

The official data race detector guide says it detects races that occur at runtime. Untested paths and interleavings remain invisible, so a clean run is evidence, not a proof that all synchronization is correct. Exercise realistic workloads and failure paths under -race.

The inverse matters too: a race-free program can still be logically wrong. It may deadlock, leak a goroutine, process messages in an unintended order, or violate an application invariant while every shared access is synchronized. The detector checks conflicting memory access, not fairness, liveness, or business rules.

State the required observation

Concurrency reviews become concrete when each shared fact has a publication edge and a consumption edge:

  • “The worker writes the result, then closes done; the caller receives from done, then reads the result.”
  • “Writers hold the mutex while changing both fields; readers hold it while checking their joint invariant.”
  • “Initialization completes inside Once.Do before any caller uses the object.”
  • “All tasks return before Wait unblocks and the aggregate is read.”

If the explanation instead depends on a short task usually finishing first, a scheduler usually waking waiters in order, or a test never failing, the program lacks the ordering it needs. Synchronize the observation directly and treat scheduling as an implementation choice.