Атомарные операции и модель памяти Go
Атомарная операция выполняет неделимое обновление одной ячейки и задаёт синхронизацию, определённую моделью памяти Go. Она полезна для простых независимых счётчиков, флагов и lock-free read paths. Если корректность зависит от нескольких полей, проверки и изменения вместе, нужен mutex, канал или иной явно защищающий инвариант механизм.
Зачем это на интервью
Важно не только назвать atomic.Add, но и объяснить, почему x++ — read-modify-write с гонкой, что обычное чтение рядом с atomic write тоже может быть data race, и почему не следует переносить в Go догадки о relaxed/acquire/release из другого языка.
Минимум для E4
- Различать atomicity одной переменной и атомарность бизнес-операции над несколькими полями.
- Использовать типы
atomic.Int64,atomic.Bool,atomic.Pointer[T]вместо смешивания обычного и atomic доступа. - Не читать и не писать переменную обычной операцией, если другой путь обращается к ней atomically.
- Выбирать
Mutexдля составных инвариантов иatomicтолько для маленького проверяемого hot path.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var total atomic.Int64
var wg sync.WaitGroup
for range 100 {
wg.Add(1)
go func() {
defer wg.Done()
total.Add(1)
}()
}
wg.Wait()
fmt.Println(total.Load()) // 100
}total.Add(1) — одна атомарная read-modify-write операция. Запись total++ для обычного int64 состоит из отдельных load, increment и store, поэтому два goroutine могут потерять обновление. Атомарный счётчик не делает безопасными связанные с ним map, slice или условие «если лимит не превышен — добавить».
Углубление для E5/Senior
Модель памяти Go определяет happens-before: если запись happens-before чтения, чтение обязано наблюдать эту запись или более позднюю. Mutex, channel communication, WaitGroup/Once согласно своим контрактам и атомарные операции создают такие отношения. Программы без data race выполняются последовательно согласованно (DRF-SC): их поведение можно объяснять некоторой последовательностью операций, согласованной с happens-before.
Документация sync/atomic задаёт для атомарных операций Go последовательную согласованность: все atomic operations ведут себя так, будто исполнены в одном последовательном порядке. В прикладном Go не подставляйте вручную acquire/release/relaxed: этих режимов в публичном API нет. Низкоуровневая реализация и процессорный reorder — задача runtime; ваш контракт выражается правильно выбранными atomic API и отсутствием гонок.
Для публикации неизменяемой конфигурации подойдёт atomic.Pointer[T] или atomic.Value: сначала полностью построить объект, затем atomically Store; readers делают Load и не меняют опубликованный объект. ABA, lock-free структуры и CAS-loop оправданы только с доказанным выигрышем и тестами; для сложных состояний mutex проще проверить и сопровождать.
package main
import (
"fmt"
"sync/atomic"
)
type Config struct {
Limit int
}
func main() {
var current atomic.Pointer[Config]
current.Store(&Config{Limit: 10}) // объект больше не изменяется
cfg := current.Load()
fmt.Println(cfg.Limit)
}Ключевые понятия
- Data race — два конфликтующих доступа к одной ячейке из разных goroutine без синхронизации, хотя бы один — write.
- Happens-before — частичный порядок видимости операций, который создают синхронизирующие события.
- Sequential consistency — результат эквивалентен некоторому единому порядку atomic операций, согласованному с программным порядком.
- CAS — compare-and-swap: поменять значение только если оно всё ещё ожидаемое; часто применяется в retry loop.
- Publication — безопасно сделать полностью инициализированное, далее неизменяемое значение доступным readers.
Типовые вопросы
- Почему
counter++не безопасен?- Это несколько операций: чтение, вычисление и запись. Interleaving теряет обновления и образует data race без синхронизации.
- Можно ли читать atomic-поле обычным
if flag?- Нет. Все конкурентные обращения к такой переменной должны следовать одному синхронизированному протоколу, например
flag.Load()/Store().
- Нет. Все конкурентные обращения к такой переменной должны следовать одному синхронизированному протоколу, например
- Что выбрать: atomic или mutex?
- Atomic для одной простой переменной с ясной семантикой; mutex для нескольких зависимых полей, сложных переходов состояния и читаемости.
- Гарантирует ли atomic отсутствие любой гонки?
- Только для конкретной ячейки и операции. Объекты, на которые указывает atomic pointer, тоже нельзя изменять конкурентно без отдельной синхронизации.
- Нужны ли в Go ручные memory barriers?
- Обычно нет. Используйте документированные atomic и sync primitives; публичный
sync/atomicдаёт последовательную согласованность.
- Обычно нет. Используйте документированные atomic и sync primitives; публичный
- Когда нужен
atomic.Value?- Для read-mostly публикации значений одного конкретного типа. Нельзя хранить
nil, а все Store должны иметь согласованный concrete type.
- Для read-mostly публикации значений одного конкретного типа. Нельзя хранить
Практика
- Напишите конкурентный счётчик обычным
int64, затемatomic.Int64. Готово: первый вариант обнаруживается-raceпри достаточной нагрузке, второй имеет точный итог. - Опубликуйте неизменяемый Config через
atomic.Pointer. Готово: readers не модифицируют Config; тест с параллельными Load/Store проходитgo test -race. - Реализуйте лимит «проверить и зарезервировать» сначала CAS-loop, затем mutex. Готово: оба варианта сохраняют лимит; выбор объяснён benchmark и сложностью поддержки.
Частые ошибки и ловушки
- Защищать только счётчик atomic-операцией, но менять связанное состояние без lock.
- Смешивать обычные и atomic обращения к одному полю.
- Публиковать pointer, а затем менять объект, на который он указывает.
- Строить lock-free очередь без необходимости и тестов на ABA/прогресс.
- Путать отсутствие падения с отсутствием data race.
Связанные темы
Конкурентность · sync-примитивы · Гонки и race detector · False sharing