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:

  1. A send on a channel happens before the corresponding receive completes
  2. Closing a channel happens before a receive that returns zero value
  3. A receive from an unbuffered channel happens before the send completes
  4. 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:

  1. Happens-Before: Only happens-before relationships guarantee memory visibility
  2. Synchronization: Use channels, mutexes, atomic operations, or WaitGroups
  3. Data Races: Avoid concurrent access without synchronization
  4. Race Detector: Use go run -race to catch problems
  5. 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