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:

  1. Channel operations: A send on a channel happens before the corresponding receive completes
  2. Mutex operations: An unlock happens before a subsequent lock
  3. WaitGroup operations: A call to Add happens before the goroutines started by the calls to Wait
  4. 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.