Skip to Content
Course content

62: Go Memory Model Basics

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing code for a colleague who had built a high-performance telemetry aggregator. He had a worker goroutine that processed a queue of events in a loop, and to shut it down gracefully, he used a simple boolean flag: stopWorker := false. When the main thread wanted the worker to quit, it just set stopWorker = true. On his local machine, it worked perfectly every time. But once we deployed it to a massive 64-core production server, we started seeing "zombie" workers—goroutines that simply refused to die, continuing to process data long after the shutdown signal was sent. He was baffled because the code was logically sound; the flag was being set, yet the worker loop never saw it.

What he ran into is the "invisible" part of Go: the Go Memory Model. Most of us assume that if Goroutine A writes a value to a variable, Goroutine B will see that value immediately. In reality, that is not guaranteed. Between your code and the hardware, there are compilers and CPUs that love to reorder operations or cache values in registers to make things faster. Without explicit synchronization, Go doesn't guarantee that a write in one goroutine is ever observed by another.

Understanding the "Happens-Before" Relationship

The entire Go Memory Model is built around a concept called the "happens-before" relationship. If event A happens-before event B, then the memory effects of A are guaranteed to be visible to the observer of B. If there is no happens-before relationship, you have a data race, and the behavior is undefined. You might see the correct value, you might see a stale value, or in rare cases, you might see a partially written value.

For example, a channel send happens-before the corresponding channel receive completes. This is why channels are the gold standard for communication in Go; they don't just move data, they create a synchronization barrier that forces the memory to sync across CPU cores. Similarly, a sync.Mutex.Unlock() happens-before a subsequent sync.Mutex.Lock() on the same mutex. When you use these tools, you aren't just preventing concurrent access; you're telling the compiler and the CPU, "Do not reorder these operations, and make sure the other thread sees what I just did."

The Cost of Naked Shared Variables

When you use a "naked" variable—like my colleague's boolean flag—without a mutex, an atomic operation, or a channel, you're gambling. The Go compiler might decide that since the worker loop doesn't modify the stopWorker variable itself, it can just load that value into a CPU register once and reuse it forever. To the CPU, it looks like an optimization. To you, it looks like a bug that only happens on certain hardware architectures.

// DANGEROUS: This may never terminate on some architectures
func worker(stop *bool) {
    for !*stop {
        // do work
    }
}

I've seen people try to "fix" this by using time.Sleep inside the loop, thinking it forces a refresh. Don't do that. It's a band-aid on a bullet wound. The only correct way to handle this is to establish a formal happens-before relationship. Either use a channel to signal the shutdown, protect the flag with a sync.RWMutex, or use the sync/atomic package to ensure the load and store operations are visible across all cores.




📋 Practical Task

Fixing the Ghost-Loop Shutdown Signal

You've been handed a piece of code for a background logger. The developer used a shared boolean to signal when the logger should stop, but it's failing the race detector and occasionally hanging during shutdown in the CI environment.

Your task is to refactor the Logger struct and its Stop() method to ensure the running flag is handled according to the Go Memory Model. You may use sync/atomic or a sync.Mutex, but the goal is to eliminate the data race and guarantee that the run() loop terminates immediately when Stop() is called.

package main

import (
	"fmt"
	"time"
)

type Logger struct {
	running bool // This is the problematic field
}

func (l *Logger) run() {
	fmt.Println("Logger started...")
	for l.running {
		fmt.Println("Logging data...")
		time.Sleep(100 * time.Millisecond)
	}
	fmt.Println("Logger stopped.")
}

func (l *Logger) Stop() {
	l.running = false
}

func main() {
	l := &Logger{running: true}
	go l.run()

	time.Sleep(500 * time.Millisecond)
	l.Stop()
	
	// Wait a moment to see if it actually stops
	time.Sleep(200 * time.Millisecond)
	fmt.Println("Main exiting.")
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.