GraphLMS

Go
0/32 решеноНачать
Глава 02 · Основы~13 мин чтения

Модель памяти и happens-before

О чём эта глава

Вы уже умеете запускать горутины и пересылать данные через каналы. Эта глава — про невидимый слой под всем этим: правила, по которым одна горутина видит (или не видит) то, что записала другая. Эти правила называют моделью памяти Go.

Звучит абстрактно, но вопрос предельно практичный. Вы пишете в переменную в одной горутине и читаете её в другой. Когда чтение гарантированно увидит свежее значение? Интуитивный ответ «ну, сразу же» — неверный. Правильный ответ: только если между записью и чтением протянута цепочка синхронизации. Без неё запись может быть невидима другой горутине сколь угодно долго, и это не «иногда глючит» — это формально неопределённое поведение.

Дальше разберём, почему так устроено, что такое happens-before и как им пользоваться, и почему data race и race condition — совершенно разные звери, хотя звучат похоже.

Почему «просто присвоить» недостаточно

Начнём с кода, который выглядит абсолютно нормально и при этом сломан:

var done bool
var msg string
 
go func() {
    msg = "hello"
    done = true        // сигнал «готово»
}()
 
for !done {            // ждём сигнала
}
fmt.Println(msg)

Кажется, что горутина запишет msg, потом done, а главная горутина увидит done == true и напечатает "hello". На практике этот код может:

  • крутиться в for !done вечно — главная горутина читает свою кэшированную копию done и не видит чужую запись;
  • увидеть done == true, но напечатать пустую строку — запись done «долетела», а запись msg ещё нет.

Почему? Между записью в одной горутине и чтением в другой нет ни одного акта синхронизации. А раз так, в дело вступают три силы, каждая из которых имеет полное право переставить или спрятать вашу запись:

  • Компилятор оптимизирует. Он видит, что внутри for !done {} тело done не меняется, и вправе прочитать done один раз в регистр, а потом крутить бесконечный цикл по регистру.
  • Процессор переупорядочивает инструкции и откладывает записи в буферах, чтобы не ждать медленную память.
  • Кэши ядер не обязаны мгновенно согласовываться между собой.

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

Запомните главное: отсутствие синхронизации означает не «работает чуть медленнее и не по порядку», а «никаких гарантий видимости вообще».

Happens-before: единственная гарантия, которая есть

Чтобы рассуждать строго, в модели памяти есть одно центральное отношение — happens-before («произошло-раньше»). Формулировка простая:

Если событие A happens-before события B, то все эффекты A (включая записи в память) гарантированно видны в момент B.

Внутри одной горутины happens-before — это просто порядок строк в программе: что написано выше, то и произошло раньше. Никаких сюрпризов.

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

  • Отправка в канал happens-before завершения соответствующего приёма из него.
  • Закрытие канала happens-before приёма нулевого значения, вызванного закрытием.
  • Для небуферизованного канала завершение приёма happens-before завершения соответствующей отправки.
  • mu.Unlock() happens-before следующего по времени mu.Lock() того же мьютекса.
  • Запись через sync/atomic happens-before чтения, которое её увидело.
  • wg.Done() happens-before возврата из wg.Wait().
  • Тело f в once.Do(f) happens-before возврата любого вызова once.Do.

И ключевое свойство — транзитивность: если A happens-before B, а B happens-before C, то A happens-before C. Именно это связывает «обычную запись» с актом синхронизации.

Перепишем сломанный пример правильно — через канал:

package main
 
import "fmt"
 
func main() {
    var msg string
    done := make(chan struct{})
 
    go func() {
        msg = "hello"  // (1) обычная запись
        close(done)    // (2) акт синхронизации
    }()
 
    <-done             // (3) приём из закрытого канала
    fmt.Println(msg)   // (4) гарантированно видим "hello"
}

Цепочка такая: внутри горутины (1) идёт до (2) по порядку программы. close в (2) happens-before приёма в (3) по правилу про закрытие канала. А (3) до (4) снова по порядку программы. Транзитивно: запись msg happens-before Println. Поэтому "hello" напечатается всегда, а не «обычно».

Обратите внимание: канал здесь не передаёт msg. Он передаёт пустую структуру. Его задача — построить happens-before, а уж видимость msg приезжает «прицепом» по транзитивности.

То же самое можно сделать мьютексом — Unlock в одной горутине happens-before Lock в другой:

package main
 
import (
    "fmt"
    "sync"
)
 
func main() {
    var mu sync.Mutex
    var ready bool
    var msg string
 
    mu.Lock()          // держим лок, пока готовим данные
    go func() {
        defer mu.Unlock()
        msg = "готово"
        ready = true
    }()
    // отдаём лок горутине: пробуем взять снова — дождёмся её Unlock
    mu.Lock()
    fmt.Println(ready, msg)
    mu.Unlock()
}

Unlock горутины happens-before нашего второго Lock, поэтому записи ready и msg видны корректно: вывод всегда true готово.

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

Самый частый способ синхронизации — WaitGroup

На практике happens-before чаще всего возникает там, где вы и не задумываетесь — при ожидании горутин через sync.WaitGroup. wg.Done() happens-before возврата из wg.Wait(), поэтому всё, что горутина записала до Done, видно после Wait:

package main
 
import (
    "fmt"
    "sync"
)
 
func main() {
    var wg sync.WaitGroup
    results := make([]int, 4)
 
    for i := 0; i < 4; i++ {
        wg.Add(1)
        go func(idx int) {
            defer wg.Done()
            results[idx] = idx * idx  // каждая горутина пишет в СВОЙ индекс
        }(i)
    }
    wg.Wait()                          // happens-after всех Done
 
    fmt.Println(results)               // [0 1 4 9] — все записи видны
}

Тут две вещи работают вместе. Во-первых, Wait даёт happens-before — записи видны. Во-вторых, горутины пишут в разные элементы слайса, поэтому конкурентных обращений к одной ячейке нет. Если бы они все писали в одну переменную без синхронизации — был бы data race, даже несмотря на Wait в конце.

Atomic как лёгкая синхронизация

Если нужно просто безопасно поделить счётчик или флаг, не обязательно тащить мьютекс — хватит sync/atomic. Атомарная запись happens-before атомарного чтения, которое её увидело, плюс операция неделима (её нельзя «разорвать»):

package main
 
import (
    "fmt"
    "sync"
    "sync/atomic"
)
 
func main() {
    var counter int64
    var wg sync.WaitGroup
 
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            atomic.AddInt64(&counter, 1)  // неделимый инкремент
        }()
    }
    wg.Wait()
 
    fmt.Println(atomic.LoadInt64(&counter))  // ровно 100
}

Тот же цикл с counter++ вместо atomic.AddInt64 был бы классическим data race: counter++ — это три операции (прочитать, прибавить, записать), и они переплетутся. Подробнее об этом — в главе про гонки и утечки.

Data race ≠ race condition

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

Data race — это про память. Две горутины обращаются к одной ячейке памяти конкурентно, хотя бы одна из них пишет, и между ними нет happens-before. С точки зрения спецификации это неопределённое поведение: программа вправе вернуть мусор, заклинить, упасть или (что хуже всего) «работать» на ваших тестах и взорваться в проде. Data race — это всегда баг, без исключений.

Race condition — это про логику. Результат программы зависит от того, в каком порядке успели выполниться события. Race condition может существовать и без единого data race — когда каждый отдельный доступ к памяти аккуратно синхронизирован, а неверна сама последовательность шагов.

Классический пример race condition без data race — TOCTOU (time-of-check to time-of-use):

// каждая функция внутри потокобезопасна — data race НЕТ
if !store.Exists(key) {   // проверка (time of check)
    store.Create(key)     // использование (time of use)
}
// но между двумя строками другая горутина успела создать key —
// и мы перезапишем/продублируем. Это race condition.

Здесь и Exists, и Create могут быть полностью синхронизированы внутри — race detector промолчит. Но логика всё равно сломана: атомарны отдельные операции, а атомарным должен быть весь участок «проверил → создал». Лечится это не атомиками на отдельные вызовы, а одним мьютексом на всю критическую секцию (или транзакцией, или CAS-логикой).

Запомните вывод:

Race detector находит только data race, но не race condition. Чистый прогон go test -race означает «нет гонок по памяти», но не означает «программа логически корректна».

Почему гонки нельзя показать «вживую» в браузере

Все запускаемые примеры выше детерминированы и корректны. А вот настоящий data race показать в этом тренажёре нельзя — и важно понимать, почему.

Интерактивные примеры исполняются интерпретатором в браузере однопоточно. Настоящего параллелизма нет, как нет и race-детектора. Поэтому код с гонкой здесь либо отработает «случайно правильно», либо просто не покажет проблему — что хуже обмана. Такие вещи мы даём статичным блоком и разбираем глазами.

Вот как выглядит «сломанный» счётчик, который нужно увидеть и распознать, а не запускать:

// СТАТИЧНЫЙ пример — НЕ запускается, демонстрирует data race.
// Запустите его локально с `go run -race` и увидите отчёт детектора.
var counter int
 
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        counter++   // ЧТЕНИЕ-ПРИБАВЛЕНИЕ-ЗАПИСЬ без синхронизации
    }()
}
wg.Wait()
fmt.Println(counter)  // почти наверняка НЕ 1000, и значение каждый раз разное

counter++ не атомарен: две горутины читают одно и то же старое значение, прибавляют по единице и записывают — один инкремент теряется. Плюс нет happens-before между записями. Лечится либо atomic.AddInt64 (см. запускаемый пример выше), либо мьютексом вокруг counter++. Проверять такое нужно локально командой go run -race или go test -race — детектор покажет точные строки конфликтующих доступов.

Частые ошибки

  • «В Go есть volatile / достаточно пометить переменную». Нет. В Go нет ключевого слова для «всегда читать из памяти». Единственный способ сделать запись видимой другой горутине — синхронизация: канал, мьютекс или sync/atomic. Голая bool-переменная-флаг между горутинами — это data race.

  • «Один int читается/пишется атомарно, ему синхронизация не нужна». Размер слова не спасает. Без синхронизации компилятор может закэшировать значение в регистре, а другая горутина никогда не увидит обновление. Это всё ещё data race.

  • «На моём ноутбуке (x86) работает». x86 действительно строже многих архитектур по упорядочиванию памяти, но компилятор всё равно оптимизирует обращения, а на ARM (телефоны, серверы Graviton, Apple Silicon) модель слабее. «Вроде работает» — не гарантия; на другой машине или после апдейта Go сломается.

  • «-race прошёл — значит всё корректно». -race ловит только data race. Логические race condition (как TOCTOU) он не видит. Зелёный прогон — необходимое, но не достаточное условие корректности.

Мысленная модель

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

Отсюда практическое правило на каждый день:

Разделяемые данные либо не шарьте — передавайте владение через канал («share memory by communicating»), — либо защищайте примитивом синхронизации (мьютекс, atomic). Третьего безопасного варианта нет.

Что дальше

Эта глава — фундамент под топиком 2 (задачи 5–8), где вы выбираете между мьютексом и атомиками и обязаны понимать happens-before каждого, и под топиком 6 (21–22), где гонки нужно распознавать и ловить детектором. Любое «решение без синхронизации, но вроде проходит» — это ловушка, которую вы теперь умеете видеть.