Модель памяти и гонки

Перед началом: модуль 1 (GMP, GOMAXPROCS). Инструменты: Go 1.22+, go test -race, go vet. Трудоёмкость: ~4 ч (теория 2 ч · практика 1.5 ч · квиз 0.5 ч).

🔥 Инцидент: конфиг иногда приходил nil, а сервис падал раз в неделю

Сервис раз в несколько дней падал на старте с nil pointer dereference при чтении конфигурации. Воспроизвести не удавалось: на ноутбуке разработчика и на CI всё проходило, в проде — примерно раз в неделю. Конфиг загружался в отдельной горутине, а main ждал флаг готовности:

go
var cfg *Config
var ready bool

go func() {
    cfg = loadConfig()
    ready = true
}()

for !ready {
}
use(cfg) // иногда nil

Внешне это выглядело как «редкая одновременность»: не пересеклись — хорошо, пересеклись — падение. На самом деле одновременность ни при чём. Компилятор и процессор вправе переставить ready = true раньше записи полей cfg, и читатель увидит флаг, но не данные. На x86 перестановка редка, на ARM — обычное дело; отсюда «раз в неделю».

Это модуль про то, какие пары операций гарантируют видимость (happens-before), чем гонка данных отличается от логической гонки и как ловить обе.

📖 Теория

2.1 Happens-before: порядок, который обещает модель памяти

Модель памяти Go задаёт частичный порядок событий: если A happens-before B, то B гарантированно видит результаты A, а компилятор не вправе переставить их местами. Без такого ребра порядок не определён — даже если в исходнике строка выше.

Пять источников рёбер, которых достаточно на практике:

  • Порядок программы: внутри одной горутины операции идут в порядке исходника.
  • go: всё, что выполнено до go f(), happens-before старта f.
  • Канал: отправка happens-before приёма, который её получил; close(ch) happens-before приёма нулевого значения из ch.
  • Мьютекс: Unlock happens-before следующего Lock того же мьютекса; для RWMutex — Unlock перед RLock.
  • atomic: Store happens-before Load, прочитавшего это значение.

К ним примыкает WaitGroup: Done happens-before возврата Wait. Отношение транзитивно: A до B и B до C даёт A до C. Публикация — цепочка «запись данных → барьер → чтение данных»; в инциденте выше барьера не было вовсе:

go
go func() {
    cfg = loadConfig() // запись полей
    ready = true       // нет барьера: порядок не гарантирован
}()
for !ready {           // busy-wait, чтение без синхронизации
}
use(cfg)               // может увидеть nil

Корректные способы закрыть этот разрыв:

go
// 1. Канал: приём видит всё, что было до отправки.
ch := make(chan *Config, 1)
go func() { ch <- loadConfig() }()
cfg := <-ch

// 2. Мьютекс: данные под замком, флаг — atomic.
mu.Lock()
cfg = loadConfig()
mu.Unlock()
ready.Store(true)

for !ready {} с обычным bool — не барьер, а сжигание ядра. С atomic.Bool видимость есть, но планировщик всё равно голодает: ждите канал, WaitGroup или мьютекс с условием.

2.2 Гонка данных и логическая гонка — разные вещи

Гонка данных (data race) — два обращения к одной ячейке из разных горутин, хотя бы одно — запись, и между ними нет happens-before. В Go это неопределённое поведение; пример:

go
var n int
go func() { n++ }() // load + add + store
go func() { n++ }() // те же три шага без порядка

n++ — не атомарная операция: две горутины читают одно и то же старое значение и теряют по единице. go test -race сообщает:

text
WARNING: DATA RACE
Write at 0x00c0000140f8 by goroutine 7:
  main.main.func1()
      /tmp/race.go:6 +0x30
Previous write at 0x00c0000140f8 by goroutine 6:
  main.main.func2()
      /tmp/race.go:7 +0x30

Читать отчёт нужно по двум стекам: это два конфликтующих доступа к одному адресу, у каждого — горутина и строка. Между ними нет синхронизации — значит, нужен барьер, а не «повезёт».

Логическая гонка (race condition) — шире: результат зависит от порядка событий, даже когда все обращения атомарны. Пример check-then-act без гонки данных:

go
if !cache.Has(k) {  // RLock внутри
    v := load(k)    // долго, блокировка не держится
    cache.Set(k, v) // двое успеют загрузить одно и то же
}

Гонки данных здесь нет, но два потока сделают лишнюю работу или запишут разные версии. -race такую ошибку не найдёт: он видит только несинхронизированный доступ к памяти, а не нарушение инварианта. Поэтому детектор — фильтр грубых ошибок, а не доказательство корректности.

Что важно знать про go test -race:

  • Инструментирует только выполненные пути: непройденная ветка не будет проверена. Нужны тесты с реальной конкурентностью.
  • Замедляет код в 5–10 раз и требует больше памяти — запускайте в CI и на интеграционных тестах, не в горячем проде.
  • Находит именно гонки данных, но не дедлоки, не утечки горутин и не логические гонки. Для них — отдельные приёмы (модули 4, 17).

2.3 Atomic: одна переменная и явные пары Load/Store

Пакет sync/atomic даёт операции над одной ячейкой без мьютекса. Для счётчиков и флагов хватает Add, Load, Store; для «прочитал — проверил — записал» нужен цикл CAS:

go
var n atomic.Int64
for {
    old := n.Load()
    if n.CompareAndSwap(old, old+1) {
        break // успели; иначе значение уже другое — пробуем снова
    }
}

CompareAndSwap меняет значение, только если оно всё ещё равно old. Под нагрузкой попыток будет несколько, поэтому CAS-цикл дороже одного Add; для простого счётчика берите Add.

Через atomic удобно публиковать неизменяемые снимки:

go
var current atomic.Pointer[Config]

func reload() {
    c := loadConfig() // новый объект, никто не видит его до Store
    current.Store(c)
}

func get() *Config { return current.Load() }

Читатели не блокируются. Условие: после Store объект нельзя менять — иначе читатель получит гонку. Правка конфига — это новый объект и новый Store, а не мутация старого.

atomic не заменяет мьютекс, когда менять нужно несколько полей согласованно: две атомарные записи не образуют одну транзакцию. И atomic.Value/atomic.Pointer требуют, чтобы тип значения не менялся между Store.

2.4 Барьеры: канал, мьютекс, WaitGroup

Барьер — это операция, которая создаёт ребро happens-before. Выбор барьера определяет, кто кого ждёт и как выглядит выход:

  • Канал — барьер и передача владения: отправка завершается, когда значение принято, и приём видит его целиком.
  • Мьютекс — барьер для shared state: Unlock виден только следующему Lock, а не «всем вообще».
  • sync.WaitGroup — барьер завершения: Done happens-before возврата Wait; Add — строго до запуска горутины.
  • sync.Once — барьер инициализации: вернувшийся из Do видит результат, даже если функция шла в другой горутине.

В сниппете модуля показаны три рабочих схемы. Первая — данные под sync.Mutex, готовность в atomic.Bool; читатель сначала дожидается флага, затем берёт замок и видит конфиг целиком. Вторая — канал: ch <- config{...} и got := <-ch образуют пару, после которой все поля видны. Третья — CAS-цикл: четыре горутины по тысяче инкрементов дают ровно 4000, потому что неудачная попытка повторяется, а не теряется.

2.5 Компромиссы

  • atomic vs мьютекс: атомик дешевле и не блокирует, но годится для одной переменной; мьютекс защищает инвариант из нескольких полей. Если сомневаетесь — начинайте с мьютекса.
  • Канал vs мьютекс для публикации: канал выражает передачу владения и дружит с select; мьютекс удобнее для постоянного чтения shared state. Смешивать «флаг в atomic, данные без защиты» — классическая ошибка.
  • Неизменяемый снимок vs блокировка: atomic.Pointer даёт чтение без ожидания ценой аллокации на каждое обновление; мьютекс экономит память, но сериализует читателей с писателем.
  • -race в CI vs всегда: детектор замедляет в разы, поэтому его место — тесты и ночные прогоны; в проде включают точечно.

💻 Практика

  1. Воспроизведите инцидент. Соберите пример с cfg *Config и флагом ready bool без синхронизации, запустите go run -race и найдите два стека. Почините: atomic + мьютекс, канал, WaitGroup.
  2. Сравните счётчики. 8 горутин по 100 000 инкрементов: n++, mutex, atomic.Add, CAS-цикл. Корректные версии дают 800 000. Замерьте время и объясните, почему n++ «почти работает», а CAS-цикл медленнее Add.
  3. Опубликуйте снимок конфига. atomic.Pointer на неизменяемую структуру: писатель раз в 100 мс создаёт новый объект, десять читателей вызывают Load. Прогоните с -race, затем добавьте мутацию после Store — детектор поймает ошибку.
  4. Запустите сниппет модуля. Ожидаемый вывод: atomic-флаг + мьютекс: 3 500ms, через канал: 5 1s, CAS-счётчик (4×1000): 4000. Первые две строки — пары «барьер + данные», третья — CAS без потерь; -race чист.

🧩 Quiz

1. Что гарантирует отношение happens-before между A и B?
2. Зачем CAS-цикл повторяет попытку после неудачи?
3. О чём говорит отчёт WARNING: DATA RACE?
4. Что такое гонка данных (data race)?
5. Как безопасно опубликовать готовую структуру конфига?

✅ Итоги модуля

Студент должен:

  • Объяснять happens-before и перечислять его источники
  • Отличать гонку данных от логической гонки
  • Читать отчёт go test -race по двум стекам
  • Публиковать данные через канал, мьютекс или WaitGroup
  • Использовать atomic.Load/Store и CAS-цикл осознанно
  • Публиковать неизменяемые снимки через atomic.Pointer
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 3

Барьер — это не только «дождаться», но и «передать владение». Самый выразительный инструмент — канал: очередь, направление и правило, кто вправе закрыть. Дальше — каналы и fan-in.