Article illustration 1

When multiple Goroutines increment a shared counter in Go, developers often encounter surprising results. As this code snippet demonstrates, five Goroutines each incrementing a variable 10,000 times rarely produces the expected 50,000:

total := 0
var wg sync.WaitGroup
for range 5 {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for range 10000 { total++ }
    }()
}
wg.Wait()
fmt.Println(total) // Outputs values like 40478, 26775

Go's race detector flags this as a data race—but why? The culprit lies in the non-atomic nature of total++. This seemingly simple operation decomposes into three distinct steps:
1. Read current value
2. Increment locally
3. Write back result

When Goroutines interleave these steps, overlapping reads cause lost updates. Two Goroutines reading 42 simultaneously will both write 43, losing one increment.

The Atomic Toolkit: Beyond Mutexes

Go's sync/atomic package provides lock-free primitives that resolve these issues through CPU-level guarantees. Key types include:
- atomic.Int32, Int64, Uint32, Uint64
- atomic.Bool
- atomic.Pointer[T]
- atomic.Value (for arbitrary types)

These expose methods that compile to single-instruction operations:

var counter atomic.Int32
counter.Store(10)          // Set value
val := counter.Load()      // Read value
old := counter.Add(5)      // Add and return previous
swapped := counter.CompareAndSwap(15, 20) // Update if current matches

Rewriting our counter with atomics fixes the data race:

var total atomic.Int32
for range 5 {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for range 10000 { total.Add(1) }
    }()
}
wg.Wait()
total.Load() // Always 50000

The Composition Trap: When Atomics Aren't Enough

Critical insight: While individual atomic operations are thread-safe, their composition isn't automatically atomic. Consider this flawed state machine:

var mode atomic.Int32

func update() {
    current := mode.Load()
    if current == 0 {
        mode.Store(1)
    } else {
        mode.Store(2)
    }
}

Between Load() and Store(), another Goroutine may change the state. The solution? CompareAndSwap:

func update() {
    for {
        current := mode.Load()
        var next int32
        if current == 0 { next = 1 } else { next = 2 }
        if mode.CompareAndSwap(current, next) {
            break
        }
    }
}

Atomic vs. Mutex: Choosing Your Weapon

Atomics shine for:
1. Simple counters and flags
2. Early-exit patterns where mutexes would force unnecessary waits

Example: A Gate type that must close exactly once:

type Gate struct {
    closed atomic.Bool
}

func (g *Gate) Close() {
    if g.closed.CompareAndSwap(false, true) {
        // Release resources - guaranteed once
    }
}

For complex multi-operation transactions, mutexes remain preferable. Atomics optimize granular operations but cannot replace mutexes for coordinating dependent state changes.

The Delicate Balance

Atomic operations offer blazing performance for specific use cases but demand rigorous understanding of their limitations. They eliminate lock contention at the cost of increased cognitive overhead—especially when composing operations. As Go's concurrency model evolves, these primitives empower developers to build highly performant systems, provided we respect their constraints and verify behavior through tools like the race detector. When used precisely, atomics unlock levels of efficiency that mutexes alone cannot achieve.