18.6 The Memory Model: Happens-Before and Synchronization Guarantees
Right, so you’ve decided to play with fire. Good. Lock-free programming is like performing brain surgery on yourself, in a moving car, while blindfolded. It’s incredibly powerful, letting you build high-performance data structures that don’t block, but one wrong move and your program will fail in ways so subtle and bizarre you’ll start questioning reality itself.
The only thing standing between you and this madness is the Go memory model. It’s the rulebook for how memory operations are perceived by different goroutines. Ignore it, and you’re not writing code; you’re conducting a séance and hoping the spirits align your bits correctly.
The Illusion of Sequential Consistency
Here’s the first gut-punch: your computer is a filthy liar. It doesn’t execute your code in the beautiful, logical order you wrote it. The compiler reorders instructions for optimization. The CPU reorders instructions for pipelining and caching. Your multi-core machine has caches that aren’t instantly synchronized. The result is that writes by one goroutine might be seen in a completely different order by another. This isn’t a bug; it’s a fundamental property of modern hardware, done for performance reasons that pay my cloud bill.
Without synchronization, you’re flying blind. Look at this disaster waiting to happen:
var a, b int
func one() {
a = 1
b = 2
}
func two() {
for b != 2 {
// wait for b to be set... or are we?
}
fmt.Println(a) // What do you think this prints?
}
func main() {
go one()
go two()
time.Sleep(time.Second)
}
You, a logical human, think two() will print 1. But the Go memory model makes no such promise. Goroutine two might see the write to b before it sees the write to a. It could legitimately print 0. This is the problem we’re solving.
Happens-Before: The Golden Thread
The “happens-before” relationship is the formal mechanism the Go memory model uses to cut through this chaos. If event A happens-before event B, then the effects of A are guaranteed to be visible to B.
It’s not about time; it’s about visibility. The key is that these relationships are created by using specific synchronization primitives, like channels or the types in sync and sync/atomic. They create a contract between goroutines.
If you do nothing, there’s no happens-before. Chaos reigns. When you use a synchronization operation, it establishes a happens-before relationship between operations in different goroutines. A write that happens-before a send on a channel is guaranteed to be visible to the receive that happens-after that send.
How sync/atomic Creates Order
This is where atomic operations come in. They aren’t just about doing something without interruption; they are also synchronization operations that create critical happens-before edges. They act as fences.
The sync/atomic package provides a guarantee: if an atomic write to a variable happens-before an atomic read of that same variable, then the read is guaranteed to see the write and any other memory writes that happened before the atomic write.
Let’s fix our previous train wreck:
var a int
var b int32 // We'll use an atomic for this one
func one() {
a = 1 // Write to regular variable
atomic.StoreInt32(&b, 2) // Atomic write: This is the synchronization event
}
func two() {
for atomic.LoadInt32(&b) != 2 { // Atomic read: This synchronizes with the store
// busy wait
}
fmt.Println(a) // This is NOW guaranteed to print 1.
}
func main() {
go one()
go two()
time.Sleep(time.Second)
}
Why does this work now? The atomic store to b in one() happens-before the atomic load in two() that reads the value 2. Furthermore, the non-atomic write a = 1 happens-before the atomic store in the same goroutine. Therefore, by the transitive property of happens-before, a = 1 happens-before the fmt.Println(a). The memory model guarantees the visibility.
The Pitfalls: What atomic Does NOT Do
This is where everyone gets tripped up. The synchronization guarantee only applies to the atomic variable itself and the non-atomic operations that are correctly ordered around it.
Pitfall 1: Protecting non-atomic data. atomic only protects the single value you’re operating on. It does not magically protect other, unrelated variables. You must design your algorithm so that the atomic operation controls access to the other data, like our example above where the atomic b guards access to a.
Pitfall 2: The ABA Problem. Imagine a thread reads an atomic value A, gets preempted, and by the time it comes back, the value has changed to B and back to A again. A naive CompareAndSwap will succeed, thinking nothing changed. This is a classic lock-free headache. For most Go code, you won’t encounter this, but if you’re building complex structures, you must be aware of it. The typical mitigation is to use a version number alongside the pointer.
Pitfall 3: You’re on your own for algorithm design. The atomic package gives you the bricks (the operations) and the mortar (the memory model guarantees). It does not give you the blueprint for a house. Building a correct, efficient lock-free queue, stack, or map is notoriously difficult. There’s a reason these are often found in well-vetted, peer-reviewed libraries and not in your average business logic.
The rule of thumb? If you can use a channel or a sync.Mutex, do it. They are easier to reason about. Only resort to sync/atomic when you have concrete proof—from profiling—that a mutex is your bottleneck, and you have the expertise and time to verify the resulting lock-free code is correct. It’s a sharp tool, not a default choice.