Go Concurrency Patterns Series: ← Context Propagation | Series Overview | Graceful Shutdown →
What is the Go Memory Model?
The Go Memory Model specifies the conditions under which reads of a variable in one goroutine can be guaranteed to observe values produced by writes to the same variable in a different goroutine. Understanding this model is crucial for writing correct concurrent code without data races.
Core Concepts:
- Happens-Before: Ordering guarantees between memory operations
- Memory Visibility: When writes in one goroutine are visible to reads in another
- Synchronization: Mechanisms that establish happens-before relationships
- Data Races: Concurrent memory accesses without proper synchronization
Real-World Impact
- Correctness: Prevent subtle bugs in concurrent code
- Performance: Understand when synchronization is necessary
- Debugging: Diagnose race conditions and memory visibility issues
- Optimization: Make informed decisions about lock-free algorithms
- Code Review: Identify potential concurrency bugs
The Happens-Before Relationship
Definition
A happens-before relationship guarantees that one event occurs before another in program order, and that effects of the first event are visible to the second.
package main
import (
"fmt"
"time"
)
var a, b int
// Example demonstrating happens-before
func happensBefore() {
// Event 1: Write to 'a' happens before write to 'b'
a = 1
b = 2
// Within a single goroutine, statements execute in order
fmt.Printf("a = %d, b = %d\n", a, b) // Always prints: a = 1, b = 2
}
// NO happens-before guarantee between goroutines without synchronization
func noHappensBefore() {
go func() {
a = 1 // Write in goroutine 1
}()
go func() {
fmt.Println(a) // Read in goroutine 2 - MAY see 0 or 1!
}()
time.Sleep(100 * time.Millisecond)
}
Program Order Guarantee
Within a single goroutine, operations happen in the order specified by the program:
func singleGoroutine() {
x := 1 // 1
y := 2 // 2 happens after 1
z := 3 // 3 happens after 2
// Reading y is guaranteed to see the value 2
// Reading z is guaranteed to see the value 3
fmt.Printf("x=%d, y=%d, z=%d\n", x, y, z)
}
Memory Visibility Without Synchronization
The Problem: Data Races
package main
import (
"fmt"
"time"
)
// WARNING: This code has a data race!
var sharedCounter int
func increment() {
for i := 0; i < 1000; i++ {
sharedCounter++ // RACE: concurrent read and write
}
}
func demonstrateRace() {
// Start two goroutines
go increment()
go increment()
time.Sleep(time.Second)
// Result is unpredictable! Could be anywhere from 1000 to 2000
fmt.Printf("Counter: %d\n", sharedCounter)
}
Run with race detector:
go run -race main.go
Output:
==================
WARNING: DATA RACE
Write at 0x... by goroutine 7:
main.increment()
/path/to/main.go:12 +0x...
Previous read at 0x... by goroutine 6:
main.increment()
/path/to/main.go:12 +0x...
==================
Why Data Races Are Dangerous
package main
import (
"fmt"
"sync"
)
type Config struct {
ready bool
value int
}
var config Config
// BAD: No synchronization
func unsafePublish() {
go func() {
// Writer goroutine
config.value = 42
config.ready = true // Signal that config is ready
}()
// Reader goroutine
for !config.ready { // RACE: reading 'ready'
// Busy wait
}
// RACE: May see value = 0 even though ready = true!
fmt.Printf("Config value: %d\n", config.value)
}
// GOOD: Proper synchronization
func safePublish() {
var mu sync.Mutex
done := make(chan bool)
go func() {
mu.Lock()
config.value = 42
config.ready = true
mu.Unlock()
done <- true
}()
<-done // Wait for signal
mu.Lock()
fmt.Printf("Config value: %d\n", config.value) // Guaranteed to see 42
mu.Unlock()
}
Synchronization Primitives and Happens-Before
1. Channel Operations
Channels establish happens-before relationships:
package main
import (
"fmt"
)
var data int
func channelHappensBefore() {
ch := make(chan bool)
// Goroutine 1: Writer
go func() {
data = 42 // 1. Write to data
ch <- true // 2. Send on channel
}()
// Goroutine 2: Reader
<-ch // 3. Receive from channel
fmt.Println(data) // 4. Read data
// Guarantees:
// - 1 happens before 2 (program order)
// - 2 happens before 3 (send happens before receive)
// - 3 happens before 4 (program order)
// Therefore: 1 happens before 4 (data write visible to read)
}
Channel Rules:
- A send on a channel happens before the corresponding receive completes
- Closing a channel happens before a receive that returns zero value
- A receive from an unbuffered channel happens before the send completes
- The k-th receive on a channel with capacity C happens before the (k+C)-th send completes
2. Mutex Operations
package main
import (
"fmt"
"sync"
)
var (
mu sync.Mutex
count int
)
func mutexHappensBefore() {
// Goroutine 1
go func() {
mu.Lock()
count = 1 // 1. Write under lock
mu.Unlock() // 2. Release lock
}()
// Goroutine 2
mu.Lock() // 3. Acquire lock
fmt.Println(count) // 4. Read under lock
mu.Unlock()
// Guarantee:
// - Unlock (2) happens before Lock (3)
// - Therefore: Write (1) happens before Read (4)
}
Mutex Rules:
- For any Mutex m, call n of m.Unlock() happens before call m+1 of m.Lock()
3. WaitGroup
package main
import (
"fmt"
"sync"
)
func waitGroupHappensBefore() {
var wg sync.WaitGroup
var result int
wg.Add(1)
go func() {
result = 42 // 1. Write
wg.Done() // 2. Decrement counter
}()
wg.Wait() // 3. Wait returns
fmt.Println(result) // 4. Read
// Guarantee:
// - Done (2) happens before Wait returns (3)
// - Therefore: Write (1) happens before Read (4)
}
4. Once
package main
import (
"fmt"
"sync"
)
var (
once sync.Once
config int
)
func initialize() {
config = 42 // Guaranteed to complete before Once.Do returns
}
func onceHappensBefore() {
// Multiple goroutines
for i := 0; i < 10; i++ {
go func() {
once.Do(initialize) // Only runs once
fmt.Println(config) // All goroutines see 42
}()
}
}
Once Rule:
- A single call to f() from once.Do(f) happens before any once.Do(f) returns
Atomic Operations
Memory Ordering with sync/atomic
package main
import (
"fmt"
"sync/atomic"
"time"
)
var (
data int
ready atomic.Bool
)
func atomicPublish() {
go func() {
data = 42 // 1. Write data
ready.Store(true) // 2. Atomic store (release semantics)
}()
// Wait for ready
for !ready.Load() { // 3. Atomic load (acquire semantics)
time.Sleep(time.Millisecond)
}
fmt.Println(data) // 4. Read data
// Atomic operations provide synchronization:
// - Store (2) synchronizes with Load (3)
// - Write (1) happens before Read (4)
}
Compare-and-Swap (CAS)
package main
import (
"fmt"
"sync/atomic"
)
type LockFreeStack struct {
head atomic.Pointer[node]
}
type node struct {
value int
next *node
}
func (s *LockFreeStack) Push(value int) {
newNode := &node{value: value}
for {
oldHead := s.head.Load()
newNode.next = oldHead
// CAS provides happens-before guarantee
if s.head.CompareAndSwap(oldHead, newNode) {
return
}
// If CAS fails, retry
}
}
func (s *LockFreeStack) Pop() (int, bool) {
for {
oldHead := s.head.Load()
if oldHead == nil {
return 0, false
}
newHead := oldHead.next
// CAS ensures memory ordering
if s.head.CompareAndSwap(oldHead, newHead) {
return oldHead.value, true
}
// If CAS fails, retry
}
}
Initialization Order
Package Initialization
package main
import "fmt"
// 1. Package-level variables initialized in declaration order
var a = b + c // 3rd: after b and c
var b = f() // 1st: function called
var c = 2 // 2nd: constant
func f() int {
fmt.Println("Initializing b")
return 1
}
// 2. init() functions run after variable initialization
func init() {
fmt.Println("init() running")
// All package variables are initialized
}
// 3. main() runs after all init() functions
func main() {
fmt.Printf("a=%d, b=%d, c=%d\n", a, b, c)
}
Output:
Initializing b
init() running
a=3, b=1, c=2
Goroutine Creation
package main
import (
"fmt"
"time"
)
var x int
func goroutineCreation() {
x = 1 // 1. Write
go func() {
fmt.Println(x) // 2. Read
}() // Go statement
// Guarantee:
// - Write (1) happens before go statement
// - Go statement happens before goroutine execution starts
// - Therefore: Write (1) happens before Read (2)
time.Sleep(10 * time.Millisecond)
}
Rule: The go statement that starts a new goroutine happens before the goroutine’s execution begins.
Goroutine Destruction
package main
import (
"fmt"
)
func goroutineExit() {
ch := make(chan int)
go func() {
x := 42
// NO guarantee that this write is visible after goroutine exits!
ch <- x // Must use channel to communicate
}()
// This guarantees we see the value
value := <-ch
fmt.Println(value)
}
Rule: The exit of a goroutine is NOT guaranteed to happen before any event in the program.
Common Concurrency Patterns and Memory Model
1. Double-Checked Locking (Broken in Go)
package main
import (
"sync"
)
var (
instance *Singleton
mu sync.Mutex
)
type Singleton struct {
value int
}
// BAD: Double-checked locking doesn't work in Go!
func getBrokenSingleton() *Singleton {
if instance == nil { // RACE: First check without lock
mu.Lock()
if instance == nil {
instance = &Singleton{value: 42}
}
mu.Unlock()
}
return instance // May return partially initialized instance!
}
// GOOD: Use sync.Once
var (
singleInstance *Singleton
once sync.Once
)
func getSingleton() *Singleton {
once.Do(func() {
singleInstance = &Singleton{value: 42}
})
return singleInstance
}
2. Lazy Initialization
package main
import (
"sync"
)
type LazyValue struct {
once sync.Once
value int
}
func (l *LazyValue) Get() int {
l.once.Do(func() {
// Expensive initialization
l.value = computeValue()
})
return l.value // Safe: Once guarantees happens-before
}
func computeValue() int {
// Expensive computation
return 42
}
3. Producer-Consumer with Visibility
package main
import (
"fmt"
"sync"
)
type Message struct {
ID int
Data string
}
func producerConsumer() {
queue := make(chan *Message, 10)
var wg sync.WaitGroup
// Producer
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 5; i++ {
msg := &Message{
ID: i,
Data: fmt.Sprintf("Message %d", i),
}
// Send establishes happens-before
queue <- msg
}
close(queue)
}()
// Consumer
wg.Add(1)
go func() {
defer wg.Done()
for msg := range queue {
// Receive guarantees we see complete Message
fmt.Printf("Received: %+v\n", msg)
}
}()
wg.Wait()
}
Memory Model Violations
Example 1: Reordering
package main
import "fmt"
var x, y int
var a, b bool
// Compiler/CPU can reorder these!
func reorderingExample() {
go func() {
x = 1 // A
a = true // B
}()
go func() {
y = 1 // C
b = true // D
}()
// Possible to observe: a=true, b=true, x=0, y=0
// Due to reordering: B before A, D before C
fmt.Printf("x=%d, y=%d, a=%t, b=%t\n", x, y, a, b)
}
Example 2: Cache Visibility
package main
import (
"runtime"
"time"
)
// BAD: Spin loop without synchronization
func busyWait() {
var flag bool
go func() {
time.Sleep(100 * time.Millisecond)
flag = true // Write to flag
}()
// May loop forever! Compiler might optimize to:
// if !flag { for {} }
for !flag {
runtime.Gosched() // Hint to scheduler, but doesn't guarantee visibility
}
}
// GOOD: Use channel
func properWait() {
done := make(chan bool)
go func() {
time.Sleep(100 * time.Millisecond)
done <- true
}()
<-done // Guaranteed to observe the signal
}
Testing for Race Conditions
Using the Race Detector
package main
import (
"sync"
"testing"
)
var counter int
func increment() {
counter++ // RACE
}
func TestRaceCondition(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
increment()
}()
}
wg.Wait()
if counter != 100 {
t.Errorf("Expected 100, got %d", counter)
}
}
Run with:
go test -race
Writing Race-Free Code
package main
import (
"sync"
"sync/atomic"
)
// Option 1: Mutex
type SafeCounterMutex struct {
mu sync.Mutex
count int
}
func (c *SafeCounterMutex) Increment() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
// Option 2: Atomic
type SafeCounterAtomic struct {
count atomic.Int64
}
func (c *SafeCounterAtomic) Increment() {
c.count.Add(1)
}
// Option 3: Channel
type SafeCounterChannel struct {
ops chan func(*int)
}
func NewSafeCounterChannel() *SafeCounterChannel {
c := &SafeCounterChannel{
ops: make(chan func(*int)),
}
go c.run()
return c
}
func (c *SafeCounterChannel) run() {
var count int
for op := range c.ops {
op(&count)
}
}
func (c *SafeCounterChannel) Increment() {
c.ops <- func(count *int) {
*count++
}
}
Best Practices
1. Don’t Communicate by Sharing Memory
// BAD: Shared memory
var sharedMap = make(map[string]int)
var mapMutex sync.Mutex
func updateShared(key string, value int) {
mapMutex.Lock()
sharedMap[key] = value
mapMutex.Unlock()
}
// GOOD: Share memory by communicating
type Update struct {
Key string
Value int
}
func mapManager(updates chan Update) {
m := make(map[string]int)
for update := range updates {
m[update.Key] = update.Value
}
}
2. Use sync/atomic for Simple Counters
// GOOD: Atomic for simple cases
var requestCount atomic.Int64
func handleRequest() {
requestCount.Add(1)
// Handle request
}
func getRequestCount() int64 {
return requestCount.Load()
}
3. Avoid Premature Optimization
// Start with channels and mutexes
// Profile before using lock-free algorithms
// Most code doesn't need lock-free optimizations
Performance Implications
Memory Barriers
Synchronization operations insert memory barriers:
- Acquire barrier: Prevents reordering of subsequent loads/stores before the barrier
- Release barrier: Prevents reordering of prior loads/stores after the barrier
- Full barrier: Both acquire and release semantics
// Mutex.Lock() = Acquire barrier
// Mutex.Unlock() = Release barrier
// Channel send/receive = Full barrier
// Atomic operations = Configurable barriers
False Sharing
package main
import "sync/atomic"
// BAD: False sharing
type Counters struct {
a atomic.Int64 // These might share a cache line
b atomic.Int64 // Causing performance degradation
}
// GOOD: Padding to separate cache lines
type PaddedCounters struct {
a atomic.Int64
_ [56]byte // Padding (64-byte cache line - 8 bytes)
b atomic.Int64
_ [56]byte
}
Conclusion
Understanding the Go Memory Model is essential for writing correct concurrent programs. Key principles:
- Happens-Before: Only happens-before relationships guarantee memory visibility
- Synchronization: Use channels, mutexes, atomic operations, or WaitGroups
- Data Races: Avoid concurrent access without synchronization
- Race Detector: Use
go run -raceto catch problems - Simplicity: Prefer channels and mutexes over lock-free algorithms
Key Takeaways:
- Within a goroutine, operations happen in program order
- Between goroutines, you need explicit synchronization
- Channels provide the strongest guarantees
- Always use the race detector during testing
- “Share memory by communicating” is usually the right approach
Next, learn about Graceful Shutdown Patterns to properly clean up resources and stop goroutines.
Previous: Context Propagation Patterns Next: Graceful Shutdown Patterns Series: Go Concurrency Patterns