Atomic operations com sync/atomic

1. Introdução às Operações Atômicas em Go

Operações atômicas são instruções de máquina que executam uma operação de leitura-modificação-escrita de forma indivisível. Em um ambiente concorrente, múltiplas goroutines podem acessar a mesma variável simultaneamente, resultando em race conditions. As operações atômicas garantem que nenhuma outra goroutine observe a operação pela metade.

A principal diferença entre operações atômicas e locks (como sync.Mutex) está no nível de granularidade e overhead. Locks suspendem a execução de goroutines quando há contenção, enquanto operações atômicas são implementadas diretamente no nível de hardware usando instruções como LOCK CMPXCHG (x86) ou LDREX/STREX (ARM). Isso torna as operações atômicas significativamente mais leves para operações simples.

O pacote sync/atomic fornece operações atômicas de baixo nível para tipos primitivos (int32, int64, uint32, uint64, uintptr, unsafe.Pointer) e, a partir do Go 1.19, também oferece atomic.Int32, atomic.Int64, atomic.Uint64 e outros tipos convenientes.

2. Operações Básicas: Load, Store e Swap

As operações mais fundamentais são Load (leitura atômica) e Store (escrita atômica). Sem elas, leituras e escritas concorrentes podem resultar em dados corrompidos.

package main

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

type RequestCounter struct {
    count atomic.Int64
}

func (rc *RequestCounter) Increment() {
    rc.count.Add(1)
}

func (rc *RequestCounter) Value() int64 {
    return rc.count.Load()
}

func main() {
    counter := &RequestCounter{}

    for i := 0; i < 100; i++ {
        go func() {
            for j := 0; j < 1000; j++ {
                counter.Increment()
            }
        }()
    }

    time.Sleep(time.Second)
    fmt.Printf("Total requests: %d\n", counter.Value())
}

A operação Swap permite trocar um valor atômico e obter o valor anterior:

var value atomic.Int64
old := value.Swap(42) // Troca o valor atual por 42 e retorna o anterior

3. Operações de Comparação e Troca (CAS - Compare And Swap)

CAS é a operação atômica mais versátil. Ela compara o valor atual com um esperado e, se forem iguais, substitui por um novo valor. Tudo isso ocorre atomicamente.

type SpinLock struct {
    locked atomic.Bool
}

func (s *SpinLock) Lock() {
    for s.locked.Load() {
        // Spin-wait
    }
    if !s.locked.CompareAndSwap(false, true) {
        s.Lock() // Tenta novamente
    }
}

func (s *SpinLock) Unlock() {
    s.locked.Store(false)
}

O problema ABA: Considere um ponteiro que muda de A para B e depois volta para A. Uma operação CAS pode não detectar essa mudança. Para resolver isso, usa-se um contador de versão junto com o valor:

type ABAFreeValue struct {
    value atomic.Value
    version atomic.Uint64
}

func (v *ABAFreeValue) CompareAndSwap(old, new interface{}) bool {
    currentVersion := v.version.Load()
    if v.value.Load() != old {
        return false
    }
    return v.version.CompareAndSwap(currentVersion, currentVersion+1)
}

4. Operações Aritméticas Atômicas: Add e Sub

Add é a operação mais comum para contadores e métricas:

type ServerMetrics struct {
    requests  atomic.Int64
    errors    atomic.Int64
    bytesSent atomic.Int64
}

func (m *ServerMetrics) RecordRequest() {
    m.requests.Add(1)
}

func (m *ServerMetrics) RecordError() {
    m.errors.Add(1)
}

func (m *ServerMetrics) RecordBytes(n int64) {
    m.bytesSent.Add(n)
}

func (m *ServerMetrics) Snapshot() map[string]int64 {
    return map[string]int64{
        "requests":  m.requests.Load(),
        "errors":    m.errors.Load(),
        "bytes_sent": m.bytesSent.Load(),
    }
}

Para tipos unsigned, use AddUint32 ou AddUint64:

var counter atomic.Uint64
counter.Add(1) // Incremento seguro para uint64

5. Trabalhando com Tipos Genéricos: atomic.Value

atomic.Value permite armazenar qualquer tipo de forma atômica, mas requer type assertion na leitura:

type AppConfig struct {
    MaxConnections int
    Timeout        time.Duration
    DebugMode      bool
}

type ConfigManager struct {
    config atomic.Value
}

func NewConfigManager(initial AppConfig) *ConfigManager {
    cm := &ConfigManager{}
    cm.config.Store(initial)
    return cm
}

func (cm *ConfigManager) Get() AppConfig {
    return cm.config.Load().(AppConfig)
}

func (cm *ConfigManager) Update(newConfig AppConfig) {
    cm.config.Store(newConfig)
}

// Uso em runtime
func main() {
    cm := NewConfigManager(AppConfig{
        MaxConnections: 100,
        Timeout:        30 * time.Second,
        DebugMode:      false,
    })

    // Goroutine que atualiza configurações
    go func() {
        for {
            time.Sleep(5 * time.Second)
            cm.Update(AppConfig{
                MaxConnections: 200,
                Timeout:        60 * time.Second,
                DebugMode:      true,
            })
        }
    }()

    // Leitura segura de qualquer goroutine
    config := cm.Get()
    fmt.Printf("Max connections: %d\n", config.MaxConnections)
}

Importante: atomic.Value não deve ser copiada após o primeiro uso, e o tipo armazenado deve ser consistente (não misture tipos diferentes no mesmo Value).

6. Padrões Avançados com sync/atomic

Contador de Referência Atômico

type RefCounted struct {
    refs atomic.Int32
    data interface{}
}

func (r *RefCounted) Retain() {
    r.refs.Add(1)
}

func (r *RefCounted) Release() {
    if r.refs.Add(-1) == 0 {
        // Cleanup
        r.data = nil
    }
}

Fila Lock-Free Simples (SPSC - Single Producer Single Consumer)

type LockFreeQueue struct {
    buffer []interface{}
    head   atomic.Uint64
    tail   atomic.Uint64
    size   uint64
}

func NewLockFreeQueue(size uint64) *LockFreeQueue {
    return &LockFreeQueue{
        buffer: make([]interface{}, size),
        size:   size,
    }
}

func (q *LockFreeQueue) Enqueue(item interface{}) bool {
    for {
        tail := q.tail.Load()
        nextTail := (tail + 1) % q.size
        if nextTail == q.head.Load() {
            return false // Fila cheia
        }
        if q.tail.CompareAndSwap(tail, nextTail) {
            q.buffer[tail] = item
            return true
        }
    }
}

func (q *LockFreeQueue) Dequeue() (interface{}, bool) {
    for {
        head := q.head.Load()
        if head == q.tail.Load() {
            return nil, false // Fila vazia
        }
        item := q.buffer[head]
        if q.head.CompareAndSwap(head, (head+1)%q.size) {
            return item, true
        }
    }
}

7. Boas Práticas e Erros Comuns

Quando usar cada abordagem:
- Use sync/atomic para operações simples em tipos primitivos (contadores, flags, ponteiros)
- Use sync.Mutex para seções críticas que envolvem múltiplas operações ou estruturas complexas
- Use channels para comunicação entre goroutines (não apenas sincronização)

Alinhamento de memória: Em arquiteturas 32-bit, variáveis de 64-bit (int64, uint64) precisam estar alinhadas a 8 bytes. Em structs, organize os campos para evitar problemas:

// Correto: campos de 64-bit primeiro
type Aligned struct {
    counter int64  // 8 bytes
    active  bool   // 1 byte
    _       [7]byte // padding
}

// Incorreto: pode causar panic em 32-bit
type Misaligned struct {
    active  bool
    counter int64 // Não alinhado a 8 bytes
}

Ponteiros e GC: atomic.StorePointer e atomic.LoadPointer trabalham com unsafe.Pointer. O garbage collector pode mover objetos, mas esses ponteiros são tratados corretamente pelo runtime. Use atomic.Value sempre que possível para evitar manipulação direta de ponteiros.

8. Benchmark e Performance: atomic vs Locks

package benchmark

import (
    "sync"
    "sync/atomic"
    "testing"
)

type AtomicCounter struct {
    value atomic.Int64
}

type MutexCounter struct {
    mu    sync.Mutex
    value int64
}

func BenchmarkAtomicAdd(b *testing.B) {
    c := &AtomicCounter{}
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            c.value.Add(1)
        }
    })
}

func BenchmarkMutexAdd(b *testing.B) {
    c := &MutexCounter{}
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            c.mu.Lock()
            c.value++
            c.mu.Unlock()
        }
    })
}

Execute com:

go test -bench=. -benchmem -race

Em cenários de baixa contenção, atomic.Add é 5-10x mais rápido que Mutex. Em alta contenção (muitas goroutines competindo), a diferença diminui, mas operações atômicas ainda são mais eficientes por não causar trocas de contexto no scheduler.

Resultados típicos (Go 1.21, 8 cores):

BenchmarkAtomicAdd-8    100000000    12.3 ns/op    0 B/op    0 allocs/op
BenchmarkMutexAdd-8     30000000     45.7 ns/op    0 B/op    0 allocs/op

Referências