Understanding Go’s Memory Model
Before diving into atomic operations, it’s essential to understand Go’s memory model, which defines the rules for how memory operations in one goroutine become visible to another. This understanding forms the foundation for correct concurrent programming in Go.
Memory Ordering and Visibility
Go’s memory model is based on the happens-before relationship, which determines when the effects of one goroutine’s memory operations become visible to another goroutine:
package main
import (
"fmt"
"sync"
"time"
)
func memoryModelDemo() {
var a, b int
var wg sync.WaitGroup
wg.Add(2)
// Without proper synchronization, there's no guarantee
// about the visibility of memory operations between goroutines
go func() {
defer wg.Done()
a = 1 // Write to a
// No synchronization here
fmt.Printf("First goroutine: b = %d\n", b) // Read b
}()
go func() {
defer wg.Done()
b = 1 // Write to b
// No synchronization here
fmt.Printf("Second goroutine: a = %d\n", a) // Read a
}()
wg.Wait()
fmt.Printf("Final values: a = %d, b = %d\n", a, b)
}
func main() {
// Run multiple times to observe different outcomes
for i := 0; i < 5; i++ {
fmt.Printf("\nIteration %d:\n", i+1)
memoryModelDemo()
time.Sleep(10 * time.Millisecond)
}
}
This example demonstrates that without proper synchronization, there’s no guarantee about when writes in one goroutine become visible to another. The output might vary across runs, showing that both goroutines could read zero for the variable written by the other goroutine, despite the writes happening before the reads in program order.
Establishing Happens-Before Relationships
Go provides several mechanisms to establish happens-before relationships:
- Channel operations: A send on a channel happens before the corresponding receive completes
- Mutex operations: An unlock happens before a subsequent lock
- WaitGroup operations: A call to
Add
happens before the goroutines started by the calls toWait
- Atomic operations: Provide memory ordering guarantees
Here’s how atomic operations establish happens-before relationships:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func atomicHappensBefore() {
var x int32
var y int32
var wg sync.WaitGroup
wg.Add(2)
// Goroutine 1
go func() {
defer wg.Done()
// Store to x happens before the atomic store to y
x = 42
// This atomic store creates a happens-before relationship
atomic.StoreInt32(&y, 1)
}()
// Goroutine 2
go func() {
defer wg.Done()
// Wait until y is set to 1
for atomic.LoadInt32(&y) == 0 {
// Spin until y is observed as 1
}
// The atomic load from y happens before this read of x
// Due to the happens-before relationship, x must be 42 here
fmt.Printf("x = %d\n", x)
}()
wg.Wait()
}
func main() {
atomicHappensBefore()
}
This example demonstrates how atomic operations establish happens-before relationships. The atomic store to y
happens before the atomic load of y
that observes the value 1. Combined with the program order rules, this ensures that the write to x
happens before the read of x
in the second goroutine, guaranteeing that the second goroutine will see x
as 42.
Memory Barriers and Ordering Constraints
Atomic operations in Go implicitly include memory barriers that enforce ordering constraints:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func memoryBarrierDemo() {
var flag int32
var data [10]int
var wg sync.WaitGroup
wg.Add(2)
// Writer goroutine
go func() {
defer wg.Done()
// Initialize data
for i := 0; i < len(data); i++ {
data[i] = i + 1
}
// Memory barrier: ensures all writes to data are visible
// before the flag is set
atomic.StoreInt32(&flag, 1)
}()
// Reader goroutine
go func() {
defer wg.Done()
// Wait for flag to be set
for atomic.LoadInt32(&flag) == 0 {
// Spin waiting for flag
}
// Memory barrier: ensures all reads of data happen after
// the flag is observed as set
sum := 0
for i := 0; i < len(data); i++ {
sum += data[i]
}
fmt.Printf("Sum of data: %d\n", sum)
// Expected output: Sum of data: 55 (1+2+3+...+10)
}()
wg.Wait()
}
func main() {
memoryBarrierDemo()
}
This example demonstrates how atomic operations create memory barriers that ensure proper ordering of non-atomic memory operations. The atomic store to flag
ensures that all writes to the data
array are visible to other goroutines that observe flag
as 1 through an atomic load.
Understanding these memory model concepts is crucial for correctly implementing lock-free algorithms with atomic operations. Without proper attention to memory ordering, concurrent programs can exhibit subtle and hard-to-reproduce bugs.