Go’s sync/atomic package allows for low-level, hardware-supported atomic operations, which are often a more performant alternative to traditional mutexes for simple state updates in concurrent programs.

Let’s see this in action. Imagine we have a simple counter that multiple goroutines need to increment concurrently.

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

func main() {
	var counter int64 // Use int64 for atomic operations
	var wg sync.WaitGroup
	numGoroutines := 1000

	wg.Add(numGoroutines)

	for i := 0; i < numGoroutines; i++ {
		go func() {
			defer wg.Done()
			// Using atomic operation to increment
			atomic.AddInt64(&counter, 1)
		}()
	}

	wg.Wait()
	fmt.Printf("Final counter value (atomic): %d\n", counter)

	// Now, let's do the same with a mutex for comparison
	var mutexCounter int64
	var mutex sync.Mutex

	wg.Add(numGoroutines)
	for i := 0; i < numGoroutines; i++ {
		go func() {
			defer wg.Done()
			// Using a mutex to protect the increment
			mutex.Lock()
			mutexCounter++
			mutex.Unlock()
		}()
	}
	wg.Wait()
	fmt.Printf("Final counter value (mutex): %d\n", mutexCounter)
}

When you run this, you’ll consistently see the atomic version produce the correct result (1000), while the mutex version might sometimes produce a slightly lower number due to race conditions if not carefully implemented (though in this simple increment, it’s usually correct, but the potential for error is there). The atomic operation guarantees that the read-modify-write cycle for the counter happens as a single, indivisible unit, preventing other goroutines from interfering mid-operation.

The core problem atomic operations solve is the "read-modify-write" race condition. When multiple goroutines try to update a shared variable, they might all read the same initial value, independently perform their modification, and then write back their result. This can lead to lost updates. For example, if two goroutines both read 0, both increment it to 1, and both write 1 back, the counter should be 2, but it ends up as 1. Atomic operations, by leveraging special CPU instructions, ensure that this entire sequence appears to happen instantaneously from the perspective of other goroutines.

The sync/atomic package provides functions like AddInt64, LoadInt64, StoreInt64, CompareAndSwapInt64, etc., for various integer types. The key is that these operations don’t involve blocking or context switching like mutexes do. They operate directly on memory locations using hardware primitives. This makes them significantly faster for simple, single-variable updates, as they avoid the overhead of acquiring and releasing locks, managing waiting goroutines, and potential scheduler contention.

The CompareAndSwap (CAS) operations are particularly powerful. atomic.CompareAndSwapInt64(&value, old, new) atomically checks if value is currently old. If it is, it sets value to new and returns true. Otherwise, it does nothing and returns false. This is the building block for many lock-free data structures. For instance, to implement a simple linked list where nodes are added atomically, you might use CAS to update the head pointer: try to swap the current head with a new node, and if another goroutine modified the head in the meantime, retry the operation with the new current head.

Mutexes, on the other hand, are a higher-level synchronization primitive. They provide mutual exclusion: only one goroutine can hold the lock at a time. This is excellent for protecting larger critical sections of code where multiple operations need to be performed atomically as a group, or when the operations are complex and cannot be easily broken down into atomic memory operations. A mutex works by having a state that goroutines try to acquire. If the mutex is already locked, the requesting goroutine is put to sleep by the operating system scheduler until the mutex is released. This sleeping and waking up, managed by the Go runtime, incurs overhead.

The primary lever you control with atomic operations is the type of operation and the variable it operates on. You can’t atomically increment a struct or perform a complex calculation. You’re limited to simple, single-value modifications. The atomic functions take a pointer to the variable you want to modify, ensuring they operate on the correct memory location.

When you use atomic.AddInt64(&counter, 1), the CPU instruction for adding a value to a memory address is executed. This instruction is designed to be atomic. It means that while this instruction is running, no other CPU core can access that memory location, or if they do, the operation will appear to happen instantaneously. This is fundamentally different from mutexCounter++. That operation involves three distinct steps: LOAD (read mutexCounter into a register), ADD (increment the register), STORE (write the register’s value back to mutexCounter). If another goroutine’s LOAD happens between your LOAD and STORE, you lose an update.

The most surprising thing about atomic operations is that they are often not implemented by a single, magical CPU instruction that does "add and store atomically." Instead, many atomic operations, especially CAS loops, are built using weaker memory ordering primitives combined with retry logic. The CPU provides instructions that guarantee atomicity for simple operations (like XCHG or LOCK ADD on x86), but the Go runtime might also implement more complex atomic operations using techniques that ensure correctness even if the underlying hardware doesn’t offer a single instruction for the exact operation. The key is that the Go runtime guarantees the effect of atomicity, regardless of the precise CPU instructions used.

The next concept to explore is how to use atomic operations for more complex scenarios, like building lock-free queues or maps, and the trade-offs involved in those implementations.

Want structured learning?

Take the full Golang course →