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

Вы запускаете горутину, она выставляет флаг done = true и пишет данные в result. Основная горутина крутится в цикле for !done {} и после выхода читает result. На вашей машине всё работает. В проде под нагрузкой цикл иногда не завершается вовсе, а иногда завершается, но result оказывается пустым. Это не баг планировщика и не «плавающая» ошибка железа — это ровно то поведение, которое модель памяти Go разрешает. Разбираем, какие межгорутинные гарантии видимости даёт язык и почему без синхронизации запись в одной горутине может быть невидима в другой сколь угодно долго.
Материал про модель памяти и порядок видимости, а не про то, какой примитив синхронизации быстрее. Ориентируюсь на спецификацию The Go Memory Model в редакции 2022 года (обновлённой под Go 1.19), актуальной для Go 1.21–1.22.
Что вообще обещает язык
Наивная модель в голове разработчика: «я записал переменную — значит, все её видят». В многопоточной программе это неверно. Между записью в память одной горутиной и чтением другой стоят три слоя, каждый из которых может переупорядочить или отложить операции:
- компилятор — переставляет независимые с его точки зрения инструкции при оптимизации;
- процессор — исполняет и завершает записи не в порядке исходного кода (out-of-order execution, store buffer);
- кэши ядер — запись, осевшая в кэше одного ядра, не обязана мгновенно стать видимой другому.
Модель памяти Go описывает единственную вещь: при каких условиях чтение переменной гарантированно наблюдает результат конкретной записи. Формулируется это через отношение happens-before.
Отношение happens-before
Ключевое правило спецификации звучит так: чтение r переменной v может наблюдать запись w, если выполнены оба условия — w не происходит после r (то есть не «happens-after»), и между w и r нет другой записи w', которая происходит после w. А чтобы гарантированно наблюдать именно w, нужно чтобы w происходило до r (happens-before) и никакая другая запись не вклинивалась между ними.
Happens-before — это не про физическое время. Две операции могут идти в реальном времени в любом порядке; отношение задаёт лишь то, что язык обязан гарантировать по видимости. Нет установленного happens-before — нет никакой гарантии, что вы увидите свежее значение.
Внутри одной горутины happens-before совпадает с порядком в исходном коде (с точностью до переупорядочиваний, не наблюдаемых этой же горутиной). Проблемы начинаются на стыке горутин: чтобы установить happens-before между ними, нужен явный примитив синхронизации.
Где рвётся видимость: разбор исходного примера
Вернёмся к коду из начала:
package main
// НЕПРАВИЛЬНО: между горутинами нет отношения happens-before
var done bool
var result string
func setup() {
result = "данные готовы"
done = true
}
func main() {
go setup()
for !done {
}
// здесь result может быть пустым, а цикл может не завершиться никогда
println(result)
}Здесь две независимые проблемы, и обе разрешены моделью памяти.
1. Цикл может крутиться вечно
Компилятор видит, что в теле for !done {} переменная done не меняется. Он вправе прочитать её один раз в регистр и дальше проверять регистр — в исходной горутине запись в done не установила happens-before с этим чтением, значит компилятор не обязан её перечитывать. Наблюдаемо это как зависший навсегда цикл.
2. result может быть пустым даже после выхода из цикла
Допустим, цикл всё же увидел done == true. Записи result = ... и done = true идут в исходном коде именно в этом порядке, но между двумя горутинами этот порядок ничем не закреплён. Компилятор или процессор вправе сделать видимой запись в done раньше записи в result. Читатель увидит поднятый флаг и пустую строку.
Что устанавливает happens-before
Спецификация перечисляет конкретные операции, создающие межгорутинное отношение happens-before. Основные:
| Механизм | Что гарантирует |
|---|---|
Запуск go f() | Всё, что горутина сделала до go, happens-before началом выполнения f |
| Отправка в канал | Отправка в канал happens-before завершением соответствующего приёма из него |
| Закрытие канала | Закрытие happens-before приёмом, который вернул нулевое значение из-за закрытия |
| Приём из небуферизованного канала | Приём happens-before завершением соответствующей отправки |
sync.Mutex.Unlock | n-й Unlock happens-before (n+1)-м Lock |
sync.Once.Do | Возврат из Do(f) happens-before возвратом любого другого Do(f) |
sync/atomic | Атомарные операции ведут себя как единый суммарный порядок; чтение наблюдает предшествующую запись |
Правильные версии примера
Через канал — момент приёма создаёт happens-before со всеми записями до отправки:
package main
var result string
func main() {
ready := make(chan struct{})
go func() {
result = "данные готовы"
close(ready) // все записи выше happens-before приёмом из ready
}()
<-ready // после этой строки result гарантированно виден
println(result)
}Через атомик, если хочется именно флаг без блокировок:
package main
import (
"sync/atomic"
)
var result string
var done atomic.Bool // Go 1.19+, безопасный тип вместо голого bool
func main() {
go func() {
result = "данные готовы"
done.Store(true) // запись result happens-before этой атомарной записи
}()
for !done.Load() {
}
// done.Load(), увидевший true, гарантирует видимость result
println(result)
}Пара atomic.Store / atomic.Load тут работает не потому, что «атомарность защищает переменную», а потому, что с Go 1.19 модель памяти явно оговаривает: атомарные операции образуют единый последовательный порядок, и обычные записи, предшествующие атомарной записи в той же горутине, становятся видны читателю, наблюдавшему эту атомарную запись.
Гонка данных — это не «редкая ошибка», а неопределённое поведение
Первый пример содержит data race: две горутины обращаются к одной переменной без синхронизации, и хотя бы одна пишет. В отличие от C++, где такое поведение целиком неопределено, спецификация Go даёт узкую гарантию: гонка не разрушит память процесса произвольно (для слов размером не больше машинного). Но значение переменной при гонке не определено — вы можете прочитать старое, новое или, для многословных типов вроде интерфейсов и слайсов, вообще несогласованную смесь битов.
Отсутствие падения в тестах не значит, что гонки нет. Race detector ловит фактически наблюдённую гонку в конкретном прогоне, а не доказывает её отсутствие в других.
Где это ломается в проде
- Разница архитектур. На x86 (сильная модель памяти) многие некорректные программы работают годами, потому что процессор сам почти не переупорядочивает записи. Тот же код на ARM64 (сервер на Graviton, Apple Silicon, мобильные сборки) начинает падать: слабая модель памяти делает переупорядочивания видимыми. Классический сценарий «на ноутбуке-x86 работает, на ARM-сервере — нет».
- Многословные значения. Гонка на
interface{}, слайсе или мапе опаснее гонки наint: там записывается несколько машинных слов, и читатель может застать половину. Итог — паника с невнятным типом или разыменование мусорного указателя. - Ленивая инициализация вручную. Двойная проверка через голый
bool-флаг безsync.Once— та же ошибка видимости. Используйтеsync.Once.
Как это проверять
- Запускайте тесты и часть нагрузочных прогонов с флагом
-race:go test -race ./...иgo run -race main.go. Детектор инструментирует обращения к памяти и сообщает о гонках, увиденных в этом прогоне. - Держите отдельный CI-этап с гонками на ARM64, если прод на ARM, — x86-прогон не покрывает слабую модель памяти.
- Правило проектирования: любую переменную, к которой обращаются больше одной горутины, либо оборачивайте в
sync.Mutex, либо передавайте через канал, либо делайте типом изsync/atomic. Голый общийboolили указатель без синхронизации — красный флаг на ревью.
Что смотреть дальше
Прочитайте первоисточник — The Go Memory Model на go.dev, он короткий и содержит формальные определения всех перечисленных гарантий. Затем сравните пакеты sync и sync/atomic под свой сценарий: для флага готовности достаточно atomic.Bool, для защиты составного состояния берите Mutex, для конвейеров — каналы. Инвалидация кэшей процессора и стоимость барьеров памяти — отдельная тема про производительность, здесь мы разбирали только корректность видимости.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: The Go Memory Model — go.dev, Package sync/atomic — Go documentation
Частые вопросы
У меня всё работает на локальной машине без всякой синхронизации — зачем что-то менять?
Race detector ничего не нашёл — значит, гонок нет?
Достаточно ли пометить переменную atomic, чтобы всё стало безопасно?
Меняется ли поведение между версиями Go?
Что делать, если под -race программа стала слишком медленной для нагрузочного теста?
Можно ли вообще обойтись без синхронизации, если одна горутина только пишет, а другая только читает?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.