Модель памяти Go: happens-before и почему гонки — это не «иногда»

Что на самом деле гарантирует модель памяти Go: отношение happens-before, публикация данных через каналы, мьютексы и атомики, переупорядочивание операций и почему гонка данных делает программу некорректной — модель памяти перестаёт давать нужные гарантии, а не просто «изредка возвращает неверное значение»

«Ну, изредка прочитается старое значение» — так обычно думают о гонках данных, и это опасное заблуждение. Модель памяти Go говорит жёстче: программа с гонкой данных некорректна — модель больше не гарантирует, что чтение увидит ожидаемое значение, а компилятор с процессором вправе переупорядочивать операции так, что интуиция ломается полностью. (Это не «неопределённое поведение» в стиле C++: Go, например, не допускает чтения «из воздуха» для значений размером с машинное слово — но полагаться на такие частные ограничения как на гарантию нельзя.) Понимание отношения happens-before — это то, что отличает «работает на моей машине» от кода, который действительно корректен под конкуренцией.

Это третья статья серии «Конкурентность в Go». Опирается на примитивы из второй: здесь — какие гарантии видимости они на самом деле дают. Сам официальный документ The Go Memory Model открывается неожиданным для спецификации советом: «Don’t be clever» — не умничайте. Если для понимания поведения программы приходится вчитываться в модель памяти, значит, синхронизации в коде слишком мало. Эта статья — как раз про то, где проходит граница между «достаточно» и «слишком мало».

Модель памяти Go: стрелка happens-before ведёт данные от gopher-писателя (Thread A) через gate синхронизации (канал + мьютекс) к читателю (Thread B), который видит полные данные; ниже — призрачный путь «no happens-before» со stale-данными и бесконечным циклом

В статье

Что такое happens-before

Модель памяти отвечает на один вопрос: при каких условиях чтение переменной в одной горутине гарантированно видит значение, записанное в другой горутине? Ответ формулируется через частичный порядок на операциях с памятью — отношение happens-before («происходит раньше»).

Официальный документ определяет его так. Если событие e1 происходит раньше события e2, то говорят, что e2 происходит позже e1. Если e1 не происходит ни раньше, ни позже e2 — они происходят конкурентно (concurrently). Ключевые два правила порядка:

  • Внутри одной горутины happens-before — это просто порядок исходного текста программы (program order). Инструкции выполняются в том порядке, в каком написаны (с точностью до переупорядочиваний, которые извне не наблюдаемы).
  • Между разными горутинами happens-before возникает только через акты синхронизации: операции с каналами, sync/sync/atomic. Без синхронизации две горутины не упорядочены между собой — их операции конкурентны.

Формальная гарантия видимости из документа выглядит так. Чтение r переменной x гарантированно наблюдает конкретную запись w в x, если выполнены оба условия:

  1. w происходит раньше r (w happens-before r);
  2. любая другая запись в x происходит либо раньше w, либо позже r.

Первое условие обеспечивает, что w вообще видна для r. Второе — что никакая другая запись не «затирает» её конкурентно. Если хотя бы одно из условий не выполнено, гарантии нет: чтение вправе увидеть любое из конкурентных значений, включая устаревшее.

Отсюда главное практическое следствие: happens-before не возникает сам по себе от того, что «одна горутина выполнилась раньше по времени». Реальное время (wall clock) вообще не участвует в определении. Значение имеет только логический порядок, установленный синхронизацией. Две записи, разнесённые на миллисекунды по времени, но не связанные ни одним актом синхронизации, — конкурентны, и одна не обязана быть видна там, где читают другую.

graph LR A["a = «hello»
(горутина f)"] -->|program order| B["c ← 0
(send)"] B -->|send happens-before
receive completes| C["← c
(receive, main)"] C -->|program order| D["print(a)"]

graph LR
    A["a = «hello»
(горутина f)"] -->|program order| B["c ← 0
(send)"] B -->|send happens-before
receive completes| C["← c
(receive, main)"] C -->|program order| D["print(a)"]
Цепочка happens-before: запись в a видна в print только потому, что связана через send/receive на канале

Что устанавливает happens-before

Это ядро статьи. Ниже — исчерпывающий по официальному документу список того, что создаёт happens-before между горутинами. Формулировки взяты из модели дословно по смыслу: если правила нет в этом списке, гарантии нет.

Инициализация пакета

Инициализация пакета выполняется в одной горутине, но эта горутина может создавать другие, которые бегут конкурентно. Правило: если пакет p импортирует пакет q, то завершение init-функций q происходит раньше начала любой init-функции p. И: завершение всех init-функций происходит раньше начала функции main.main. Поэтому переменные, инициализированные на уровне пакета, к моменту старта main уже видны без дополнительной синхронизации.

Оператор go — создание горутины

Оператор go, запускающий новую горутину, происходит раньше начала выполнения этой горутины. Всё, что подготовлено до go f(), гарантированно видно внутри f.

var a string

func f() {
	print(a) // гарантированно печатает "hello, world"
}

func hello() {
	a = "hello, world"
	go f() // запись в a happens-before началом f
}

Запись a = "hello, world" находится в program order до go f(), а go f() happens-before началом f. Цепочка замкнута — f видит "hello, world".

Завершение горутины — happens-before НЕ устанавливает

А вот в обратную сторону симметрии нет. Момент завершения горутины не связан отношением happens-before ни с каким событием в программе. Само по себе то, что горутина «дописала и вышла», ничего не гарантирует другим горутинам.

var a string

func hello() {
	go func() { a = "hello" }() // запись без синхронизации на выходе
	print(a)                    // конкурентное чтение — гонка данных
}

Здесь запись в a не сопровождается никаким актом синхронизации, поэтому не гарантирована к наблюдению другой горутиной. Более того, документ прямо отмечает: агрессивный компилятор вправе вообще удалить весь оператор go, раз его эффект нигде корректно не наблюдается. Чтобы дождаться результата, нужна явная синхронизация — канал, sync.WaitGroup, мьютекс.

Каналы

Каналы — основной механизм синхронизации в Go, и модель памяти описывает их четырьмя правилами. Каждая отправка на конкретный канал сопоставлена соответствующему приёму из этого канала (обычно в другой горутине).

  1. Отправка в канал происходит раньше завершения соответствующего приёма.
  2. Закрытие канала происходит раньше приёма, который возвращает нулевое значение из-за того, что канал закрыт.
  3. Приём из небуферизованного канала происходит раньше завершения соответствующей отправки.
  4. Обобщение для буферизованных: k-й приём из канала ёмкости C происходит раньше завершения (k+C)-й отправки. При C = 0 это и есть правило 3.

Правило 1 даёт классическую публикацию через буферизованный или любой канал:

var c = make(chan int, 10)
var a string

func f() {
	a = "hello, world"
	c <- 0 // отправка
}

func main() {
	go f()
	<-c       // приём завершается ПОСЛЕ отправки
	print(a)  // гарантированно "hello, world"
}

Цепочка: запись в a (program order) → отправка c <- 0 → (правило 1) завершение приёма <-c → (program order) print(a). Замена c <- 0 на close(c) работает по тому же правилу 2 — приём нулевого значения из закрытого канала тоже happens-after закрытия.

Правило 3 менее интуитивно, но важно: у небуферизованного канала приём завершается раньше, чем завершается отправка. Это позволяет публиковать данные «в обратную сторону»:

var c = make(chan int) // небуферизованный!
var a string

func f() {
	a = "hello, world"
	<-c // приём
}

func main() {
	go f()
	c <- 0   // отправка завершается ПОСЛЕ приёма в f
	print(a) // гарантированно "hello, world"
}

Здесь запись в a happens-before приёма <-c, приём happens-before завершения отправки c <- 0, отправка happens-before print(a). Тонкость: если бы канал был буферизованным (make(chan int, 1)), эта гарантия исчезла бы — отправка не блокировалась бы приёмом, цепочка рвётся, и программа могла бы напечатать пустую строку. Ёмкость канала — не деталь производительности, а часть семантики синхронизации.

Мьютексы

sync.Mutex и sync.RWMutex дают happens-before через пары Unlock/Lock. Формально: для любой переменной l типа sync.Mutex/sync.RWMutex и n < m, n-й вызов l.Unlock() происходит раньше, чем возвращается m-й вызов l.Lock().

var l sync.Mutex
var a string

func f() {
	a = "hello, world"
	l.Unlock() // первый Unlock
}

func main() {
	l.Lock()
	go f()
	l.Lock() // второй Lock возвращается ПОСЛЕ первого Unlock
	print(a) // гарантированно "hello, world"
}

Для RWMutex действует то же по существу: nUnlock happens-before возврата из соответствующего RLock, а парный RUnlock happens-before возврата (n+1)-го Lock. Отдельно про TryLock: успешный TryLock эквивалентен Lock в смысле синхронизации, а неуспешный не даёт никакого синхронизирующего эффекта — с точки зрения модели TryLock вправе вернуть false даже если мьютекс на самом деле свободен, так что на результат TryLock нельзя полагаться как на happens-before.

sync.Once

Once — безопасный механизм однократной инициализации. Из нескольких горутин, вызывающих once.Do(f), f() выполнит ровно одна, остальные заблокируются до её завершения. Гарантия: единственный вызов f() из once.Do(f) происходит раньше возврата любого вызова once.Do(f).

var a string
var once sync.Once

func setup() {
	a = "hello, world"
}

func doprint() {
	once.Do(setup)
	print(a) // гарантированно "hello, world" в любой горутине
}

func twoprint() {
	go doprint()
	go doprint()
}

setup выполнится один раз и завершится раньше, чем любой once.Do(setup) вернёт управление, — поэтому обе горутины гарантированно видят инициализированное a. Это и есть корректный способ ленивой инициализации; ручной «флаг + проверка» его не заменяет (см. ниже).

sync/atomic

С Go 1.19 модель памяти для атомиков уточнена и формализована. Правило: если эффект атомарной операции A наблюдается атомарной операцией B, то A происходит раньше B. И сильнее — все атомарные операции в программе выполняются так, как будто в некотором последовательно согласованном (sequentially consistent) порядке. Это та же семантика, что у sequentially consistent атомиков C++ и volatile-переменных Java. Go предоставляет только sequentially consistent атомики — никаких relaxed/acquire-release режимов, доступных в C++ или Rust, в языке нет.

var done atomic.Bool
var a string

func setup() {
	a = "hello, world"
	done.Store(true) // атомарная запись
}

func main() {
	go setup()
	for !done.Load() { // атомарное чтение
		runtime.Gosched()
	}
	print(a) // гарантированно "hello, world"
}

Здесь done.Store(true) — атомарная запись, done.Load() — атомарное чтение. Когда цикл наблюдает true, эффект Store наблюдён Load-ом, значит Store happens-before этого Load. А запись a = "hello, world" в program order стоит до Store, поэтому по транзитивности она видна после выхода из цикла. Именно наличие атомарной синхронизации превращает этот код из гонки в корректный (сравните с битым флагом ниже).

Go 1.19 заодно ввёл типизированные атомики — atomic.Bool, atomic.Int64, atomic.Pointer[T] и другие. Они предпочтительнее «сырых» функций atomic.AddInt64/atomic.LoadInt64: тип сам защищает значение, его нельзя случайно прочитать неатомарно, а atomic.Pointer[T] ещё и типобезопасен.

Прочие механизмы

Модель также описывает sync.WaitGroup (вызов Done() happens-before возврата разблокированного им Wait()), sync.Map, sync.Pool, sync.Cond и финализаторы (SetFinalizer(x, f) happens-before вызова f(x)). Все они сводятся к тому же принципу: happens-before возникает в точке, где эффект одной операции наблюдается другой.

Переупорядочивание: компилятор и процессор

Почему всё это вообще нужно? Потому что ни компилятор, ни процессор не обязаны исполнять память в порядке исходного текста — они переставляют операции ради скорости, пока это не наблюдаемо в пределах одной горутины. Между горутинами без синхронизации переупорядочивание становится видимым и ломает интуицию.

Канонический пример из документа:

var a, b int

func f() {
	a = 1
	b = 2
}

func g() {
	print(b) // может напечатать 2 ...
	print(a) // ... а затем 0
}

func main() {
	go f()
	g()
}

Интуиция подсказывает: раз a = 1 написано раньше b = 2, то увидев b == 2, мы точно увидим a == 1. Это неверно. g может напечатать 2, а затем 0 — потому что между f и g нет happens-before, и наблюдаемый порядок записей ничем не зафиксирован. Компилятор мог переставить a = 1 и b = 2 местами, процессор мог отложить запись a в общую память.

Сломанный double-checked locking

Отсюда — самая частая ошибка: «ленивая инициализация с флагом» без настоящей синхронизации.

var done bool // обычный bool, не atomic
var a string

func setup() {
	a = "hello, world"
	done = true
}

func main() {
	go setup()
	for !done { // конкурентное чтение done — гонка данных
	}
	print(a) // может напечатать "" ... или не завершиться никогда
}

Здесь три отдельные беды, все — прямые следствия отсутствия happens-before:

  • Нет гарантии, что, наблюдая done == true, main увидит и запись в a. Программа может напечатать пустую строку.
  • Нет гарантии, что запись done = true вообще когда-нибудь станет видна main. Между горутинами нет ни одного акта синхронизации, поэтому компилятор вправе поднять чтение done из цикла один раз — и цикл может не завершиться никогда.
  • Даже публикация через указатель не спасает: если бы setup собирал структуру и присваивал g = t без синхронизации, наблюдение g != nil не гарантировало бы видимость инициализированных полей t.

Правильных способов ровно два — и оба уже показаны выше: sync.Once для одноразовой инициализации либо sync/atomic для флага (плюс, если публикуется целый объект, — atomic.Pointer[T] или мьютекс вокруг критической секции). «Double-checked locking» в Go не пишут руками — его роль полностью закрывает sync.Once.

Data race и race condition — разные вещи

Эти два термина постоянно путают, а различие принципиальное.

Data race (гонка данных) — понятие модели памяти. Официальное определение: гонка данных — это когда запись в область памяти происходит конкурентно с другим чтением или записью той же области, если только все участвующие доступы не являются атомарными (операциями sync/atomic). То есть три условия: (1) два или более доступа к одной ячейке, (2) хотя бы один — запись, (3) они конкурентны (нет happens-before между ними) и не все атомарны. Программа с гонкой данных некорректна.

Race condition (гонка состояний) — понятие логики программы: результат зависит от относительного порядка событий. Race condition возможна и без всякой гонки данных.

var (
	mu  sync.Mutex
	bal int // баланс
)

func withdraw(n int) bool {
	mu.Lock()
	ok := bal >= n // проверка
	mu.Unlock()

	// ... здесь другая горутина может успеть списать ...

	mu.Lock()
	if ok {
		bal -= n // списание по устаревшему решению
	}
	mu.Unlock()
	return ok
}

Каждый доступ к bal защищён мьютексом — data race здесь нет, -race промолчит. Но проверка и списание разнесены на две критические секции, и логика «сначала проверил, потом списал» сломана: две конкурентные горутины могут обе увидеть достаточный баланс и обе списать, уведя bal в минус. Это классическая race condition, и лечится она не добавлением синхронизации к доступам, а расширением критической секции (проверка и списание — под одним Lock).

Практический вывод про инструменты: data race детектируется динамически (-race), race condition — нет. Детектор гонок наблюдает фактические доступы к памяти во время исполнения и ловит именно нарушения happens-before. Логическую ошибку атомарности решения он не видит — с его точки зрения всё синхронизировано.

Что модель гарантирует, а что нет

Соберём границы явно.

Что Гарантия модели памяти Go
Программа без гонок данных Ведёт себя так, будто все горутины выполнялись в некотором последовательно согласованном чередовании; можно рассуждать в терминах happens-before
Программа с гонкой данных Некорректна; поведение не определено моделью на уровне логики
Оператор go happens-before началом новой горутины
Завершение горутины Ничего не гарантирует само по себе
Send в канал happens-before завершения соответствующего приёма
Close канала happens-before приёма нулевого значения из-за закрытия
Receive из небуферизованного happens-before завершения соответствующей отправки
Unlock мьютекса happens-before возврата последующего Lock
Once.Do(f) выполнение f happens-before возврата любого Do
sync/atomic (Go 1.19+) sequentially consistent; наблюдённый эффект A ⇒ A happens-before B
Порядок между несинхронизированными горутинами Не определён

Что модель не обещает, и об этом легко забыть:

  • Порядок между горутинами без синхронизации не определён. Ни реальное время, ни «интуитивно очевидная» причинность роли не играют.
  • Go даёт лишь слабую защиту от совсем дикого поведения: чтение ячейки размером не больше машинного слова не «порвётся» (torn read) и не вернёт значение «из воздуха» (out-of-thin-air), оно увидит какое-то предшествующее или конкурентное записанное значение. Но это не делает racy-программу корректной — оно лишь ограничивает вид катастрофы. Значения крупнее машинного слова (например, интерфейсы, срезы) при гонке могут быть прочитаны «рваными».
  • -race не доказывает отсутствие гонок. Детектор находит только те гонки, которые реально произошли на исполненных путях кода при данном прогоне. Не выполнился путь — гонка не найдена. При этом ложных срабатываний он практически не даёт: если -race сообщил о гонке, гонка настоящая. Отсюда рабочая практика — гонять -race не только в юнит-тестах, но и под интеграционными сценариями и реалистичной нагрузкой.

Формально «программа без гонок данных» — это не удача, а проектное свойство: любое разделяемое между горутинами изменяемое состояние проходит через синхронизацию. Если это выдержано, рассуждать о видимости можно спокойно, в терминах happens-before, не заглядывая в детали компилятора и железа. Ровно это и имеет в виду совет из документа: не умничайте.

Классические ошибки

  1. «Флаг + busy-wait» без atomic/Once. for !done {} по обычному bool — гонка данных: возможна невидимость записи и незавершающийся цикл. Лечится sync.Once, atomic.Bool или каналом.
  2. Публикация объекта без синхронизации. g = t в одной горутине и чтение g в другой без happens-before: наблюдение g != nil не гарантирует видимость полей t. Нужен atomic.Pointer[T], мьютекс или канал.
  3. Ставка на завершение горутины. «Горутина отработала — значит, её запись видна» — неверно. Выход горутины не устанавливает happens-before; нужен WaitGroup/канал/join через синхронизацию.
  4. Предположение о порядке двух записей. «Увидел b, значит увижу и a, ведь a записана раньше» — переупорядочивание это ломает. Порядок наблюдаем только через синхронизацию.
  5. Буферизованный канал там, где нужна семантика небуферизованного. Переход с make(chan int) на make(chan int, 1) меняет happens-before (правило 3 перестаёт действовать) и может тихо сломать публикацию.
  6. Опора на неуспешный TryLock. Неуспешный TryLock/TryRLock не даёт синхронизации; строить на его результате happens-before нельзя.
  7. Смешение data race и race condition. «-race молчит — значит, корректно». Логическая гонка (проверил-потом-сделал под двумя Lock) детектором не ловится.
  8. -race только в CI на юнит-тестах. Непокрытый путь = ненайденная гонка. Нужны интеграционные прогоны и нагрузка под -race.

Checklist

  • Любое разделяемое изменяемое состояние доступно из нескольких горутин только через синхронизацию (канал, sync, sync/atomic).
  • Публикация значения между горутинами опирается на конкретное правило happens-before, а не на «оно уже точно записалось».
  • Ленивая инициализация — через sync.Once, а не ручной double-checked locking по обычному флагу.
  • Флаги-сигналы между горутинами — atomic.Bool/канал, не обычный bool.
  • Публикация целого объекта — atomic.Pointer[T], мьютекс или отправка по каналу.
  • Выбор буферизованный/небуферизованный канал сделан осознанно, с учётом семантики happens-before, а не только пропускной способности.
  • Ожидание завершения горутин — через WaitGroup/канал; на факт «горутина вышла» никто не полагается.
  • Критические секции покрывают решение целиком (нет проверки в одной секции и действия в другой) — защита от race condition, а не только от data race.
  • Тесты и хотя бы часть интеграционных/нагрузочных прогонов идут под -race.
  • Атомики используются как единственный способ доступа к своей ячейке: нет параллельного неатомарного чтения/записи той же переменной.

Демо и версии

  • Код: живой стенд digital-cookbook/go-concurrency/memory-model/ — починенные версии (безопасная публикация через atomic.Pointer, сигнал через atomic.Bool/канал, happens-before на каналах) под проходящими -race-тестами; сломанные примеры — в cmd/: broken-flag и publish-unsafe под go run -race . дают WARNING: DATA RACE, а race-condition-no-data-race детектор не ловит (гонки данных нет — каждая операция под замком) — он печатает нарушенный составной инвариант (двойное списание). Код в тексте — выжимки.
  • Версии: примеры требуют Go 1.19+ (типизированные атомики atomic.Bool, atomic.Pointer[T] и уточнённая sequentially consistent модель атомиков появились в Go 1.19); собирайте на текущем стабильном релизе.
  • Смежные статьи серии: горутины и каналы, пакет sync и атомики, паттерны конкурентности, отладка гонок и утечек.

Документация и первоисточники

Статья опирается строго на официальную спецификацию — все формулировки happens-before выше взяты из неё по смыслу:

  • The Go Memory Model — первоисточник: определение happens-before, правила для инициализации, оператора go, каналов, мьютексов, Once, атомиков и раздел про некорректную синхронизацию.
  • Go 1.19 Release Notes — обновление модели памяти (согласование с C/C++/Java/Rust/Swift, sequentially consistent атомики) и новые типизированные атомики в sync/atomic.
  • Data Race Detector — как работает -race, его накладные расходы и ограничение: находит только реально произошедшие гонки.
  • Russ Cox, Hardware Memory Models и Programming Language Memory Models — почему модели памяти вообще устроены так и откуда берётся переупорядочивание.

Обсуждение в Telegram

Присоединиться →

Комментарии