18.1 atomic.Int64, atomic.Uint32, and Other Atomic Types
Right, so you’ve graduated from just slapping a mutex around everything and you’re ready to get your hands dirty with the real stuff. Good. Using sync/atomic is like being handed a surgeon’s scalpel instead of a sledgehammer. It’s precise, incredibly powerful, and if you slip up, you’ll bleed all over the operating table with race conditions that are a nightmare to debug.
The sync/atomic package gives you a set of primitives for performing “atomic” operations. Atomic, in this context, means the operation completes without any other goroutine being able to see it halfway done or interfere. It’s all-or-nothing. This is the fundamental building block for most lock-free algorithms.
Before Go 1.19, using this package was a bit of a pain. You were dealing with awkward atomic.LoadInt64(&myInt) and atomic.StoreUint32(&myOtherInt, 42) calls, passing pointers everywhere. It worked, but it felt like you were writing C. Thankfully, the Go team finally gave us the nice, shiny wrapper types we deserved: atomic.Int64, atomic.Uint32, atomic.Uintptr, atomic.Bool, and the wonderfully generic atomic.Pointer[T]. These are the new, recommended way to do this, and we’re going to focus on them because they’re objectively better.
The New Atomic Types: Your Best Friends
These types encapsulate the value they’re protecting and provide all the methods you need. The beauty is that you can’t even misuse them by forgetting to pass a pointer; the methods are all bound to the type itself.
Let’s look at a classic use case: a simple counter that multiple goroutines are pounding on.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter atomic.Int64()
var wg sync.WaitGroup
// Launch 100 goroutines that each increment the counter 1000 times.
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
for c := 0; c < 1000; c++ {
counter.Add(1) // This is the magic.
}
wg.Done()
}()
}
wg.Wait()
fmt.Println("Final counter:", counter.Load())
}
Run this. It will always print 100000. No mutexes, no fuss. The Add method is atomic. You can’t have two goroutines read the same value, increment it, and write it back at the same time. One Add completes entirely before the next one can start.
What Can You Actually Do With Them?
Each type (Int64, Uint32, etc.) has a consistent set of methods. Here’s the cheat sheet:
Load(): Safely reads the current value. This is crucial. You can’t just readmyAtomic.valdirectly; you must use.Load()to get a consistent view from memory.Store(newval): Safely writes a new value. Again, you can’t just assign to it.Add(delta): Atomically adds a delta to the value and returns the new value. This is the workhorse for counters.Swap(newval): Atomically swaps the current value with the new one and returns the old value. Useful for some kinds of updates.CompareAndSwap(old, new): This is the big one. CAS is the secret sauce of most non-trivial lock-free programming. It does: “If the current value equalsold, then swap it tonewand returntrue. Otherwise, do nothing and returnfalse.” It’s an atomic check-and-set.
The Almighty CompareAndSwap (CAS)
Let’s say you want to only update a value if it hasn’t changed since you last looked. This is impossible with just Load and Store. You’d have to do:
current := value.Load()newValue := ... some calculation based on current ...value.Store(newValue)
But what if another goroutine changed value between steps 1 and 3? You just overwrote their change! This is a classic race condition. CAS solves this.
var sharedValue atomic.Int64
// This function will only update the value if it is still 100.
func updateIf100(new int64) bool {
for { // This retry loop is a common pattern with CAS.
current := sharedValue.Load()
if current != 100 {
return false // Someone else changed it, give up.
}
if sharedValue.CompareAndSwap(current, new) {
return true // We did it!
}
// If we got here, the CAS failed. The value changed between
// our Load and our CAS. The loop will retry.
}
}
See that loop? This is called a CAS loop, and it’s the heart of many lock-free algorithms. You keep trying until you succeed or decide to bail. It’s “lock-free” because a failing goroutine doesn’t block others; it just retries.
atomic.Pointer[T]: The Gateway Drug
This one is incredibly powerful. It allows you to atomically swap entire data structures. The classic example is a read-heavy configuration map.
type Config struct {
Settings map[string]string
}
var config atomic.Pointer[Config]
// A goroutine that periodically updates the config
func updateConfig() {
for {
newConfig := loadConfigFromSomewhere() // Expensive operation
config.Store(&newConfig) // Atomic swap!
time.Sleep(10 * time.Second)
}
}
// Dozens of worker goroutines can read the config with no lock!
func worker() {
currentConfig := config.Load() // Gets a *Config
useConfig(currentConfig) // Safe, because the pointer won't change during use
}
Here’s the genius: all the reader goroutines are just doing a single atomic pointer load. They’re blindingly fast and completely wait-free. The writer does the expensive work outside the critical section, then performs a single, atomic pointer swap. Readers will either get the old config or the new one, never a half-baked one.
The Rough Edges and Pitfalls
- It’s for integers and pointers, not for everything. You can’t atomically swap a whole struct or a slice. For that, you use
atomic.Pointerto swap the entire struct, or you reach for a mutex. Don’t try to be clever. - Memory Ordering is a Thing. This is the deep end of the pool. Go’s memory model guarantees that the atomic operations themselves act as a barrier, so you don’t usually need to worry about the nightmarish memory reordering issues you see in C++. But be aware: a
Loadonly guarantees you see the value as of some point before the load. It doesn’t automatically make all other writes from other goroutines visible. For most counter-like patterns, this is fine. For complex invariants, you still need mutexes or explicit synchronization like channels. - Don’t Complicate What a Mutex Simplifies. The biggest pitfall is over-engineering. If you can solve your problem with a simple
sync.Mutexthat’s held for a few microseconds, do that. It’s simpler, safer, and probably fast enough. Reach foratomicwhen you’re building a low-level primitive, when you have a performance bottleneck proven by profiling, or when you need to avoid blocking (which is what “lock-free” is really about). Remember, a mutex is a brilliantly engineered, heavily optimized thing. Your clever CAS loop might not actually be faster.
So use these tools, but respect them. They’re not toys. They’re for when you absolutely need to squeeze out every last nanosecond or build a fundamental synchronization primitive yourself. And when you do, you’ll appreciate just how elegantly Go’s new atomic types let you do it.