Atomic Operations in Go: From Source Code to CPU Instructions
Overview
The atomic operations in Go are available via sync/atomic package
which provide synchronization for simple shared values between goroutines. Atomic operation only
guarantees that only given operation happens atomically, so sequence of operations, e.g. atomic
increment and ordinary read is not automatically one indivisible operation.
The Go API
Calling atomic.AddInt64(&counter, 1) atomically increments a value shared by goroutines:
package main
import "sync/atomic"
var counter int64
func increment() {
atomic.AddInt64(&counter, 1)
}
// or
var new_counter atomic.Int64
func new_increment() {
new_counter.Add(1)
}The package provides:
- Load/Store:
atomic.LoadInt64(),atomic.StoreInt64() - Add:
atomic.AddInt64(),atomic.AddUint32() - Swap:
atomic.SwapInt64() - CompareAndSwap (CAS):
atomic.CompareAndSwapInt64()- conditionally update if value matches - Typed values:
atomic.Int64,atomic.Uint64,atomic.Bool,atomic.Pointer[T], and others - Atomic interface values:
atomic.Value
Memory Ordering in Go
Go's atomic operations are sequentially consistent. Each atomic operation is indivisible, and
all goroutines agree on the order in which atomic operations occur. For example, one goroutine can
write some data and then publish a ready flag:
var data int
var ready atomic.Bool
// Writer
go func() {
data = 42
ready.Store(true)
}()
// Reader
go func() {
for !ready.Load() {
}
println(data) // guaranteed to see data = 42
}()The writer stores 42 in data and then performs an atomic store to ready. The reader waits
until its atomic load observes ready == true. At that point, the reader is guaranteed to see the
earlier write to data.
Atomic operations are not automatically atomic sequences
Atomicity applies to each individual operation, not to a sequence of operations. E.g. if both
Load() and Add() are atomic it doesn't mean whole operation is as well. Two goroutines can both
read 9 before either one increments the counter:
var counter atomic.Int64
func incrementIfBelowLimit() {
if counter.Load() < 10 {
counter.Add(1)
}
}
// Goroutine 1: Load() → 9
// Goroutine 2: Load() → 9
// Goroutine 1: Add(1) → 10
// Goroutine 2: Add(1) → 11The individual operations are safe, but the check-then-increment sequence is not atomic. If the condition and update must happen as one indivisible operation, different synchronization strategy must be used, such as CAS or a mutex.
Go Runtime Layer
The Go compiler recognizes atomic operations and lowers them to CPU-specific atomic instructions or runtime implementations, depending on the target architecture and operation. Different architectures have different instruction sets:
- amd64:
LOCK XADD,LOCK CMPXCHG,XCHG - ARM64:
LDAXR/STLXRretry loops, or LSE instructions such asLDADD/CASwhen supported
Go's runtime provides a portable abstraction so the same Go code works regardless of the underlying architecture.
Real example: atomic.AddInt64 on amd64
atomic.AddInt64(&counter, 1) is specially recognized by the compiler, so on amd64 it can avoid a
function call and emit a LOCK XADDQ directly:
MOVL $1, AX ; delta = 1
LEAQ counter, CX ; CX = &counter
LOCK
XADDQ AX, (CX) ; atomically: AX = old value; counter += deltaThe runtime also provides its own implementation in internal/runtime/atomic/atomic_amd64.s,
Xadd64. This is the implementation used when the compiler does not lower the operation directly:
TEXT ·Xadd64(SB), NOSPLIT, $0-24
MOVQ ptr+0(FP), BX ; BX = &counter
MOVQ delta+8(FP), AX ; AX = delta
MOVQ AX, CX ; save a copy of delta
LOCK
XADDQ AX, 0(BX) ; atomically: AX = old *ptr; *ptr += delta
ADDQ CX, AX ; AX = old + delta = new value
MOVQ AX, ret+16(FP) ; return the new value
RETXADDQ performs an atomic read-modify-write: it adds delta to the counter and puts the old
value in AX. LOCK makes the memory read-modify-write operation atomic with respect to other
processors/cores. Another core cannot observe or perform a conflicting modification in the middle of
the XADD operation. ADDQ then uses the saved delta to calculate the new value returned by
atomic.AddInt64.
The runtime keeps a copy of delta in CX because XADDQ overwrites AX with the old counter
value, so registers contain:
AX = delta, BX = &counter, CX = deltaA few useful details when reading the function:
MOVQ src, dstmoves a 64-bit value (Quad Word).LOCKis an x86 prefix that makes the following memory read-modify-write atomic.XADDQ reg, memadds the original register value to memory and puts the old memory value into the register.ADDQ src, dstperforms a normal 64-bit addition.RETreturns from the function.FPis Go assembler's frame-pointer pseudo-register;ptr+0(FP),delta+8(FP), andret+16(FP)refer to the function's arguments and result.SBis Go assembler's static-base pseudo-register used when referring to symbols; it is not a real CPU register.NOSPLITdisables the stack-split check at function entry. This is typical for low-level runtime code.
The $0-24 in the TEXT declaration means a 0-byte local stack frame and a 24-byte argument/result
area: 8 bytes for ptr, 8 for delta, and 8 for the return value.
Go assembler syntax and directives - Official Go assembler guide.
When the compiler lowers the atomic operation directly, it emits LOCK XADDQ instead of generating
a call to Xadd64. The runtime implementation still exists as the low-level implementation when a
direct lowering is not used.
Atomics vs Mutexes?
Atomics can win when the shared state is small and the invariant fits one operation: a counter,
flag, pointer, or state transition. sync.Mutex when several fields must change together or when
the critical section is easier to express as ordinary code:
type Stats struct {
mu sync.Mutex
requestCount int64
lastRequest time.Time
}
func (s *Stats) RecordRequest() {
s.mu.Lock()
defer s.mu.Unlock()
s.requestCount++
s.lastRequest = time.Now()
}A mutex uses atomic operations internally, but it also integrates with the Go scheduler: a waiting goroutine can be parked instead of repeatedly retrying. Under contention, an atomic retry loop can spend more time fighting over one cache line than a mutex spends managing the waiters.
Glossary
Compiler lowering
Compiler lowering is the process of translating a higher-level, abstract code representation into a simpler, more concrete low-level representation.
Pseudo-register
A pseudo-register is a register-like name provided by an assembler, debugger, or other low-level
tool. It represents an abstract value or addressing context and does not necessarily correspond to a
physical CPU register. In Go assembler, FP refers to the current function's frame and SB refers
to the static symbol base.