Модуль: Concurrency · Уровень: Senior

TL;DR#

sync/atomic даёт атомарные операции над одиночными словами (load/store/add/swap/CAS), реализованные аппаратными инструкциями (LOCK-префикс на x86, LL/SC или CASAL на ARM) без блокировок и парковки горутин. Go 1.19 ввёл типобезопасные обёртки atomic.Int64, atomic.Pointer[T], atomic.Bool и др., которые нельзя случайно скопировать и которые гарантируют выравнивание. Atomic — для простых счётчиков/флагов/указателей и lock-free структур; для составных инвариантов (несколько связанных полей) нужен мьютекс.

Теория#

Операции#

  • Load / Store — атомарное чтение/запись слова целиком (без «разорванной» записи).
  • Add — атомарный инкремент/декремент, возвращает новое значение.
  • Swap — атомарно записать новое и вернуть старое.
  • CompareAndSwap (CAS) — если текущее значение == old, записать new, вернуть успех. Фундамент lock-free алгоритмов.
var n atomic.Int64
n.Add(1)
v := n.Load()

// CAS-цикл (классический lock-free паттерн)
for {
    old := n.Load()
    next := compute(old)
    if n.CompareAndSwap(old, next) {
        break // успех: никто не вмешался между Load и CAS
    }
    // кто-то изменил n — повторяем
}

Типобезопасные обёртки (Go 1.19+)#

var (
    flag atomic.Bool
    cnt  atomic.Uint64
    cfg  atomic.Pointer[Config]
    val  atomic.Value // для произвольного типа, тип фиксируется первым Store
)

Преимущества над старыми функциями atomic.AddInt64(&x, 1):

  • Нельзя случайно сделать неатомарный доступ (x++ мимо atomic) — поле приватное.
  • noCopy — go vet ловит копирование.
  • Гарантия выравнивания: на 32-битных платформах старый int64 требовал ручного 8-байтного выравнивания (первое поле структуры), иначе паника в рантайме. Обёртки решают это сами.
  • atomic.Pointer[T] — типизированный, без unsafe.Pointer в пользовательском коде.

atomic.Value и atomic.Pointer для copy-on-write#

Read-heavy конфиг без блокировок у читателей:

var cfg atomic.Pointer[Config]

func Get() *Config { return cfg.Load() }      // читатели — без блокировки
func Update(c *Config) { cfg.Store(c) }        // писатель публикует новый снапшот

Читатели берут неизменяемый снапшот; писатель строит новую структуру и атомарно подменяет указатель. Старые читатели продолжают видеть прежний снапшот (он жив, пока на него есть ссылки — GC соберёт позже).

Memory ordering в Go#

Go не даёт программисту выбора порядка (нет relaxed/acquire/release как в C++). Модель проще:

  • Все atomic-операции в Go — sequentially consistent (с Go 1.19 это явно зафиксировано в memory model).
  • Атомарная запись, наблюдаемая атомарным чтением, устанавливает happens-before между ними: всё, что записано до atomic Store, видно после atomic Load этого значения.
  • Это значит: atomic-флаг можно использовать как барьер публикации данных (как done в sync.Once).

Под капотом компилятор вставляет нужные барьеры/инструкции: на x86 (сильная модель) обычные load/store почти бесплатны, нужен барьер в основном для CAS/Add (LOCK); на ARM/ARM64 (слабая модель) вставляются dmb/используются acquire-release инструкции (LDAR/STLR, CASAL).

Когда atomic вместо mutex#

Сценарийatomicmutex
Счётчик, флаг, один указательдаизбыточно
Несколько связанных полей (инвариант)нет (нельзя обновить атомарно)да
Длинная критическая секция, I/Oнетда
Copy-on-write снапшотatomic.Pointerможно, но дороже читателям
Высокая частота, простая операцияда (без парковки)возможен contention

Atomic выигрывает на простых операциях: нет захвата мьютекса, нет парковки/побудки горутин, нет переключения контекста. Но при высокой конкуренции CAS-цикл может крутиться (много retry) и тоже грузить шину когерентности — не всегда быстрее мьютекса.

Подводные камни / gotchas#

  • Atomic защищает только одну переменную. Два связанных atomic-поля нельзя обновить атомарно вместе — между обновлениями возможна гонка логики. Для инварианта над несколькими полями — мьютекс или один atomic.Pointer на всю структуру (COW).
  • Смешивание atomic и обычного доступа к той же переменной — гонка данных (-race ловит). Если переменная atomic, все обращения к ней должны быть atomic.
  • Старый API на 32-битах: atomic.AddInt64(&x, ...) требует 8-байтного выравнивания x; невыровненный → паника. Типизированные обёртки (1.19) снимают эту боль — предпочитайте их.
  • ABA-проблема в CAS. Значение могло смениться A→B→A; CAS пройдёт, хотя «что-то происходило». Для lock-free структур (стеки, очереди) это реальный риск; решают версионными счётчиками/тегами или иными приёмами.
  • Busy CAS-цикл под высокой конкуренцией жжёт CPU и может голодать. Иногда мьютекс (с парковкой) эффективнее.
  • Копирование структуры с atomic-полем ломает атомарность; go vet (через noCopy) предупреждает.
  • atomic.Value: все Store должны быть одного конкретного типа, иначе паника. Нельзя Store(nil) первым.

Вопросы на собеседовании#

В: Что такое CAS и зачем нужен? О: Compare-And-Swap: атомарно проверяет, что значение равно ожидаемому old, и только тогда записывает new. Возвращает успех/неудачу. Это основа lock-free алгоритмов: в цикле читаем состояние, вычисляем новое, пытаемся CAS; при неудаче (кто-то вмешался) повторяем.

В: Какую модель памяти даёт atomic в Go? О: Sequentially consistent (явно с Go 1.19). Дополнительно атомарная запись, прочитанная атомарным чтением, создаёт happens-before — записи до Store видны после соответствующего Load. В Go нет relaxed/acquire/release на выбор, как в C++.

В: Когда брать atomic, а когда mutex? О: Atomic — для одиночной переменной: счётчик, флаг, указатель, lock-free снапшот. Mutex — когда нужно атомарно обновить несколько связанных полей (инвариант), длинная критическая секция или сложная логика. Atomic не умеет согласованно менять две переменные сразу.

В: Зачем нужны типизированные обёртки 1.19, если есть atomic.AddInt64? О: Они предотвращают случайный неатомарный доступ (поле приватно), запрещают копирование (noCopy/go vet), гарантируют выравнивание на 32-бит платформах и дают типобезопасный Pointer[T] без unsafe. Старый API легко использовать неправильно.

В: Что такое ABA-проблема? О: Значение меняется A→B→A, и CAS на A проходит, хотя между чтением и CAS состояние менялось. Для lock-free структур это может привести к некорректности (например, переиспользованный узел). Решают версионными тегами/счётчиками поколений.

В: Можно ли читать atomic-переменную обычным чтением? О: Нет, это гонка данных. Если переменная атомарная, все доступы (и чтение, и запись) должны идти через atomic-операции. Смешивание ловит race detector.

В: Как сделать lock-free read-heavy конфиг? О: atomic.Pointer[Config]: читатели делают Load() (без блокировки), писатель строит новый неизменяемый Config и Store() подменяет указатель (copy-on-write). Старые снапшоты живут, пока есть ссылки, потом собираются GC.

В: Всегда ли atomic быстрее мьютекса? О: Нет. На простых неконкурентных операциях — да (нет парковки/контекст-свитча). Но под высокой конкуренцией CAS-цикл может много раз ретраить и грузить когерентность кэшей; иногда мьютекс с парковкой эффективнее. Нужно мерить.

В: Что особенного в atomic на 32-битных платформах? О: 64-битные атомарные операции требуют 8-байтного выравнивания; невыровненный адрес → паника. Раньше приходилось ставить int64 первым полем структуры. Типизированные обёртки (1.19) обеспечивают выравнивание автоматически.

На что копают на senior+#

  • Аппаратная реализация: LOCK-префикс x86 vs LL/SC и CASAL/LDAR/STLR на ARM64; стоимость барьеров на сильной vs слабой модели памяти.
  • Go memory model и формулировка happens-before для atomic (изменения в 1.19, согласование с C/C++11 SC).
  • Lock-free дизайн: CAS-циклы, ABA, hazard pointers/эпохи, почему GC в Go упрощает reclamation памяти по сравнению с C++.
  • Copy-on-write на atomic.Pointer: жизненный цикл снапшотов, взаимодействие с GC, отсутствие блокировки у читателей.
  • False sharing атомарных счётчиков и padding до cache line; почему «горячий» atomic.Int64 в структуре бьёт по соседним полям.
  • Когда CAS-loop проигрывает мьютексу под contention и как это профилировать.