Модуль: Runtime и память · Уровень: Senior+

TL;DR#

Go memory model определяет, при каких условиях чтение переменной в одной горутине гарантированно видит значение, записанное в другой. Основа — отношение happens-before: если запись happens-before чтения (и между ними нет других записей в эту переменную), чтение видит эту запись. Гарантии дают только примитивы синхронизации (channels, sync.Mutex, sync.Once, sync/atomic, WaitGroup). Программа без data race ведёт себя как sequential consistency; программа с гонкой имеет неопределённое поведение, и компилятор/CPU вправе переупорядочивать операции. С Go 1.19 модель официально переписана и формализует sync/atomic как sequentially consistent.

Теория#

Что вообще гарантирует модель памяти#

Цитата из официального документа (go.dev/ref/mem, ревизия 2022):

«The Go memory model specifies the conditions under which reads of a variable in one goroutine can be guaranteed to observe values produced by writes to the same variable in a different goroutine.»

И ключевое прагматичное правило оттуда же — The Advice:

«Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access. To serialize access, protect the data with channel operations or other synchronization primitives such as those in the sync and sync/atomic packages. If you must read the rest of this document to understand the behavior of your program, you are being too clever. Don’t be clever.»

Иными словами: без синхронизации нет гарантий видимости. Внутри одной горутины код исполняется так, как написан (single-goroutine sequential consistency), но между горутинами порядок не определён, пока не установлено happens-before.

Memory operations и data race (формализация 1.19)#

В новой редакции модель оперирует понятием memory operation, у которой есть: тип (read/write/sync), адрес, прочитанное/записанное значение. Программа описывается множеством исполнений; корректное исполнение требует, чтобы каждое чтение «видело» допустимую запись.

Определение data race (официальное):

«A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as defined by the sync/atomic package.»

Две операции конкурентны, если ни одна не happens-before другой. Если есть гонка на не-atomic переменной — поведение программы по модели не определено (с практической оговоркой ниже про «no out-of-thin-air», memory safety и т.п.).

Happens-before: формальное отношение#

Happens-before — это частичный порядок на memory operations. Базовые правила:

  1. Program order (within a goroutine): если операция A текстуально предшествует B в одной горутине, то для целей этой горутины A happens-before B. (Компилятор/CPU могут переупорядочивать, но только пока это не наблюдаемо в рамках одной горутины.)
  2. Транзитивность: A hb B и B hb C ⇒ A hb C.
  3. Synchronization edges: примитивы синхронизации создают рёбра happens-before между горутинами (см. ниже channels/mutex/once/atomic).

Главная теорема видимости:

Чтение r переменной v видит запись w в v, если: (1) w happens-before r, и (2) нет другой записи w' в v такой, что w happens-before w' happens-before r.

Если же r и w конкурентны (нет hb ни в одну сторону) — r может увидеть, а может и не увидеть w; это data race.

Channels — правила happens-before#

Каналы — основной идиоматичный механизм синхронизации в Go. Правила из модели:

1. A send on a channel happens-before the corresponding receive completes.
2. The closing of a channel happens-before a receive that returns a zero value
   because the channel is closed.
3. (Unbuffered) A receive from an unbuffered channel happens-before the send
   on that channel completes.
4. (Buffered, capacity C) The kth receive on a channel with capacity C
   happens-before the (k+C)th send completes.

Правило 1 — основа передачи данных: всё, что горутина-отправитель записала до send, видно получателю после receive.

var data int            // обычная (не atomic) переменная
done := make(chan struct{})

go func() {
    data = 42           // (1) запись
    close(done)         // (2) close happens-after (1) в program order
}()

<-done                  // (3) receive zero value — happens-after close (правило 2)
fmt.Println(data)       // гарантированно 42: (1) hb (2) hb (3) hb (этот read)

Правило 3 (unbuffered) часто удивляет: на небуферизованном канале receive happens-before завершения send. Это позволяет использовать unbuffered-канал как «рукопожатие» в обе стороны.

c := make(chan int)     // unbuffered
var a string

go func() {
    a = "hello"         // запись
    c <- 0              // send; завершится только ПОСЛЕ того как receive начался
}()

<-c                     // receive happens-before завершения send выше
print(a)                // гарантированно "hello"

Тонкая ошибка из самой спецификации: если канал буферизованный, такая гарантия в обратную сторону НЕ держится, и аналогичный код может вывести пустую строку. Различие unbuffered/buffered здесь принципиально.

Mutex (sync.Mutex / sync.RWMutex)#

For any sync.Mutex/RWMutex variable l and n < m:
  the n-th call to l.Unlock() happens-before the m-th call to l.Lock() returns.

То есть всё, что сделано в одной критической секции до Unlock, видно следующему держателю замка после Lock.

var mu sync.Mutex
var balance int

func deposit(n int) {
    mu.Lock()
    balance += n        // защищено: видимость и атомарность секции
    mu.Unlock()
}

Для RWMutex: вызов RUnlock происходит после соответствующего RLock, и Unlock пишущего happens-before последующих RLock/Lock. TryLock/TryRLock создают рёбра только при успехе.

sync.Once#

The completion of once.Do(f) (i.e. the return of f) happens-before
the return of any call of once.Do(f).

Do гарантирует, что f выполнится ровно один раз, и все горутины, вызвавшие Do, после возврата увидят результаты f. Это правильный примитив для ленивой инициализации (в отличие от ручного double-checked locking, см. gotchas).

var once sync.Once
var conn *Conn

func Get() *Conn {
    once.Do(func() { conn = dial() })   // dial() ровно один раз
    return conn                          // гарантированно видим полностью инициализированный conn
}

sync/atomic — sequential consistency (формализовано в 1.19)#

До Go 1.19 модель формально молчала о порядке памяти atomic-операций (хотя на практике они были SC). С 1.19 это закреплено. Цитата:

«The APIs in the sync/atomic package are collectively “atomic operations” that can be used to synchronize the execution of different goroutines. If the effect of an atomic operation A is observed by atomic operation B, then A is “synchronized before” B. All the atomic operations executed in a program behave as though executed in some sequentially consistent order.»

То есть atomic-операции образуют единый глобальный SC-порядок, и «synchronized before» порождает happens-before. Это даёт самые сильные гарантии (sequential consistency), без отдельных acquire/release/relaxed режимов как в C++.

С Go 1.19 появились типизированные атомики, которые предпочтительнее «свободных» функций atomic.AddInt64 и т.д.:

import "sync/atomic"

var ready atomic.Bool      // типобезопасно, нельзя случайно прочитать не-атомарно
var counter atomic.Int64
var cfg atomic.Pointer[Config]

func publish(c *Config) {
    cfg.Store(c)           // SC store
    ready.Store(true)
}

func consume() {
    if ready.Load() {              // SC load
        use(cfg.Load())           // happens-before гарантирует видимость *Config целиком
    }
}

Преимущество типов над функциями: значение нельзя случайно прочитать/записать неатомарно (нет доступа к «сырому» полю), нет проблемы выравнивания 64-битных значений на 32-битных платформах (для функций atomic.AddInt64 требовалось ручное 8-байтное выравнивание первого слова структуры — типы это инкапсулируют).

WaitGroup#

A call to wg.Done() happens-before the return of any wg.Wait() call that it unblocks.

Всё, что горутина сделала до Done(), видно горутине, разблокированной Wait().

var wg sync.WaitGroup
results := make([]int, n)

for i := 0; i < n; i++ {
    wg.Add(1)
    go func(i int) {
        defer wg.Done()
        results[i] = compute(i)   // запись в разные индексы — без гонки
    }(i)
}
wg.Wait()                          // happens-after всех Done
useAll(results)                    // гарантированно видны все записи

Важно: wg.Add с положительным значением должен вызываться до старта горутины (или до соответствующего Wait), иначе гонка на самом счётчике.

Дополнительные рёбра happens-before#

  • Запуск горутины: оператор go f() (и вычисление его аргументов) happens-before начала выполнения f. Поэтому переданные аргументы видны новой горутине.
  • Завершение горутины НЕ создаёт happens-before автоматически — нельзя «дождаться» завершения горутины простым выходом из неё без синхронизации.
  • init-функции пакета: завершение всех init пакета happens-before старта main; импортируемый пакет инициализируется до импортирующего.
  • runtime.Gosched / time.Sleep НЕ дают гарантий видимости — это не синхронизация. Полагаться на sleep для упорядочивания памяти — ошибка.

Sequential consistency для программ без гонок (DRF-SC)#

Главная практическая гарантия модели — DRF-SC (Data-Race-Free ⇒ Sequentially Consistent):

Если программа свободна от data race, она исполняется так, как если бы все операции выполнялись в каком-то едином глобальном последовательном порядке, согласованном с program order каждой горутины.

Это значит: пишите код без гонок — и можете рассуждать о нём как о последовательной чередующейся (interleaved) программе. Все хитрые правила выше нужны лишь чтобы установить отсутствие гонок и нужную видимость; дальше работает интуитивная SC-семантика.

Оговорка из обновлённой модели: даже для программ с гонками Go даёт ограниченные гарантии «no out-of-thin-air values» и memory safety — race не приводит к чтению произвольного «выдуманного» значения и не ломает безопасность типов так, как UB в C++. Но конкретное наблюдаемое значение при гонке не определено.

Подводные камни / gotchas#

1. Публикация объекта через неатомарную переменную#

// ОШИБКА: data race, нет happens-before между записью cfg и его чтением
var cfg *Config       // обычный указатель

func writer() { cfg = loadConfig() }      // запись
func reader() { use(cfg) }                // конкурентное чтение — ГОНКА

Даже если «указатель пишется атомарно на этой архитектуре» — модель этого не гарантирует, и нет ребра happens-before, упорядочивающего инициализацию *Config относительно чтения. Читатель может увидеть указатель, но не увидеть записи в поля структуры. Фикс — atomic.Pointer[Config] или мьютекс.

2. Double-checked locking без atomic#

// ОШИБКА: классический сломанный DCL
var instance *Service
var mu sync.Mutex

func Get() *Service {
    if instance == nil {            // (A) чтение без синхронизации — ГОНКА с (B)
        mu.Lock()
        if instance == nil {
            instance = newService() // (B) запись под мьютексом
        }
        mu.Unlock()
    }
    return instance
}

Проверка (A) вне замка конкурентна записи (B) — data race. Хуже того, читатель может увидеть ненулевой instance, но частично сконструированный объект (поля ещё не видны). Правильно — sync.Once, либо atomic.Pointer:

var instance atomic.Pointer[Service]
var mu sync.Mutex

func Get() *Service {
    if s := instance.Load(); s != nil {   // atomic load — корректно
        return s
    }
    mu.Lock()
    defer mu.Unlock()
    if s := instance.Load(); s != nil {
        return s
    }
    s := newService()
    instance.Store(s)                      // atomic store — публикация
    return s
}

(В идиоматичном Go просто используйте sync.Once.)

3. Конкурентный доступ к map — гонка и паника#

m := map[string]int{}
go func() { m["a"] = 1 }()   // запись
go func() { _ = m["a"] }()   // конкурентное чтение/запись

Карты в Go не потокобезопасны. Конкурентная запись (или запись+чтение) — data race, и рантайм имеет встроенный детектор concurrent map writes, который аварийно завершает программу (fatal error: concurrent map writes) — это не recoverable panic. Фикс: sync.Mutex/sync.RWMutex вокруг карты или sync.Map для подходящих паттернов.

4. Опора на time.Sleep / порядок печати как на синхронизацию#

go func() { data = 1 }()
time.Sleep(time.Millisecond)   // НЕ гарантирует видимость data
println(data)                  // всё ещё гонка

Sleep не создаёт happens-before. Гонка остаётся гонкой, даже если «на практике обычно работает».

5. Цикл переменной в горутине (до Go 1.22)#

for _, v := range items {
    go func() { use(v) }()   // до Go 1.22: все горутины делят одну v — гонка + неверные данные
}

До Go 1.22 переменная цикла переиспользовалась — классическая ловушка (захват по ссылке + гонка). С Go 1.22 переменная цикла на каждой итерации новая. Но для совместимости и явности часто всё равно передают параметром: go func(v T){...}(v).

6. Передача аргументов в go vs замыкание#

go f(x) копирует x в момент запуска (есть happens-before на вычисление аргументов). Захват по замыканию (go func(){ use(x) }()) читает x уже в новой горутине — если x потом меняется в исходной горутине без синхронизации, это гонка.

Вопросы на собеседовании#

В: Что именно гарантирует Go memory model, а что нет? О: Она задаёт условия, при которых чтение переменной в одной горутине гарантированно видит запись из другой — через отношение happens-before. Внутри одной горутины код «как написан» (single-goroutine SC). Между горутинами никаких гарантий видимости нет, пока вы явно не установили happens-before через примитив синхронизации (channel, mutex, Once, atomic, WaitGroup). Модель НЕ гарантирует ничего о порядке/видимости для конкурентных не-atomic доступов — это data race с неопределённым поведением (с оговорками про memory safety и no-out-of-thin-air в редакции 1.19).

В: Сформулируй отношение happens-before и теорему о видимости чтения. О: Happens-before — частичный порядок на memory operations, образованный program order внутри горутины, транзитивностью и synchronization edges от примитивов. Чтение r видит запись w, если w happens-before r и нет промежуточной записи w’ с w hb w’ hb r. Если r и w конкурентны (нет hb ни в одну сторону) — это гонка, и r может увидеть либо не увидеть w.

В: Какие happens-before-гарантии дают каналы? В чём разница буферизованного и небуферизованного? О: (1) send happens-before завершения соответствующего receive — так передаются данные. (2) close happens-before receive, который вернул zero из-за закрытия. (3) Для unbuffered: receive happens-before завершения send (двустороннее рукопожатие). (4) Для буферизованного capacity C: k-й receive happens-before завершения (k+C)-го send. Разница принципиальна: на unbuffered можно полагаться, что отправитель «дождался» получателя; на буферизованном такой обратной гарантии нет, аналогичный код может увидеть незаписанное значение.

В: Чем плох double-checked locking без atomic и как чинить? О: Проверка instance == nil вне замка конкурентна записи под замком — data race. Даже если читатель увидит ненулевой указатель, инициализация полей объекта может быть не видна (нет happens-before, упорядочивающего конструирование относительно чтения). Чинить: sync.Once (идиоматично) или atomic.Pointer[T] с atomic Load/Store, которые дают SC-порядок и корректную публикацию.

В: Почему публикация структуры через обычный указатель — гонка, даже если запись указателя «атомарна» на железе? О: Модель не обещает атомарности обычной записи указателя, и, что важнее, нет ребра happens-before между записью полей структуры и их чтением в другой горутине. Без синхронизации компилятор/CPU вправе переупорядочить инициализацию полей и публикацию указателя, и читатель увидит указатель раньше, чем содержимое. Нужен atomic.Pointer (SC) или мьютекс, чтобы установить happens-before и гарантировать видимость всей структуры.

В: Что нового в sync/atomic с Go 1.19? О: Два изменения. Формально: модель закрепила, что все atomic-операции ведут себя так, будто выполнены в едином sequentially consistent порядке, и «synchronized before» порождает happens-before (раньше спецификация это формально не описывала). Практически: появились типизированные атомики (atomic.Bool, atomic.Int64, atomic.Pointer[T] и т.д.), которые безопаснее функций — нельзя случайно обратиться к значению неатомарно и нет проблемы 8-байтного выравнивания на 32-битных платформах.

В: Что такое DRF-SC и почему это главное практическое следствие модели? О: Data-Race-Free implies Sequential Consistency: если в программе нет ни одной гонки, она исполняется так, будто все операции идут в едином глобальном порядке, согласованном с program order каждой горутины. Это позволяет рассуждать о корректной конкурентной программе как о простом чередовании последовательных шагов, не вникая в переупорядочивания компилятора/CPU. Весь смысл правил happens-before — обеспечить отсутствие гонок, после чего работает интуитивная SC-семантика.

В: Создаёт ли завершение горутины happens-before? А go f()? А time.Sleep? О: Запуск go f() создаёт happens-before: вычисление аргументов и сам оператор go happens-before начала f, поэтому аргументы видны новой горутине. Завершение горутины НЕ создаёт happens-before автоматически — чтобы безопасно увидеть её результаты, нужна явная синхронизация (channel/WaitGroup/mutex). time.Sleep, Gosched и порядок вывода никаких гарантий памяти не дают — это не примитивы синхронизации, и опираться на них для упорядочивания нельзя.

В: Почему конкурентная запись в map валит программу, а не просто даёт гонку? О: Карты не потокобезопасны, и конкурентная запись повредила бы внутреннюю структуру (бакеты, при росте — эвакуацию). Рантайм имеет дешёвый встроенный детектор (флаг hashWriting), который при обнаружении конкурентной записи вызывает fatal error «concurrent map writes» — это не recoverable panic, программа аварийно завершается, чтобы не продолжать с повреждённой памятью. Решение: мьютекс или sync.Map.

В: Как практически находить нарушения модели памяти? О: Запускать с race detector: go test -race, go run -race, go build -race. Это ThreadSanitizer-инструментализация, которая в рантайме строит happens-before-граф и ловит конкурентные не-синхронизированные доступы. Он находит только гонки, реально случившиеся в данном прогоне, поэтому важно гонять под реалистичной нагрузкой и в CI. Накладные расходы значительны (память/CPU), поэтому в проде обычно не включают.

На что копают на senior+#

  • Unbuffered vs buffered happens-before: умение объяснить правило «receive на unbuffered hb завершения send» и привести пример, где замена unbuffered на buffered ломает корректность.
  • Точная формулировка DRF-SC и оговорки редакции 2022/1.19: что даёт модель для программ С гонками (memory safety, no out-of-thin-air) и чего не даёт (определённого значения).
  • sync/atomic как SC, без acquire/release: понимание, что Go не предоставляет relaxed/acq-rel-режимов как C++/Java, все atomic — sequentially consistent, и почему это сознательный выбор (простота рассуждений ценой иногда лишних барьеров).
  • Корректная публикация: глубокое понимание, что atomic.Pointer.Store публикует не только указатель, но и (через happens-before) всё, что записано до Store, делая видимым содержимое структуры читателю после Load.
  • Почему sync.Once правильнее ручного DCL, и как Once гарантирует видимость результата f всем вызывающим (completion of f hb return of any Do).
  • Аргументы go vs захват замыканием и тонкость с переменной цикла до/после Go 1.22.
  • Ограничения race detector: он динамический (не статический), ловит только наблюдённые в прогоне гонки, отсюда — необходимость нагрузочного покрытия; понимание, что «прошло под -race» ≠ «гонок нет».
  • Цитирование источника: знание, что есть официальный документ go.dev/ref/mem, переписанный в 2022, и его центральный совет «Don’t be clever» — синхронизируйтесь явно, а не полагайтесь на тонкости.