Understanding Atomic Operations in Go with sync/atomic
Ethan Miller
Product Engineer · Leapcell

Understanding Atomic Operations in Go with sync/atomic
In concurrent programming, managing shared state correctly is paramount to avoid data races and ensure predictable behavior. Go provides several mechanisms for concurrency control, including mutexes, channels, and wait groups. While mutexes (like sync.Mutex
) offer a robust way to protect critical sections, they can sometimes introduce overhead due to locking and unlocking, especially for simple operations like incrementing a counter. This is where atomic operations come into play.
Go's sync/atomic
package provides low-level, primitive operations that perform common tasks like adding, comparing-and-swapping, or loading values in a thread-safe manner without explicit locking. These operations are typically implemented using special CPU instructions that guarantee atomicity, meaning they complete in a single, indivisible step, even in a multi-core environment. This makes them highly efficient for specific use cases.
Why Atomic Operations?
Consider a scenario where multiple goroutines need to increment a shared counter. A naive approach might look like this:
package main import ( "fmt" "runtime" "sync" "time" ) func main() { counter := 0 numGoroutines := 1000 var wg sync.WaitGroup wg.Add(numGoroutines) for i := 0; i < numGoroutines; i++ { go func() { defer wg.Done() for j := 0; j < 1000; j++ { counter++ // Data race! } }() } wg.Wait() fmt.Println("Final Counter (potential race):", counter) }
Running this code multiple times might yield different results, and the final counter
value will likely be less than 1,000,000
. This is because counter++
is not atomic; it involves three steps: read, increment, and write. A context switch can happen between these steps, leading to lost updates.
One way to fix this is using a sync.Mutex
:
package main import ( "fmt" "sync" ) func main() { counter := 0 numGoroutines := 1000 var wg sync.WaitGroup var mu sync.Mutex // Mutex to protect the counter wg.Add(numGoroutines) for i := 0; i < numGoroutines; i++ { go func() { defer wg.Done() for j := 0; j < 1000; j++ { mu.Lock() counter++ mu.Unlock() } }() } wg.Wait() fmt.Println("Final Counter (with mutex):", counter) // Should be 1,000,000 }
While correct, acquiring and releasing a mutex for every tiny increment can introduce unnecessary overhead. For simple arithmetic operations or value swapping, sync/atomic
provides a more performant alternative.
Core Atomic Operations
The sync/atomic
package offers atomic operations for various integer types (int32
, int64
, uint32
, uint64
), pointers (unsafe.Pointer
), and boolean values (implicitly handled by integers). Here are some of the most commonly used functions:
1. Add*
Functions
These functions atomically add a delta to a value and return the new value.
atomic.AddInt32(addr *int32, delta int32) (new int32)
atomic.AddInt64(addr *int64, delta int64) (new int64)
atomic.AddUint32(addr *uint32, delta uint32) (new uint32)
atomic.AddUint64(addr *uint64, delta uint64) (new uint64)
Let's refactor our counter example using atomic.AddInt64
:
package main import ( "fmt" "sync" "sync/atomic" // Import the atomic package ) func main() { var counter int64 // Use int64 for atomic operations numGoroutines := 1000 var wg sync.WaitGroup wg.Add(numGoroutines) for i := 0; i < numGoroutines; i++ { go func() { defer wg.Done() for j := 0; j < 1000; j++ { atomic.AddInt64(&counter, 1) // Atomically add 1 } }() } wg.Wait() fmt.Println("Final Counter (with atomic):", counter) // Should be 1,000,000 }
This version is not only correct but also generally more efficient than the mutex-based approach for simple increments, especially under high contention, because it avoids the overhead of mutex management and relies on bare-metal CPU instructions.
2. Load*
Functions
These functions atomically load (read) the value stored at an address.
atomic.LoadInt32(addr *int32) (val int32)
atomic.LoadInt64(addr *int64) (val int64)
atomic.LoadUint32(addr *uint32) (val uint32)
atomic.LoadUint64(addr *uint64) (val uint64)
atomic.LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
It's crucial to always use atomic loads when reading a value that might be written to atomically by another goroutine. This ensures you get the most up-to-date, consistent value.
Example: Reading the atomic counter:
package main import ( "fmt" "sync" "sync/atomic" "time" ) func main() { var counter int64 stop := make(chan struct{}) go func() { for { select { case <-stop: return default: atomic.AddInt64(&counter, 1) // Increment counter time.Sleep(time.Millisecond) // Simulate some work } } }() time.Sleep(5 * time.Second) // Let it run for 5 seconds // Atomically load the current value of the counter currentValue := atomic.LoadInt64(&counter) fmt.Println("Current counter value:", currentValue) close(stop) time.Sleep(100 * time.Millisecond) // Give the goroutine time to stop fmt.Println("Final counter value:", atomic.LoadInt64(&counter)) }
3. Store*
Functions
These functions atomically store (write) a new value to an address.
atomic.StoreInt32(addr *int32, val int32)
atomic.StoreInt64(addr *int64, val int64)
atomic.StoreUint32(addr *uint32, val uint32)
atomic.StoreUint64(addr *uint64, val uint64)
atomic.StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
Example: Storing a new state value:
package main import ( "fmt" "sync" "sync/atomic" "time" ) const ( StateRunning = 0 StatePaused = 1 StateStopped = 2 ) func main() { var currentState int32 = StateRunning // Initial state var wg sync.WaitGroup wg.Add(2) // Goroutine to change state go func() { defer wg.Done() fmt.Println("Service: Changing state to Paused...") atomic.StoreInt32(¤tState, StatePaused) // Atomically set state time.Sleep(time.Second) fmt.Println("Service: Changing state to Stopped...") atomic.StoreInt32(¤tState, StateStopped) }() // Goroutine to monitor state go func() { defer wg.Done() for i := 0; i < 5; i++ { // Atomically load the current state val := atomic.LoadInt32(¤tState) fmt.Printf("Monitor: Current state is %d\n", val) time.Sleep(500 * time.Millisecond) if val == StateStopped { break } } }() wg.Wait() fmt.Println("All done.") }
4. Swap*
Functions
These functions atomically swap the value at an address with a new value and return the old value.
atomic.SwapInt32(addr *int32, new int32) (old int32)
atomic.SwapInt64(addr *int64, new int64) (old int64)
atomic.SwapUint32(addr *uint32, new uint32) (old uint32)
atomic.SwapUint64(addr *uint64, new uint64) (old uint64)
atomic.SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
Swap
is useful for scenarios where you need to replace a value and get the previous one simultaneously, for instance, in implementing a lock-free queue or clearing a flag.
Example: Resetting a flag and checking its previous state:
package main import ( "fmt" "sync/atomic" "time" ) func main() { var isProcessing int32 = 0 // 0 for false, 1 for true // Simulate a worker trying to acquire a "lock" or signal it's processing go func() { for i := 0; i < 3; i++ { // Try to set isProcessing to 1 (true) and get the old value // If old value was 0, it means we successfully acquired the "lock" oldVal := atomic.SwapInt32(&isProcessing, 1) if oldVal == 0 { fmt.Printf("Worker %d: Acquired processing lock. Doing work...\n", i+1) time.Sleep(time.Second) // Simulate work atomic.StoreInt32(&isProcessing, 0) // Release the lock fmt.Printf("Worker %d: Released processing lock.\n", i+1) } else { fmt.Printf("Worker %d: Could not acquire lock, already busy.\n", i+1) time.Sleep(200 * time.Millisecond) // Wait a bit before retrying } } }() time.Sleep(3 * time.Second) // Keep main goroutine alive fmt.Println("Final processing state:", atomic.LoadInt32(&isProcessing)) }
5. CompareAndSwap*
(CAS) Functions
These are arguably the most powerful atomic operations. They conditionally change the value at an address only if its current value matches an expected value.
atomic.CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
atomic.CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
atomic.CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
atomic.CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
atomic.CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
CAS operations are fundamental building blocks for many lock-free algorithms, including non-blocking queues, stacks, and thread-safe data structures. The general pattern is a "read-modify-write" loop:
for { oldVal := atomic.Load*(addr) // 1. Read current value newVal := calculateNewValue(oldVal) // 2. Compute new value based on old if atomic.CompareAndSwap*(addr, oldVal, newVal) { // 3. Try to swap break // Success! } // else, another goroutine changed the value, retry }
Example: Implementing a thread-safe maximum value updater:
package main import ( "fmt" "sync" "sync/atomic" ) func main() { var maxVal int64 = 0 // Initial max value numGoroutines := 10 var wg sync.WaitGroup wg.Add(numGoroutines) for i := 0; i < numGoroutines; i++ { go func(id int) { defer wg.Done() for j := 0; j < 100; j++ { newValue := int64(id*100 + j) // Generate some increasing values for { oldVal := atomic.LoadInt64(&maxVal) // Read current max if newValue > oldVal { // Try to set new value ONLY IF current max is still oldVal if atomic.CompareAndSwapInt64(&maxVal, oldVal, newValue) { // Successfully updated // fmt.Printf("Goroutine %d: Updated max from %d to %d\n", id, oldVal, newValue) break } // If CAS failed, another goroutine updated it. Loop and retry with new oldVal. } else { // Our new value is not greater, no need to update break } } } }(i) } wg.Wait() fmt.Println("Final Max Value:", atomic.LoadInt64(&maxVal)) // Should be 999 }
This ensures that maxVal
is only updated if the new value is actually greater than the value observed at the time of the LoadInt64
call, and the CompareAndSwapInt64
operation guarantees that this check and update happen atomically.
6. atomic.Pointer[T]
(Go 1.19+)
Go 1.19 introduced atomic.Pointer[T]
, a generic type that provides atomic operations for values of any type T
(via unsafe.Pointer
internally), abstracting away the explicit unsafe.Pointer
casts. This significantly improves type safety and usability when atomically managing pointers.
Its methods mirror the global atomic
functions: Load()
, Store()
, Swap()
, and CompareAndSwap()
.
package main import ( "fmt" "sync" "sync/atomic" "time" ) type Config struct { LogLevel string MaxConns int Timeout time.Duration } func main() { // Initialize an atomic.Pointer with an initial config var currentConfig atomic.Pointer[Config] currentConfig.Store(&Config{ LogLevel: "INFO", MaxConns: 10, Timeout: 5 * time.Second, }) var wg sync.WaitGroup wg.Add(2) // Goroutine to update config go func() { defer wg.Done() time.Sleep(2 * time.Second) fmt.Println("Updater: Updating config...") newConfig := &Config{ LogLevel: "DEBUG", MaxConns: 20, Timeout: 10 * time.Second, } currentConfig.Store(newConfig) // Atomically store new config fmt.Println("Updater: Config updated.") time.Sleep(2 * time.Second) newerConfig := &Config{ LogLevel: "ERROR", MaxConns: 5, Timeout: 2 * time.Second, } // Example of CompareAndSwap for pointers oldConfig := currentConfig.Load() if currentConfig.CompareAndSwap(oldConfig, newerConfig) { fmt.Printf("Updater: Successfully CASed config from %s to %s\n", oldConfig.LogLevel, newerConfig.LogLevel) } else { fmt.Println("Updater: CAS failed, config changed by someone else.") } }() // Goroutine to read config go func() { defer wg.Done() for i := 0; i < 5; i++ { cfg := currentConfig.Load() // Atomically load config fmt.Printf("Reader: Current config - LogLevel: %s, MaxConns: %d\n", cfg.LogLevel, cfg.MaxConns) time.Sleep(1 * time.Second) } }() wg.Wait() fmt.Println("Final Config:", currentConfig.Load()) }
atomic.Pointer[T]
is useful for scenarios like hot-reloading configurations, implementing copy-on-write data structures, or safely swapping complex objects between goroutines without needing a mutex.
When to Use sync/atomic
vs. sync.Mutex
Choosing between atomic operations and mutexes depends on the complexity of the shared state and the operations performed:
-
Use
sync/atomic
when:- You need to perform simple read, write, add, swap, or compare-and-swap operations on primitive integer types or pointers.
- Performance is critical, and the operations are truly atomic at the hardware level.
- You want to avoid the overhead of mutex locking/unlocking.
- You're implementing lock-free data structures (though this is advanced and error-prone).
-
Use
sync.Mutex
(orsync.RWMutex
) when:- The shared state is a complex data structure (e.g., maps, slices, structs) where multiple fields might be modified in a related way, or where operations involve multiple discrete reads/writes that need to be grouped as a single logical unit.
- The operations are more complex than simple arithmetic or assignments (e.g., appending to a slice, deleting from a map).
- You need to protect an entire critical section, not just a single value.
- Simplicity and correctness are prioritized over micro-optimizations. Mutexes are generally easier to reason about and less error-prone for complex scenarios.
It's also important to remember that sync/atomic
operations only guarantee atomicity for that single operation. If you have multiple atomic operations that must happen together, you might still need a mutex or a more sophisticated concurrency primitive to ensure their collective atomicity.
Alignment Requirements
For sync/atomic
functions involving 64-bit values (int64
, uint64
), the memory address of the variable must be 64-bit aligned. Go's garbage collector generally ensures that variables with a size of 8 bytes or more are properly aligned. However, if you are embedding 64-bit values within structs, you should be mindful of the struct's layout and ensure the atomic variable is at the beginning or is explicitly padded to maintain alignment. Otherwise, panic: atomic: store of unaligned 64-bit value
can occur.
The Go Memory Model (which is related to sync/atomic
) also provides guarantees for operations like Load
and Store
: if a goroutine writes value v
to a variable x
using atomic.Store*
, and another goroutine subsequently reads x
using atomic.Load*
, the read operation will observe v
or a value written after v
. This ensures visibility and ordering properties critical for correctness.
Conclusion
The sync/atomic
package is a powerful tool in Go's concurrency toolbox, offering highly efficient, low-level primitives for managing shared state. By leveraging CPU-level atomic instructions, it allows for fine-grained control and can significantly improve performance for simple, contention-heavy operations compared to mutexes. However, its effectiveness is limited to basic types and operations. For more complex concurrent access patterns or data structures, sync.Mutex
and channels remain Go's primary, more general-purpose concurrency mechanisms. Understanding when and how to appropriately use sync/atomic
is key to writing robust, high-performance concurrent Go applications.