Все главы учебника
Содержание учебника

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

13 мин чтенияКонтент v0.12.1

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

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

Правила, которые описывают, когда запись одной горутины видна другой, называются моделью памяти Go. В этой главе разберём, почему без синхронизации всё ломается, что такое отношение happens-before и как по нему проверять собственный код. В конце отделим два термина, которые постоянно путают: data race и race condition.

Перед чтением

Нужны запуск горутин и ожидание результата из предыдущей главы. Начните с одной связи: данные записаны → подан сигнал → после сигнала данные прочитаны. В списке правил ниже встретятся Mutex, Once и atomic; если они пока незнакомы, считайте список картой и вернитесь к нему после глав sync и atomic.

В примерах используется сигнальный канал chan struct{}. struct{} — структура без полей, она занимает ноль байт, поэтому такой канал передаёт только сам факт события. close(done) закрывает канал, а <-done ждёт закрытия: приём из закрытого пустого канала завершается сразу. Подробно закрытие разобрано в главе о каналах.

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

Код ниже выглядит разумно и при этом сломан:

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 == true и напечатать пустую строку, потому что запись done стала видна раньше, чем запись msg.

Между записью в одной горутине и чтением в другой нет ни одного действия синхронизации, и поэтому каждый уровень системы вправе оптимизировать по-своему.

Компилятор видит, что в теле for !done {} переменная done не меняется, и вправе прочитать её один раз в регистр. Цикл превращается в бесконечный, и никакая запись из другой горутины его не остановит.

Процессор выполняет инструкции не строго по порядку и складывает записи в буфер записи, чтобы не ждать память. На x86 записи из буфера уходят в память в исходном порядке, на ARM (Apple Silicon, серверы Graviton) порядок может меняться. Сами кэши ядер согласуются аппаратно, но буферы и переупорядочивание дают тот же эффект: другое ядро видит записи позже и не в том порядке.

Для одной горутины эти оптимизации незаметны: её собственный результат не меняется. Проблемы появляются там, где на эти записи без синхронизации смотрит другая горутина. Отсутствие синхронизации означает не «увидит чуть позже», а «нет никаких гарантий».

Happens-before

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

Если событие A happens-before события B, то все записи, сделанные до A, видны в B.

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

Между горутинами порядок «по часам» ничего не даёт. То, что строка в горутине 1 выполнилась на наносекунду раньше строки в горутине 2, не значит, что запись видна. Happens-before между горутинами создают только определённые действия синхронизации:

  • Оператор go happens-before начала выполнения запущенной горутины. Всё, что записано до go f(), видно внутри f.
  • Завершение горутины само по себе ничего не синхронизирует. Чтобы увидеть её записи, нужен сигнал после записи: канал, WaitGroup, мьютекс.
  • Отправка в канал happens-before завершения соответствующего приёма.
  • Закрытие канала happens-before приёма, который вернул нулевое значение из-за закрытия.
  • Для небуферизованного канала приём happens-before завершения соответствующей отправки. Поэтому отправитель после ch <- v знает, что получатель уже взял значение.
  • Для канала с буфером C k-й приём happens-before завершения (k+C)-й отправки. На этом правиле держится семафор на буферизованном канале из главы «Паттерны».
  • mu.Unlock() happens-before возврата из следующего mu.Lock() того же мьютекса.
  • Если атомарная операция B увидела результат атомарной операции A, то A happens-before B.
  • 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) по порядку строк одной горутины. (2) happens-before (3) по правилу закрытия канала. (3) раньше (4) по порядку строк главной горутины. По транзитивности запись msg happens-before её чтения, поэтому программа печатает hello при любом планировании, а не «обычно».

Канал здесь не передаёт msg. Он передаёт пустой сигнал, а видимость msg следует из цепочки happens-before.

То же самое можно построить на мьютексе. Пример намеренно необычный: Unlock делает не та горутина, что вызвала Lock. Спецификация это разрешает, и так проще увидеть правило в чистом виде:

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

Unlock в горутине happens-before возврата второго Lock в main, поэтому записи ready и msg видны. В рабочем коде пара Lock/Unlock почти всегда живёт в одной горутине и защищает участок кода; копировать этот пример как шаблон не нужно.

WaitGroup: синхронизация, которую не замечают

Чаще всего happens-before возникает при ожидании через sync.WaitGroup. Всё, что горутина записала до 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()
 
	fmt.Println(results)
}
[0 1 4 9]

Работают две вещи. Wait даёт happens-before, поэтому записи видны. И горутины пишут в разные элементы среза, поэтому одна ячейка памяти не используется двумя горутинами одновременно. Если бы все четыре писали в одну переменную, это была бы гонка, несмотря на Wait в конце: Wait упорядочивает записи относительно чтения в main, но не упорядочивает их между собой.

Атомарные операции

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

package main
 
import (
	"fmt"
	"sync"
	"sync/atomic"
)
 
func main() {
	var counter atomic.Int64
	var wg sync.WaitGroup
 
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Add(1) // неделимое увеличение
		}()
	}
	wg.Wait()
 
	fmt.Println(counter.Load())
}
100

С обычным int и counter++ этот цикл содержал бы гонку: counter++ — три шага (прочитать, прибавить, записать), и шаги разных горутин перемешиваются.

Data race и race condition

Эти два термина звучат похоже и означают разное.

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

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

Классический пример — проверка и действие, разнесённые во времени (TOCTOU, time-of-check to time-of-use):

// Exists и Create внутри защищены мьютексом — гонки данных нет.
if !store.Exists(key) {   // проверка
    store.Create(key)     // действие
}
// Между двумя вызовами другая горутина могла создать key.

Обе функции могут быть полностью синхронизированы, и race detector промолчит. Но неделимыми должны быть не отдельные вызовы, а весь участок «проверил → создал». Его защищают одним мьютексом на весь участок, транзакцией или одной операцией вида «создать, если нет» (CAS, LoadOrStore).

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

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

«В Go есть volatile». Нет. Ключевого слова «всегда читать из памяти» в Go нет. Запись становится видимой другой горутине только через синхронизацию: канал, мьютекс, atomic. Обычная переменная bool в роли флага между горутинами — гонка данных.

«int читается за одну инструкцию, синхронизация не нужна». Размер не спасает. Компилятор может держать значение в регистре, как в примере с for !done, и чтение никогда не увидит новую запись.

«На моём ноутбуке работает». x86 упорядочивает записи строже многих архитектур, а на ARM модель слабее. Но даже на x86 компилятор вправе переставлять и выбрасывать обращения. Код с гонкой, который «работает», сломается на другой машине, с другой версией компилятора или под другой нагрузкой.

«-race прошёл, значит, всё корректно». Детектор проверяет только гонки данных и только выполненные пути. TOCTOU он не видит.

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

Удобно представлять, что у каждой горутины своя копия памяти, которая сверяется с общей только в точках синхронизации. Железо устроено не так, но для рассуждений модель даёт правильный ответ: запись видна другой горутине, только если между ними протянута цепочка happens-before. Нет цепочки — нет гарантии, как бы очевидно ни выглядел код.

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

Попробуйте сами

  1. В исправленном примере с msg и close(done) выпишите цепочку от записи msg до Println(msg) и подпишите, какое правило связывает каждую пару соседних шагов.
  2. Две горутины делают counter++ над общим int, а главная ждёт их через WaitGroup и печатает результат. Исправляет ли WaitGroup гонку?
  3. Горутина записывает x = 1 и завершается. main ничего не ждёт, но через секунду (time.Sleep) читает x. Есть ли тут happens-before?

Проверка

  1. Запись msg → close(done): порядок строк одной горутины. close(done) → приём <-done: правило закрытия канала. Приём → Println(msg): порядок строк главной горутины. Транзитивность связывает запись с чтением.
  2. Нет. Wait делает итог видимым после ожидания, но два counter++ друг относительно друга не упорядочены: обе горутины могут прочитать одно и то же старое значение. Нужен общий мьютекс вокруг обоих увеличений, атомарное увеличение или подсчёт в одной горутине-владельце.
  3. Нет. Sleep не входит в список действий синхронизации, а завершение горутины ничего не синхронизирует. Это гонка данных, даже если на практике чтение почти всегда увидит 1.

Что дальше

Модель памяти объясняет, почему работают все остальные инструменты книги. Следующая глава, «Каналы», разбирает самый частый из них. На этой главе стоит блок 2 задач (задачи 05–08: выбор между мьютексом и atomic) и блок 6 (задачи 21–22: найти гонку в чужом коде и объяснить её через happens-before).

Продолжить

Остались вопросы?