Go memory model простыми словами: happens-before, гонки и sync
Разбираем модель памяти Go: happens-before, data races, гарантии каналов, Mutex, Once и атомики — с примерами кода и реальными вопросами с интервью.
- 1. Что такое модель памяти и зачем она нужна
- 1.1 Проблема видимости изменений
- 1.2 Переупорядочивание инструкций
- 2. Happens-before: основа всего
- 2.1 Определение и транзитивность
- 2.2 Гарантии внутри одной горутины
- 3. Гарантии каналов
- 3.1 Небуферизированный канал: отправка hb получения
- 3.2 Буферизированный канал: получение hb отправке
- 4. sync.Mutex и sync.RWMutex
- 4.1 Lock/Unlock как happens-before барьеры
- 4.2 Типичная ловушка: double-checked locking
- 5. sync.Once, sync.WaitGroup и sync/atomic
- 5.1 Once.Do гарантирует видимость
- 5.2 WaitGroup.Done hb Wait
- 5.3 Атомики: порядок без Mutex
- 6. Data races: как они возникают и как их поймать
- 6.1 Что такое data race
- 6.2 go test -race и go run -race
- 6.3 Классические примеры гонок
- 7. Паттерны безопасного кода
- 7.1 Горутина-собственник данных
- 7.2 Передача владения через канал
- 7.3 Иммутабельность после публикации
- 8. Что спрашивают на собеседовании
- Итоги
- FAQ
Большинство разработчиков знают, что в Go нужно синхронизировать доступ к разделяемым данным. Но мало кто может объяснить почему именно — что происходит без синхронизации, как компилятор переупорядочивает инструкции и почему два потока могут видеть изменения в разном порядке. Модель памяти Go даёт точный ответ: она описывает, при каких условиях одна горутина гарантированно видит изменения, сделанные другой.
Этот уровень знаний отличает middle от senior на Go-собеседованиях. Вопросы про happens-before, data race, sync.Once и sync/atomic — стандартная программа для позиций с конкурентным кодом. Если ты ещё не разобрался с горутинами и их планировщиком — начни с Goroutines в Go: как работают и что спрашивают на интервью, а потом загляни в Channels в Go: буферизированные и небуферизированные. В этой статье мы идём дальше: разбираем, почему горутин недостаточно — нужны правила, по которым память становится согласованной.
Пройдём путь от первых принципов — почему вообще нужна модель памяти — до конкретных гарантий каналов, Mutex, sync.Once и атомиков. В конце разберём реальные вопросы с интервью.
Тренируй memory model с AI-интервьюером
Реальные вопросы по happens-before, гонкам данных и sync — с разбором ответов в реальном времени.
1. Что такое модель памяти и зачем она нужна
1.1 Проблема видимости изменений
Наивная картина многопоточности выглядит так: есть общая память, несколько потоков читают и пишут в неё, всё видно всем и сразу. В реальности это не так. Между записью и чтением стоят несколько уровней: кэши процессора (L1/L2/L3), буферы записи, а ещё компилятор и процессор, оба из которых вправе переупорядочивать инструкции ради производительности.
Рассмотрим простой пример:
var ready bool
var data int
// горутина 1
data = 42
ready = true
// горутина 2
for !ready {
// ждём
}
fmt.Println(data) // что напечатает?
На первый взгляд ответ очевиден: 42. Но без синхронизации компилятор вправе переставить data = 42 и ready = true — оптимизация не меняет наблюдаемого поведения внутри одной горутины. А горутина 2 может прочитать ready == true, но увидеть старое значение data == 0. Это и есть проблема видимости.
1.2 Переупорядочивание инструкций
Переупорядочивание происходит на двух уровнях.
Компилятор переставляет инструкции при оптимизации. Если две операции не связаны зависимостью по данным, компилятор считает их независимыми и может выполнить в любом порядке. Он не знает, что другой поток наблюдает за промежуточным состоянием.
Процессор тоже переупорядочивает операции: out-of-order execution, store buffers, write combining. На x86 модель относительно строгая, на ARM — слабее. Это означает, что код, который случайно работал на x86 без синхронизации, может сломаться на ARM.
Модель памяти языка — это контракт между программистом и компилятором/рантаймом. Программист выражает намерения через примитивы синхронизации, компилятор гарантирует их семантику. Вне этого контракта поведение не определено.
2. Happens-before: основа всего
2.1 Определение и транзитивность
Happens-before (hb) — формальное отношение между событиями в программе. Событие A happens-before события B означает: все эффекты A видны B, и A выполнено до B. Если A и B не связаны отношением hb ни в одну сторону, они конкурентны — их относительный порядок не определён.
Отношение транзитивно: если A hb B и B hb C, то A hb C. Это ключевое свойство: цепочка синхронизации транслирует гарантии видимости через несколько горутин.
var x, y int
func setup() {
x = 1 // A
y = 2 // B
}
func main() {
setup() // C — вызов после A и B
fmt.Println(x, y) // D — видит результаты A и B через транзитивность
}
Здесь A hb C, B hb C, C hb D — поэтому D гарантированно видит x == 1 и y == 2.
2.2 Гарантии внутри одной горутины
Самая простая гарантия: внутри одной горутины все операции выполняются в том порядке, в котором они записаны в программе. Это называется sequenced-before и автоматически выполняется компилятором.
func f() {
a := 1 // A
b := a // B — гарантированно видит a == 1
_ = b
}
Это очевидно, но важно: sequenced-before является частным случаем happens-before. Всё happens-before в рамках одной горутины — бесплатно. Проблемы начинаются на границах между горутинами.
Запуск горутины создаёт hb-барьер: всё в горутине-родителе до go func() happens-before первой инструкции новой горутины. Но завершение горутины не создаёт автоматического hb обратно — для этого нужен явный примитив (канал, WaitGroup).
var x int
func main() {
x = 42
go func() {
// x == 42 гарантированно, потому что go-statement hb начало горутины
fmt.Println(x)
}()
// НО: нет гарантии, что горутина выполнится до завершения main
time.Sleep(time.Millisecond) // так делать не надо — это не синхронизация
}
Go для собеседований: 50 реальных вопросов
Горутины, каналы, ошибки, интерфейсы — с разборами и примерами кода.
3. Гарантии каналов
Каналы — наиболее богатый источник happens-before гарантий в Go. Их семантика прописана в спецификации явно.
3.1 Небуферизированный канал: отправка hb получения
Для небуферизированного канала: отправка N-го элемента happens-before получения N-го элемента. Это прямое следствие рандеву-семантики: отправитель блокируется до тех пор, пока получатель не забрал значение.
var message string
done := make(chan struct{}) // небуферизированный
go func() {
message = "hello from goroutine" // A
done <- struct{}{} // B: отправка hb получения
}()
<-done // C: получение — C hb D
fmt.Println(message) // D: гарантированно видит "hello from goroutine"
Цепочка: A hb B (sequenced), B hb C (канальная гарантия), C hb D (sequenced) → A hb D. Модель памяти позволяет это доказать формально.
Важный нюанс: закрытие канала тоже создаёт hb-барьер. Закрытие канала happens-before получения нулевого значения после закрытия. Поэтому паттерн с close(done) для сигнализации нескольким горутинам — корректен.
var shared int
done := make(chan struct{})
go func() {
shared = 99 // A
close(done) // B: close hb все последующие получения из канала
}()
<-done // C: получение нулевого значения из закрытого канала
fmt.Println(shared) // D: видит 99
3.2 Буферизированный канал: получение hb отправке
Для буферизированного канала ёмкостью C: получение N-го элемента happens-before отправки (N+C)-го элемента. Это менее интуитивная гарантия, но из неё следует популярный паттерн — семафор:
// Семафор: не более 3 горутин одновременно
sem := make(chan struct{}, 3)
for i := 0; i < 10; i++ {
sem <- struct{}{} // блокируется, когда буфер полон
go func(n int) {
defer func() { <-sem }()
work(n)
}(i)
}
Когда <-sem (получение N-го элемента) happens-before следующей sem <- (отправки (N+3)-го), то буфер не переполнится и инвариант «не более 3 активных горутин» соблюдается.
Ещё следствие: отправка в небуферизированный канал НЕ гарантирует, что код после отправки выполняется раньше кода получателя после получения. Гарантия направлена в сторону получателя: получатель знает, что отправитель завершил запись. Код после отправки и после получения конкурентны.
4. sync.Mutex и sync.RWMutex
4.1 Lock/Unlock как happens-before барьеры
sync.Mutex даёт две ключевые гарантии:
Unlock()happens-before следующегоLock()того же мьютекса.- Все операции внутри критической секции (между Lock и Unlock) видны следующей горутине, которая войдёт в критическую секцию.
var mu sync.Mutex
var counter int
func increment() {
mu.Lock() // A: Lock happens-after предыдущего Unlock
counter++ // B: видит все изменения counter из предыдущей секции
mu.Unlock() // C: Unlock hb следующего Lock
}
Это именно то, что нужно для защиты разделяемого состояния: каждая горутина, входящая в критическую секцию, видит актуальные данные.
sync.RWMutex расширяет гарантии для read-heavy нагрузки. Несколько RLock() могут держаться одновременно, но Lock() эксклюзивен. Гарантии: RUnlock() hb следующего Lock(), Unlock() hb следующего RLock() или Lock().
4.2 Типичная ловушка: double-checked locking
Паттерн double-checked locking в классическом виде небезопасен в языках без явного volatile/fence — и Go не исключение. Наивная реализация:
// НЕБЕЗОПАСНО — data race
var instance *Singleton
func GetInstance() *Singleton {
if instance == nil { // первая проверка без блокировки — гонка!
mu.Lock()
if instance == nil { // вторая проверка
instance = &Singleton{}
}
mu.Unlock()
}
return instance
}
Проблема: компилятор или процессор может частично инициализировать Singleton{} и записать указатель в instance до завершения инициализации полей. Другая горутина увидит ненулевой instance и начнёт работать с неинициализированной структурой.
Правильное решение — sync.Once (см. следующий раздел) или атомарный указатель:
// Безопасно через sync/atomic
var instance atomic.Pointer[Singleton]
func GetInstance() *Singleton {
if p := instance.Load(); p != nil {
return p
}
mu.Lock()
defer mu.Unlock()
if p := instance.Load(); p != nil {
return p
}
p := &Singleton{}
instance.Store(p)
return p
}
5. sync.Once, sync.WaitGroup и sync/atomic
5.1 Once.Do гарантирует видимость
sync.Once — самый чистый инструмент для однократной инициализации. Гарантия из спецификации: завершение первого вызова Once.Do(f) happens-before завершения любого другого вызова Once.Do (у которого та же Once).
var (
once sync.Once
instance *ExpensiveResource
)
func GetResource() *ExpensiveResource {
once.Do(func() {
instance = &ExpensiveResource{}
instance.init() // дорогая инициализация
})
return instance // все горутины видят полностью инициализированный instance
}
Это именно то решение двойной проверки, которое безопасно: Do блокируется до завершения инициализации, и hb-барьер гарантирует видимость для всех последующих вызовов Do.
5.2 WaitGroup.Done hb Wait
sync.WaitGroup даёт гарантию: каждый Done() happens-before возврата из Wait(). Это означает, что код после Wait() гарантированно видит всё, что делали горутины перед вызовом Done().
var wg sync.WaitGroup
results := make([]int, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
results[n] = compute(n) // A: запись
}(i)
}
wg.Wait() // B: ждём всех Done()
fmt.Println(results) // C: гарантированно видит все записи A
Важная деталь: Add() нужно вызывать до запуска горутины, а не внутри неё. Если горутина вызовет Add(1) и сразу Done(), Wait() может завершиться до этого Add().
5.3 Атомики: порядок без Mutex
Пакет sync/atomic предоставляет атомарные операции над базовыми типами и указателями. Начиная с Go 1.19, также есть типизированные generic-обёртки: atomic.Int64, atomic.Pointer[T] и другие.
Гарантия: атомарные операции в Go имеют последовательную согласованность (sequentially consistent ordering) — самую сильную модель. Это означает, что существует глобальный порядок всех атомарных операций, согласованный со всеми горутинами.
var counter atomic.Int64
// безопасно из любой горутины
counter.Add(1)
counter.Store(0)
v := counter.Load()
// CAS (compare-and-swap): атомарно проверить и обновить
old := int64(5)
new := int64(10)
swapped := counter.CompareAndSwap(old, new)
Когда использовать атомики, а когда Mutex? Атомики подходят для одиночной переменной с простыми операциями (счётчик, флаг, указатель). Mutex — для сложных инвариантов, охватывающих несколько переменных. Попытка координировать несколько атомиков без Mutex почти всегда приводит к гонкам.
// ОПАСНО: два отдельных атомика не атомарны вместе
var balance atomic.Int64
var transactions atomic.Int64
func deposit(amount int64) {
balance.Add(amount) // A
transactions.Add(1) // B: A и B не атомарны как пара!
}
Между A и B другая горутина может прочитать balance уже увеличенным, но transactions — ещё нет. Если инвариант «balance и transactions обновляются вместе» важен — нужен Mutex.
Go для собеседований: 50 реальных вопросов
Горутины, каналы, ошибки, интерфейсы — с разборами и примерами кода.
6. Data races: как они возникают и как их поймать
6.1 Что такое data race
Формальное определение: data race — ситуация, когда два конкурентных доступа к одной переменной происходят без happens-before связи между ними, и хотя бы один из доступов — запись.
При data race программа ведёт себя неопределённо. Это не «иногда получается некорректный результат» — это «компилятор и процессор могут делать что угодно». На практике это проявляется по-разному: иногда программа работает правильно случайно (ложная безопасность), иногда даёт неверные результаты, иногда падает в самых неожиданных местах.
// Data race: обе горутины обращаются к x без синхронизации
var x int
go func() { x = 1 }() // запись
go func() { x = 2 }() // запись (конкурентно с первой)
// Ещё хуже: чтение конкурентно с записью
go func() { x = 1 }()
fmt.Println(x) // чтение конкурентно с записью выше
6.2 go test -race и go run -race
Флаг -race подключает детектор гонок, построенный на ThreadSanitizer (TSan). Компилятор добавляет инструментацию к каждому доступу к памяти, рантайм отслеживает вектор-часы горутин. При обнаружении гонки выводится подробный отчёт.
go run -race main.go
go test -race ./...
go build -race -o app ./...
Пример вывода детектора:
==================
WARNING: DATA RACE
Write at 0x00c00001a0a8 by goroutine 7:
main.main.func1()
/tmp/main.go:12 +0x30
Previous write at 0x00c00001a0a8 by goroutine 6:
main.main.func2()
/tmp/main.go:9 +0x30
==================
Детектор даёт два стектрейса: какая горутина делает текущий конфликтный доступ, и какая делала предыдущий. Это сразу показывает, где проблема.
Важные ограничения: детектор работает динамически — находит только те гонки, которые реально произошли во время конкретного запуска. Если конкурентный путь не был пройден в тесте, гонка не обнаружится. Поэтому нужны тесты, покрывающие конкурентные сценарии. Замедление — 5–20× по CPU, память растёт в 5–10 раз. Для CI это нормально, для продакшена — нет.
6.3 Классические примеры гонок
Счётчик без синхронизации:
var count int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // data race: read-modify-write не атомарно
}()
}
wg.Wait()
fmt.Println(count) // не 1000 — и детектор об этом скажет
Операция count++ раскрывается в три шага: прочитать, прибавить 1, записать. Между шагами другая горутина может прочитать то же значение. Итог: несколько горутин сделали инкремент, но записали одинаковое значение.
Замыкание в цикле:
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // i — это переменная цикла, захвачена по ссылке
}()
}
// Все горутины видят одно и то же i — и, скорее всего, конечное значение 5
Это не data race в строгом смысле (нет записи из горутины), но классическая ошибка видимости. Исправление — передавать i аргументом: go func(n int) { fmt.Println(n) }(i).
Инициализация без Once:
var cache map[string]int
func getCache(key string) int {
if cache == nil {
cache = make(map[string]int) // data race: несколько горутин могут инициализировать
}
return cache[key]
}
Карты не потокобезопасны — конкурентные чтение и запись в map вызывают панику в рантайме Go (с детектором гонок или без него). Используй sync.Map или защищай RWMutex.
7. Паттерны безопасного кода
7.1 Горутина-собственник данных
Самый надёжный паттерн: данные принадлежат ровно одной горутине, которая является единственным читателем и писателем. Все остальные горутины взаимодействуют через каналы.
type counter struct {
inc chan struct{}
get chan chan int
quit chan struct{}
count int
}
func newCounter() *counter {
c := &counter{
inc: make(chan struct{}, 1),
get: make(chan chan int),
quit: make(chan struct{}),
}
go c.run()
return c
}
func (c *counter) run() {
for {
select {
case <-c.inc:
c.count++
case reply := <-c.get:
reply <- c.count
case <-c.quit:
return
}
}
}
func (c *counter) Increment() { c.inc <- struct{}{} }
func (c *counter) Get() int {
reply := make(chan int)
c.get <- reply
return <-reply
}
Здесь count — приватное поле, доступное только из горутины run(). Никаких мьютексов, никаких гонок.
7.2 Передача владения через канал
Если горутине нужно передать данные другой горутине — передавай владение, а не ссылку. После отправки отправитель не должен обращаться к данным.
func producer(out chan<- []byte) {
for {
buf := make([]byte, 1024)
// заполняем buf
out <- buf // передаём владение: после этого producer не трогает buf
}
}
func consumer(in <-chan []byte) {
for buf := range in {
process(buf) // consumer единственный владелец buf
}
}
Это соглашение, а не языковая гарантия — Go не отслеживает владение как Rust. Но если следовать паттерну дисциплинированно, гонок не будет.
7.3 Иммутабельность после публикации
Если данные после публикации никогда не изменяются, их можно безопасно читать из любого числа горутин — без синхронизации. Ключевое слово: после. Нужна синхронизация при публикации.
type Config struct {
Host string
Port int
Timeout time.Duration
}
var globalConfig atomic.Pointer[Config]
func init() {
cfg := &Config{Host: "localhost", Port: 8080, Timeout: 30 * time.Second}
globalConfig.Store(cfg) // публикуем через атомик
}
func GetConfig() *Config {
return globalConfig.Load() // все поля Config иммутабельны после Store
}
После Store() конфиг не меняется, поэтому конкурентное чтение через GetConfig() безопасно. Если нужно обновить конфиг — создаём новый объект и атомарно публикуем указатель. Никакой синхронизации при чтении не нужно.
8. Что спрашивают на собеседовании
Вопрос: Объясни, что такое happens-before. Почему без него нельзя рассуждать о конкурентности?
Happens-before — формальное отношение между событиями: если A hb B, то B гарантированно видит все эффекты A. Без этого отношения говорить о правильности конкурентной программы невозможно: компилятор и процессор переупорядочивают инструкции, кэши CPU могут не обновляться немедленно. «Программа работает правильно» без hb-гарантий — случайность, а не инвариант. Модель памяти Go явно перечисляет, какие конструкции создают hb-отношения.
Вопрос: Есть два поля структуры. Достаточно ли двух атомиков, чтобы обновлять их конкурентно безопасно?
Зависит от того, есть ли инвариант между полями. Если поля независимы — да, каждый атомик защищает своё поле. Но если есть инвариант «оба поля всегда согласованы» — нет. Атомарность каждой операции отдельно не даёт атомарности двух операций вместе. Читатель может увидеть первое поле уже обновлённым, а второе — ещё нет. Для нескольких связанных полей нужен Mutex или иная форма эксклюзивного доступа.
Вопрос: Почему sync.Once безопаснее double-checked locking?
Double-checked locking в наивном виде страдает от того, что инициализация объекта и запись указателя — не атомарны. Процессор может записать указатель до завершения инициализации полей. sync.Once внутри использует атомарный флаг и Mutex: Do(f) блокирует остальных вызывающих до завершения f, а затем выставляет флаг через атомарную операцию, которая создаёт hb-барьер. Все последующие Do видят результат f полностью.
Вопрос: Горутина пишет в переменную, другая горутина читает — есть ли data race?
Если между записью и чтением нет happens-before связи — да, это data race. Независимо от того, успевает запись завершиться «физически» до чтения. Модель памяти не оперирует физическим временем — только hb-отношениями. Исправление: синхронизировать через канал, Mutex или атомарные операции.
Вопрос: Что случится, если запустить -race и он ничего не нашёл — значит ли это, что гонок нет?
Нет. Детектор гонок работает динамически: он видит только те пути исполнения, которые реально были пройдены во время запуска. Если тест не покрывает конкурентный сценарий — детектор о нём не узнает. Это аргумент в пользу написания конкурентных тестов со стресс-нагрузкой и большим числом итераций. Статические инструменты (анализаторы, формальная верификация) дополняют детектор, но не заменяют его.
Вопрос: Чем sync.Map отличается от map с RWMutex?
sync.Map оптимизирован для двух сценариев: ключи записываются один раз, а потом часто читаются; или несколько горутин работают с непересекающимися наборами ключей. Внутри он использует два хранилища: read-only map (читается без блокировки через атомарный load) и dirty map (защищена Mutex). map с RWMutex проще рассуждать и легче отлаживать. Для общего случая — RWMutex с обычной картой часто быстрее и понятнее. sync.Map имеет смысл, когда профилирование показывает контенцию на мьютексе.
Итоги
Модель памяти Go — это не абстрактная теория, а практический инструмент. Happens-before отношение определяет, когда одна горутина видит изменения другой. Без явного hb-барьера компилятор и процессор вправе делать что угодно — и data race — это именно то, что происходит.
Ключевые hb-гарантии:
- Запуск горутины через
go— код доgohb первой инструкции новой горутины. - Небуферизированный канал — отправка hb получения.
- Буферизированный канал ёмкостью C — получение N-го hb отправки (N+C)-го.
- Закрытие канала hb получения нулевого значения.
Mutex.Unlock()hb следующегоLock().Once.Do— первый вызов hb всех последующих.WaitGroup.Done()hb возврата изWait().- Атомарные операции — последовательно согласованы.
Для углубления в тему: Goroutines в Go: как работают и что спрашивают на интервью покрывает планировщик и стек, Channels в Go: буферизированные и небуферизированные — детали работы каналов и select.
Проверь знания по memory model на реальных вопросах
AI-интервьюер задаст вопросы по happens-before, data races и sync — и разберёт каждый ответ.
Go-вопросы в Telegram
Ежедневные разборы горутин, каналов и паттернов.
FAQ
Автор
Lexicon Team
Читайте также
backend
Context в Go: отмена, дедлайны и best practices
Полный разбор context.Context в Go: WithCancel, WithTimeout, WithDeadline, WithValue, дерево контекстов, propagation отмены, best practices и антипаттерны — с примерами и вопросами с интервью.
backend
Go: 50 вопросов на собеседовании junior–middle
Разбор 50 реальных вопросов по Go для junior и middle: горутины, каналы, обработка ошибок, интерфейсы, управление памятью и структура проекта. Примеры кода и объяснения.
backend
Goroutines в Go: как работают и что спрашивают на интервью
Глубокий разбор горутин в Go: планировщик M:N, стек, каналы, sync-примитивы, паттерны конкурентности, утечки горутин и context — с примерами кода и реальными вопросами с интервью.