Arshad Mehmood

Search the blog

    Sports cars on a racetrack, standing in for race conditions
    Photo credit: GT World Challenge America

    Development

    Avoiding Race Conditions in Go: A Practical Guide to sync/atomic

    10 min readDevelopmentgoconcurrencyrace-conditions+2

    As I continue my Go learning journey, I frequently encounter the power and challenges of concurrent programming. Goroutines make it incredibly easy to write parallel code, but they also introduce one of the most common and dangerous bugs in concurrent programming: race conditions.

    Recently, I ran into this exact issue while building a tool to manage cloud instances. What seemed like simple counting logic turned into a debugging nightmare! In this post, I’ll walk you through the same scenario and show you how Go’s sync/atomic package saved the day.

    Understanding the Problem

    Imagine you’re building a cloud management tool that needs to update multiple server instances concurrently. You want to count how many instances were successfully updated. Here’s what the initial implementation might look like:

    package main
    
    import (
        "fmt"
        "time"
        "golang.org/x/sync/errgroup"
    )
    
    // Simulate updating an instance (for demonstration)
    func updateInstance(instance string) error {
        // Simulate some work
        time.Sleep(10 * time.Millisecond)
        // Simulate occasional failures
        if instance == "instance-error" {
            return fmt.Errorf("failed to update %s", instance)
        }
        return nil
    }
    
    func updateInstances(instances []string) error {
        var wg errgroup.Group
        updatedCount := 0  // 🚨 This will cause problems!
        
        for _, instance := range instances {
            instance := instance // capture loop variable
            wg.Go(func() error {
                err := updateInstance(instance)
                if err == nil {
                    updatedCount++  // ❌ RACE CONDITION!
                }
                return err
            })
        }
        
        if err := wg.Wait(); err != nil {
            return err
        }
        
        fmt.Printf("Successfully updated %d instances\n", updatedCount)
        return nil
    }

    This code looks reasonable, but it contains a subtle and dangerous bug. Can you spot it?

    The “Aha!” Moment: Understanding Atomic Operations

    The sync/atomic package provides atomic operations - think of them like transactions at an ATM. When you withdraw money, either the entire operation completes (your balance updates AND you get cash), or nothing happens at all. There’s no in-between state where the money disappears!

    The Race Condition Explained

    In our example above, we have a classic race condition. Let’s break down what’s happening:

    The problematic code:

    var wg errgroup.Group
    updatedCount := 0  // Shared variable
    
    for _, instance := range instances {
        wg.Go(func() error {
            // Multiple goroutines running concurrently
            err := updateInstance(instance)
            if err == nil {
                updatedCount++  // ❌ RACE CONDITION!
            }
            return err
        })
    }

    Why This is Dangerous

    The problem lies in the innocent-looking line updatedCount++. This operation seems atomic, but it’s actually three separate operations at the CPU level:

    1. Read the current value of updatedCount
    2. Add 1 to that value
    3. Write the new value back to updatedCount

    The Race Condition in Action

    Here’s what can happen when multiple goroutines execute this code simultaneously:

    Timeline: Two goroutines (A and B) trying to increment updatedCount
    
    Initial value: updatedCount = 5
    
    Goroutine A: Read updatedCount (5)
    Goroutine B: Read updatedCount (5)    // Still 5! B doesn't see A's changes yet
    Goroutine A: Calculate 5 + 1 = 6
    Goroutine B: Calculate 5 + 1 = 6      // Also calculating from 5!
    Goroutine A: Write 6 back
    Goroutine B: Write 6 back             // Overwrites A's work!
    
    Final value: updatedCount = 6 (should be 7!)

    Result: We lost one increment! If you had 100 successful updates, you might see anywhere from 1 to 100 as the final count, depending on the timing of the race conditions.

    The Solution: Atomic Operations

    Now let’s fix our code using atomic operations:

    package main
    
    import (
        "fmt"
        "sync/atomic"
        "time"
        "golang.org/x/sync/errgroup"
    )
    
    // Simulate updating an instance (for demonstration)
    func updateInstance(instance string) error {
        // Simulate some work
        time.Sleep(10 * time.Millisecond)
        // Simulate occasional failures
        if instance == "instance-error" {
            return fmt.Errorf("failed to update %s", instance)
        }
        return nil
    }
    
    func updateInstancesSafely(instances []string) error {
        var wg errgroup.Group
        var updatedCount int64  // ✅ Must be int64 for atomic operations
    
        for _, instance := range instances {
            instance := instance // capture loop variable
            wg.Go(func() error {
                err := updateInstance(instance)
                if err == nil {
                    atomic.AddInt64(&updatedCount, 1)  // ✅ THREAD-SAFE!
                }
                return err
            })
        }
    
        if err := wg.Wait(); err != nil {
            return err
        }
    
        // Read the final value atomically
        finalCount := atomic.LoadInt64(&updatedCount)
        fmt.Printf("Successfully updated %d instances\n", finalCount)
        return nil
    }

    Key Changes

    1. Variable type: Changed from int to int64 (required for atomic operations)
    2. Increment operation: updatedCount++ → atomic.AddInt64(&updatedCount, 1)
    3. Reading the value: updatedCount → atomic.LoadInt64(&updatedCount)

    How Atomic Operations Work

    atomic.AddInt64(&updatedCount, 1)

    This function provides several guarantees:

    • Atomically adds 1 to the value at the specified memory address
    • Cannot be interrupted by other goroutines or CPU context switches
    • Hardware-level guarantee that the entire operation completes as a single unit
    • Returns the new value after the addition (though we don’t use it in our example)
    • Thread-safe across all CPU cores

    atomic.LoadInt64(&updatedCount)

    This function ensures safe reading:

    • Atomically reads the complete value from memory
    • Guarantees consistency - you’ll never see a partially updated value
    • Prevents compiler optimizations that might cache stale values
    • Provides memory synchronization with atomic writes

    Why Atomic Operations Prevent Race Conditions

    The magic of atomic operations lies in their indivisible nature:

    1. Single CPU instruction: atomic.AddInt64 translates to a single CPU instruction that cannot be interrupted
    2. Memory barriers: Atomic operations include memory synchronization, ensuring all CPU cores see consistent values
    3. No lost updates: Multiple goroutines can safely increment the same counter simultaneously
    4. Ordering guarantees: Atomic operations provide happens-before relationships for memory ordering

    Benchmarking the Performance

    I was curious about how fast these atomic operations really are, so I wrote a benchmark. The results blew my mind:

    package main
    
    import (
        "sync"
        "sync/atomic"
        "testing"
    )
    
    func BenchmarkAtomicCounter(b *testing.B) {
        var counter int64
        var wg sync.WaitGroup
        
        numGoroutines := 100
        incrementsPerGoroutine := b.N / numGoroutines
        
        for i := 0; i < numGoroutines; i++ {
            wg.Add(1)
            go func() {
                defer wg.Done()
                for j := 0; j < incrementsPerGoroutine; j++ {
                    atomic.AddInt64(&counter, 1)
                }
            }()
        }
        
        wg.Wait()
        expected := int64(numGoroutines * incrementsPerGoroutine)
        if atomic.LoadInt64(&counter) != expected {
            b.Errorf("Counter mismatch: got %d, want %d", 
                     atomic.LoadInt64(&counter), expected)
        }
    }

    On my machine, this benchmark shows atomic operations can handle millions of concurrent increments per second with perfect accuracy. That’s faster than I ever expected!

    Here are the actual results on my Apple M3 Pro:

    goos: darwin
    goarch: arm64
    pkg: test
    cpu: Apple M3 Pro
    BenchmarkAtomicCounter-12    	47625759	        27.27 ns/op	       0 B/op	       0 allocs/op
    PASS
    ok  	test	2.313s

    That’s 47.6 million operations in just over 2 seconds! Each atomic increment takes only 27 nanoseconds with zero memory allocations. To put this in perspective, that’s fast enough to handle the busiest web applications without breaking a sweat.

    Alternative Solutions Compared

    While atomic operations are perfect for our use case, it’s worth understanding the alternatives:

    1. Mutex (More General, Heavier)

    var mu sync.Mutex
    var updatedCount int
    
    // In each goroutine:
    if err == nil {
        mu.Lock()
        updatedCount++
        mu.Unlock()
    }

    Pros:

    • Works with any data type
    • Can protect complex operations
    • Familiar to developers from other languages

    Cons:

    • Higher overhead (OS-level synchronization)
    • Risk of deadlocks with multiple mutexes
    • Goroutines block waiting for the lock

    2. Channel (The “Go Way”)

    updateChan := make(chan struct{}, len(instances))
    
    // In each goroutine:
    if err == nil {
        updateChan <- struct{}{}
    }
    
    // After all goroutines complete:
    close(updateChan)
    updatedCount := 0
    for range updateChan {
        updatedCount++
    }

    Pros:

    • Idiomatic Go
    • Natural backpressure with buffered channels
    • Composable with select statements

    Cons:

    • More complex for simple counting
    • Memory overhead for the channel buffer
    • Requires careful channel management

    3. Return Values and Sum

    type result struct {
        success bool
        err     error
    }
    
    results := make([]result, len(instances))
    
    for i, instance := range instances {
        i, instance := i, instance
        wg.Go(func() error {
            err := updateInstance(instance)
            results[i] = result{success: err == nil, err: err}
            return err
        })
    }
    
    // Count successes
    updatedCount := 0
    for _, r := range results {
        if r.success {
            updatedCount++
        }
    }

    Pros:

    • No shared state, no synchronization needed
    • Easy to collect additional information
    • Functional programming style

    Cons:

    • Requires pre-allocated slice
    • Memory usage grows with number of operations
    • Less efficient for simple counting

    When to Choose Atomic Operations

    Choose sync/atomic when:

    • ✅ Simple numeric operations: Counters, flags, sums
    • ✅ High-performance requirements: Minimal overhead
    • ✅ Lock-free algorithms: No blocking behavior needed
    • ✅ Frequent updates: Many goroutines updating the same value

    Avoid sync/atomic when:

    • ❌ Complex data structures: Use mutexes instead
    • ❌ Multiple related operations: Atomic operations are for single values
    • ❌ Need transaction semantics: Use mutexes or channels for multi-step operations

    Best Practices for Atomic Operations

    1. Use the correct integer type: int32, int64, uint32, uint64, uintptr
    2. Be consistent: Always use atomic operations for a variable, never mix with regular operations
    3. Align memory: Use atomic.Value for complex types
    4. Document clearly: Make it obvious that a variable requires atomic access
    // Good: Clear documentation and consistent usage
    type ServiceMetrics struct {
        // requestCount must be accessed using atomic operations only
        requestCount int64
        
        // errorCount must be accessed using atomic operations only  
        errorCount   int64
    }
    
    func (m *ServiceMetrics) IncrementRequests() {
        atomic.AddInt64(&m.requestCount, 1)
    }
    
    func (m *ServiceMetrics) GetRequestCount() int64 {
        return atomic.LoadInt64(&m.requestCount)
    }

    Conclusion

    Race conditions are one of the most subtle and dangerous bugs in concurrent programming, but Go’s sync/atomic package provides an elegant solution for simple cases like counters and flags.

    Key takeaways:

    • Race conditions occur when multiple goroutines access shared data without proper synchronization
    • Atomic operations provide lock-free, thread-safe access to simple data types
    • Choose the right tool: Atomic for simple operations, mutexes for complex logic, channels for communication
    • Performance matters: Atomic operations are typically the fastest synchronization mechanism
    • Always be consistent: Once you choose atomic operations for a variable, use them everywhere

    The next time you’re building concurrent Go applications, remember that a simple counter can hide complex race conditions. But with atomic operations, you can count on accurate results! 🏁

    Further Reading


    What’s your experience with race conditions in Go? Have you encountered subtle bugs that atomic operations helped solve? Share your stories in the comments below!

    Discussion

    Comments are not enabled on this build. Production uses Giscus (GitHub Discussions) when the repository secrets are set.