Атомарные операции и модель памяти 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.

Типовые вопросы

  1. Почему counter++ не безопасен?
    • Это несколько операций: чтение, вычисление и запись. Interleaving теряет обновления и образует data race без синхронизации.
  2. Можно ли читать atomic-поле обычным if flag?
    • Нет. Все конкурентные обращения к такой переменной должны следовать одному синхронизированному протоколу, например flag.Load()/Store().
  3. Что выбрать: atomic или mutex?
    • Atomic для одной простой переменной с ясной семантикой; mutex для нескольких зависимых полей, сложных переходов состояния и читаемости.
  4. Гарантирует ли atomic отсутствие любой гонки?
    • Только для конкретной ячейки и операции. Объекты, на которые указывает atomic pointer, тоже нельзя изменять конкурентно без отдельной синхронизации.
  5. Нужны ли в Go ручные memory barriers?
    • Обычно нет. Используйте документированные atomic и sync primitives; публичный sync/atomic даёт последовательную согласованность.
  6. Когда нужен atomic.Value?
    • Для read-mostly публикации значений одного конкретного типа. Нельзя хранить nil, а все Store должны иметь согласованный concrete type.

Практика

  • Напишите конкурентный счётчик обычным 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

Источники