18.2 Load, Store, Add, and Swap Operations
Right, let’s get our hands dirty. You’re here because you want to go faster, and you’ve realized that slapping a big Mutex{} around every piece of data in your hot path is like trying to win a Formula 1 race by driving a tank. It’s safe, sure, but it’s not exactly performant.
The sync/atomic package is your pit crew for this. It gives you a set of incredibly sharp, precise tools to manipulate data at the CPU instruction level. We’re talking about the fundamental operations that your processor guarantees are indivisible—they happen in a single, uninterruptible step. No other CPU core can see a half-finished operation. This is the bedrock of lock-free programming.
The Basic Moves: Load, Store, Add, and Swap
These are your core primitives. They work on a handful of types: int32, int64, uint32, uint64, uintptr, and the general-purpose Pointer type for, well, unsafe pointers.
Think of an atomic variable not as a simple integer, but as a tiny, heavily guarded castle. You can’t just wander in and change the flag on the tower. You have to use the specific, approved methods.
atomic.LoadInt64(&myInt) is you asking the guard, “What number is on the flag right now?” You get a clean, consistent snapshot of the value at a single moment in time. This is crucial. Without an atomic load, on some architectures, you could theoretically read a torn value—a half-written 64-bit integer because a write on another core happened in the middle of your read. Atomic loads prevent that.
atomic.StoreInt64(&myInt, 42) is you telling the guard, “Change the flag to 42. Now. And don’t let anyone see it halfway up the pole.” It ensures that any observer will either see the old value or the new value, never a garbled mess in between.
Let’s see them in action. This is a classic example: a concurrent counter and a background logger that needs to safely read its current value.
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
func main() {
var counter int64 // This MUST be used only with atomic operations now.
var wg sync.WaitGroup
// Goroutine 1: The Writer (Incrementer)
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 10; i++ {
time.Sleep(1 * time.Millisecond) // Simulate some work
atomic.AddInt64(&counter, 1)
}
}()
// Goroutine 2: The Reader (Logger)
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 5; i++ {
time.Sleep(2 * time.Millisecond) // Simulate less frequent logging
val := atomic.LoadInt64(&counter) // This is the safe read.
fmt.Printf("Current counter value: %d\n", val)
}
}()
wg.Wait()
fmt.Println("Final counter:", atomic.LoadInt64(&counter))
}
Notice how the reader never just accesses counter directly. It always uses atomic.Load. This is non-negotiable. The memory model doesn’t guarantee you’ll see writes from other goroutines without either synchronization (like channels or mutexes) or atomic operations.
The Workhorses: Add and Swap
atomic.AddInt64(&myInt, 1) is the atomic increment. It’s the equivalent of “take the current value, add one to it, and store it back” all as one, uninterruptible operation. This is how you build counters without any locks. It’s blindingly fast.
atomic.SwapInt64(&myInt, 42) is a bit more interesting. It sets the value to 42 and returns the old value that was in there. Also in one atomic step. This is useful, but its utility is a bit limited on its own. Its real power is unlocked when we get to CompareAndSwap in the next section. Think of it as “get out of the way, I’m taking over, but tell me what was here before I arrived.”
The Giant, Glaring Caveat (This is Important)
Here’s where I have to be your brilliant friend and give you the hard truth. These operations are atomic, but that doesn’t magically make your logic thread-safe.
Look at this seemingly innocent code:
// This is a TERRIBLE idea. Do NOT do this.
func maybeUpdate(newValue int64) {
oldValue := atomic.LoadInt64(&sharedValue)
if newValue > oldValue {
// DANGER ZONE: The value of `sharedValue` could have changed right here!
atomic.StoreInt64(&sharedValue, newValue)
}
}
The time between the Load and the Store is a gaping race condition. Another goroutine could have changed sharedValue to something else entirely. Your check (newValue > oldValue) is now based on stale data, and your subsequent Store might overwrite a better value. This pattern is wrong. Just flat-out wrong.
This is the fundamental mind-shift of lock-free programming: you can’t think in steps. You have to think in single, atomic operations that validate the state as they change it. This is why Add is safe—it doesn’t have a gap. The entire operation is the single step.
So, while Load, Store, Add, and Swap are your essential building blocks, they are just that—blocks. To build anything truly interesting and safe, you need a more powerful tool: CompareAndSwap. And that, my friend, is where the real fun begins.