Модуль: 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#
| Сценарий | atomic | mutex |
|---|---|---|
| Счётчик, флаг, один указатель | да | избыточно |
| Несколько связанных полей (инвариант) | нет (нельзя обновить атомарно) | да |
| Длинная критическая секция, 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 и как это профилировать.