Happens-Before: Mutex, Channels, Atomic, WaitGroup
The Go memory model is built around a single concept: the happens-before relationship. Every correct concurrent program in Go depends on establishing happens-before edges between goroutines at the right points. This article makes those edges precise for each of the four main synchronization mechanisms: mutexes, channels, atomics, and WaitGroups.
What Happens-Before Means
Happens-before is a partial order over events (memory reads and writes, synchronization operations) in a concurrent program. Writing it formally: if event A happens-before event B, we write A → B, and the guarantee is:
- Every memory write that A can observe is also observable at B.
- The program (compiler + CPU + runtime) must not behave in a way that contradicts this ordering.
If neither A → B nor B → A holds, the events are concurrent and neither can make any assumption about the other's memory writes. This is not just a theoretical concern — the compiler and CPU actively reorder operations that are not constrained by happens-before.
Happens-before is not about wall-clock time. Two events can be "concurrent" even if they happen microseconds apart. What matters is whether a synchronization operation creates a formal ordering between them.
1. Mutex
The Go memory model specifies for sync.Mutex (and sync.RWMutex):
For any
sync.Mutexorsync.RWMutexvariableland n < m, call n ofl.Unlock()happens-before call m ofl.Lock()returns.
In plain terms: when goroutine A releases the lock, every write goroutine A made while holding the lock is visible to goroutine B when goroutine B subsequently acquires the same lock.
package main
import (
"fmt"
"sync"
)
type SafeBuffer struct {
mu sync.Mutex
data []string
}
func (b *SafeBuffer) Produce(item string) {
b.mu.Lock()
b.data = append(b.data, item) // Write happens-before Unlock
b.mu.Unlock()
// Unlock happens-before the next Lock in any goroutine
}
func (b *SafeBuffer) Drain() []string {
b.mu.Lock()
out := b.data // This Lock happens-after the Unlock above
b.data = nil
b.mu.Unlock()
return out
}
func main() {
buf := &SafeBuffer{}
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
buf.Produce(fmt.Sprintf("item-%d", n))
}(i)
}
wg.Wait()
fmt.Println("Collected:", buf.Drain())
}
The sequence of Lock/Unlock calls chains the happens-before relationships. Every item produced before an Unlock is visible after the subsequent Lock, so the final Drain sees all five items.
2. Channels
Channel operations are the most expressive synchronization tool in Go. The memory model gives two rules:
Unbuffered channels: A send on channel ch happens-before the corresponding receive from ch completes.
Buffered channels: The kth receive from a channel of capacity C happens-before the (k+C)th send on that channel completes.
The buffered rule is less intuitive. It means the channel acts as a counting semaphore: a send can only complete once there is capacity, and capacity is freed by receives.
package main
import "fmt"
func main() {
ch := make(chan int) // unbuffered
result := 0
go func() {
result = 99 // Write
ch <- 1 // Send happens-before receive completes
}()
<-ch // Receive completes — happens-after the send
fmt.Println(result) // Guaranteed to see 99
}
Buffered Channel as Semaphore
package main
import (
"fmt"
"sync"
)
func main() {
// Allow at most 3 concurrent workers
sem := make(chan struct{}, 3)
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
sem <- struct{}{} // Acquire: blocks when 3 slots full
fmt.Printf("worker %d running\n", n)
<-sem // Release: the kth receive happens-before (k+C)th send
}(i)
}
wg.Wait()
}
The memory model guarantee for buffered channels ensures that each receive that frees a slot happens-before the next blocked sender unblocks, establishing the ordering needed for safe resource limiting.
3. Atomic Operations
Since the Go 1.19 memory model revision, the specification explicitly states:
If the effect of an atomic operation A is observed by atomic operation B, then A happens-before B.
This formalizes what was previously implementation-defined behavior. sync/atomic operations are now sequentially consistent: a Store that is observed by a Load establishes a happens-before edge.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var ready atomic.Int32
var data string
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
data = "the result"
ready.Store(1) // Store happens-before the Load that sees 1
}()
wg.Add(1)
go func() {
defer wg.Done()
// Spin-wait: not great for performance, but correct for demonstration
for ready.Load() == 0 {
} // Load observes 1 — happens-after the Store
fmt.Println(data) // Guaranteed to see "the result"
}()
wg.Wait()
}
The spin-wait above is shown for clarity. In production code, prefer channels or a sync.Mutex with a condition variable (sync.Cond). Spin-waiting burns CPU and can degrade badly under contention.
4. WaitGroup
The sync.WaitGroup rule is:
For any
sync.WaitGroupvaluewg, a call towg.Done()happens-before a call towg.Wait()that it unblocks returns.
This means all memory writes made by a goroutine before calling wg.Done() are visible to the goroutine after wg.Wait() returns.
package main
import (
"fmt"
"sync"
)
func process(id int, results []int, wg *sync.WaitGroup) {
defer wg.Done()
results[id] = id * id // Write before Done
}
func main() {
const n = 8
results := make([]int, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go process(i, results, &wg)
}
wg.Wait() // Returns only after all Done() calls
// All writes to results are visible here
fmt.Println(results)
}
Common Misconception: Channels Do Not Make Shared Data Safe
A frequent mistake is believing that if two goroutines communicate via a channel, all their shared memory accesses are automatically safe. This is false. The channel only establishes happens-before between the send and receive operations themselves. Any other shared variable accessed outside of that synchronization point is still unprotected.
package main
import (
"fmt"
"sync"
)
var sharedLog []string // still needs protection
func main() {
ch := make(chan int)
var wg sync.WaitGroup
var mu sync.Mutex // needed even though ch exists
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
mu.Lock()
sharedLog = append(sharedLog, fmt.Sprintf("goroutine %d", n))
mu.Unlock()
ch <- n
}(i)
}
go func() {
wg.Wait()
close(ch)
}()
for range ch {
// drain
}
fmt.Println("log entries:", len(sharedLog))
}
The channel coordinates completion signaling. The mutex protects the shared slice. These are two separate concerns requiring separate synchronization.
Key Takeaways
- Happens-before is a partial order; concurrent events (no ordering in either direction) have no memory visibility guarantees.
- Mutex:
Unlock()call n happens-beforeLock()call n+1. All writes made under the lock are visible to the next holder. - Unbuffered channel: send happens-before receive completes. Buffered channel: kth receive happens-before (k+C)th send completes.
- Atomic (Go 1.19+): a Store is sequentially consistent — a Load that observes it happens-after it.
- WaitGroup:
Done()happens-beforeWait()returns. Writes beforeDone()are visible afterWait(). - Using a channel to signal does not protect unrelated shared memory. Each shared resource needs its own synchronization.