Go Synchronization Establishes Observation, Not Execution Order
Use Go happens-before guarantees for visibility without assuming goroutine scheduling, lock fairness, or race-detector completeness.
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
Unlockcall is synchronized before a later successfulLockreturns as specified by the package. - The function run by
once.Do(f)completes before anyonce.Do(f)call returns. - A
WaitGroup.Doneis synchronized before the return of aWaitcall 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 fromdone, then reads the result.” - “Writers hold the mutex while changing both fields; readers hold it while checking their joint invariant.”
- “Initialization completes inside
Once.Dobefore any caller uses the object.” - “All tasks return before
Waitunblocks 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.