Skip to main content
Bits & Bytes

Understanding Atomic Operations in Go

Atomic operations in Go provide a lightweight way to handle concurrent access to simple variables without the overhead of mutexes or the complexity of channels. They also perform significantly better in certain scenarios. While they're not a silver bullet for all concurrency needs, atomic operations excel in specific scenarios where you need fast, simple synchronization.

In this article, we’ll look at: what atomic operations are, how they’re implemented at the instruction set level, when to use them and when not to, as well as some common patterns for using them in code. By the end of reading you should understand when to reach for this valuable concurrency tool instead of better known tools like the sync package, goroutines, and channels.

What Are Atomic Operations?

At their core, atomic operations are indivisible operations that complete in a single step from the perspective of other threads. When you perform an atomic operation, no other goroutine can see the operation partially complete. This guarantee is crucial for maintaining data consistency in concurrent programs.

The sync/atomic Package

Go's sync/atomic package provides atomic operations for primitive types:

The most commonly used operations include:

When to Use Atomic Operations

Use Atomics When:

  1. You need to modify a single numerical value concurrently

    var counter uint64
    atomic.AddUint64(&counter, 1)// Atomic increment
  2. You want to implement a flag or signal between goroutines

    var initialized uint32
    if atomic.CompareAndSwapUint32(&initialized, 0, 1) {
    // First goroutine to set the flag
        performOneTimeInitialization()
    }
  3. You need maximum performance for simple operations

    var totalBytes uint64
    atomic.AddUint64(&totalBytes, uint64(len(data)))// Track bytes processed

Don't Use Atomics When:

  1. You need to protect complex data structures

    // Use a mutex instead for structs
    type UserStats struct {
        visits    int
        lastVisit time.Time
    }
  2. You need to perform multiple related operations atomically

    // Use a mutex for multiple operations
    mu.Lock()
    balance -= withdrawal
    transactions = append(transactions, withdrawal)
    mu.Unlock()
  3. You need to coordinate complex goroutine communication

    // Use channels for complex communication patterns
    done := make(chan struct{})
    go worker(done)

Behind the Scenes

Atomic operations are implemented using CPU instructions that guarantee atomicity. These instructions typically:

  1. Lock the memory bus or use cache coherency protocols
  2. Prevent other cores from accessing the same memory location
  3. Ensure the operation completes without interruption

For example, on x86 processors, atomic operations often use instructions like:

On arm64 processors they’ll use instructions like:

Using the sync/atomic package in Go will result in running the best atomic instructions on the architecture that you build for.

Benefits of Atomic Operations

  1. Performance: Atomic operations are significantly faster than mutexes because they:

    • Don't require context switches
    • Use CPU-level instructions
    • Avoid the overhead of lock acquisition and release
  2. Simplicity: For single variables, atomic operations are more straightforward than managing mutexes:

    // Atomic counter
    atomic.AddInt64(&counter, 1)
    
    // vs mutex-based counter
    mu.Lock()
    counter++
    mu.Unlock()
  3. Lock-Free Programming: Atomic operations enable lock-free algorithms that can:

    • Avoid deadlocks
    • Provide better scalability
    • Maintain progress even if goroutines are suspended

Practical Example: Lock-Free Counter

Here's a complete example showing how to implement a thread-safe counter using atomics:

type AtomicCounter struct {
    value uint64
}

func (c *AtomicCounter) Increment() uint64 {
    return atomic.AddUint64(&c.value, 1)
}

func (c *AtomicCounter) Get() uint64 {
    return atomic.LoadUint64(&c.value)
}

func (c *AtomicCounter) Reset() {
    atomic.StoreUint64(&c.value, 0)
}

Common Patterns with CAS

Compare-and-swap (CAS) operations are particularly useful for implementing lock-free algorithms. Here's a pattern for retrying operations until they succeed:

func UpdateIfEqual(addr *uint64, old, new uint64) bool {
    for {
        if atomic.CompareAndSwapUint64(addr, old, new) {
            return true
        }
        old = atomic.LoadUint64(addr)
        if old >= new {
            return false
        }
    }
}

Best Practices

  1. Use The Right Tool: Choose atomics only when they're the simplest solution that meets your needs.
  2. Avoid Premature Optimization: Start with mutexes or channels, and only switch to atomics if profiling shows a need.
  3. Document Usage: Always document why you're using atomics instead of other synchronization primitives.
  4. Consider Memory Ordering: Be aware that atomic operations have memory ordering implications that can affect program behavior.

Conclusion

Atomic operations in Go are a powerful tool for concurrent programming that should be used selectively. They shine in scenarios that need simple, high-performance synchronization of individual values. For complex operations or data structures, however, traditional synchronization tools like mutexes and channels are more appropriate.

Mastering when to use atomics versus other concurrency primitives is essential for writing efficient, maintainable Go code. Start with the simplest solution that solves your problem correctly, and only use atomics when their benefits outweigh their added complexity.