«Ну, изредка прочитается старое значение» — так обычно думают о гонках данных, и это опасное заблуждение. Модель памяти Go говорит жёстче: программа с гонкой данных некорректна — модель больше не гарантирует, что чтение увидит ожидаемое значение, а компилятор с процессором вправе переупорядочивать операции так, что интуиция ломается полностью. (Это не «неопределённое поведение» в стиле C++: Go, например, не допускает чтения «из воздуха» для значений размером с машинное слово — но полагаться на такие частные ограничения как на гарантию нельзя.) Понимание отношения happens-before — это то, что отличает «работает на моей машине» от кода, который действительно корректен под конкуренцией.
Это третья статья серии «Конкурентность в Go». Опирается на примитивы из второй: здесь — какие гарантии видимости они на самом деле дают. Сам официальный документ The Go Memory Model открывается неожиданным для спецификации советом: «Don’t be clever» — не умничайте. Если для понимания поведения программы приходится вчитываться в модель памяти, значит, синхронизации в коде слишком мало. Эта статья — как раз про то, где проходит граница между «достаточно» и «слишком мало».
В статье
- Что такое happens-before
- Что устанавливает happens-before
- Переупорядочивание: компилятор и процессор
- Data race и race condition — разные вещи
- Что модель гарантирует, а что нет
- Классические ошибки
- Checklist
- Демо и версии
- Документация и первоисточники
Что такое happens-before
Модель памяти отвечает на один вопрос: при каких условиях чтение переменной в одной горутине гарантированно видит значение, записанное в другой горутине? Ответ формулируется через частичный порядок на операциях с памятью — отношение happens-before («происходит раньше»).
Официальный документ определяет его так. Если событие e1 происходит раньше события e2, то говорят, что e2 происходит позже e1. Если e1 не происходит ни раньше, ни позже e2 — они происходят конкурентно (concurrently). Ключевые два правила порядка:
- Внутри одной горутины happens-before — это просто порядок исходного текста программы (program order). Инструкции выполняются в том порядке, в каком написаны (с точностью до переупорядочиваний, которые извне не наблюдаемы).
- Между разными горутинами happens-before возникает только через акты синхронизации: операции с каналами,
sync/sync/atomic. Без синхронизации две горутины не упорядочены между собой — их операции конкурентны.
Формальная гарантия видимости из документа выглядит так. Чтение r переменной x гарантированно наблюдает конкретную запись w в x, если выполнены оба условия:
wпроисходит раньшеr(whappens-beforer);- любая другая запись в
xпроисходит либо раньшеw, либо позжеr.
Первое условие обеспечивает, что w вообще видна для r. Второе — что никакая другая запись не «затирает» её конкурентно. Если хотя бы одно из условий не выполнено, гарантии нет: чтение вправе увидеть любое из конкурентных значений, включая устаревшее.
Отсюда главное практическое следствие: happens-before не возникает сам по себе от того, что «одна горутина выполнилась раньше по времени». Реальное время (wall clock) вообще не участвует в определении. Значение имеет только логический порядок, установленный синхронизацией. Две записи, разнесённые на миллисекунды по времени, но не связанные ни одним актом синхронизации, — конкурентны, и одна не обязана быть видна там, где читают другую.
(горутина 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
Это ядро статьи. Ниже — исчерпывающий по официальному документу список того, что создаёт 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, и модель памяти описывает их четырьмя правилами. Каждая отправка на конкретный канал сопоставлена соответствующему приёму из этого канала (обычно в другой горутине).
- Отправка в канал происходит раньше завершения соответствующего приёма.
- Закрытие канала происходит раньше приёма, который возвращает нулевое значение из-за того, что канал закрыт.
- Приём из небуферизованного канала происходит раньше завершения соответствующей отправки.
- Обобщение для буферизованных:
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 действует то же по существу: n-й Unlock 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, не заглядывая в детали компилятора и железа. Ровно это и имеет в виду совет из документа: не умничайте.
Классические ошибки
- «Флаг + busy-wait» без atomic/Once.
for !done {}по обычномуbool— гонка данных: возможна невидимость записи и незавершающийся цикл. Лечитсяsync.Once,atomic.Boolили каналом. - Публикация объекта без синхронизации.
g = tв одной горутине и чтениеgв другой без happens-before: наблюдениеg != nilне гарантирует видимость полейt. Нуженatomic.Pointer[T], мьютекс или канал. - Ставка на завершение горутины. «Горутина отработала — значит, её запись видна» — неверно. Выход горутины не устанавливает happens-before; нужен
WaitGroup/канал/joinчерез синхронизацию. - Предположение о порядке двух записей. «Увидел
b, значит увижу иa, ведьaзаписана раньше» — переупорядочивание это ломает. Порядок наблюдаем только через синхронизацию. - Буферизованный канал там, где нужна семантика небуферизованного. Переход с
make(chan int)наmake(chan int, 1)меняет happens-before (правило 3 перестаёт действовать) и может тихо сломать публикацию. - Опора на неуспешный
TryLock. НеуспешныйTryLock/TryRLockне даёт синхронизации; строить на его результате happens-before нельзя. - Смешение data race и race condition. «
-raceмолчит — значит, корректно». Логическая гонка (проверил-потом-сделал под двумяLock) детектором не ловится. -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 — почему модели памяти вообще устроены так и откуда берётся переупорядочивание.
Комментарии