Aprelius logo
uptime: 00:00:00
Go

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:

go
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:

go
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:

go
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)  → 11

The 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/STLXR retry loops, or LSE instructions such as LDADD/CAS when 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:

asm
MOVL $1, AX        ; delta = 1
LEAQ counter, CX   ; CX = &counter
LOCK
XADDQ AX, (CX)     ; atomically: AX = old value; counter += delta

The 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:

asm
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
    RET

XADDQ 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.

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:

go
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.

References