---
title: "Модель памяти и happens-before"
description: "Одна горутина записала значение в переменную, другая его читает. Когда вторая гарантированно увидит новое значение? Интуитивный ответ «сразу же» неверен. Правильный ответ: только…"
canonical: https://graphlms.ru/go/book/memory-model
course: "Конкурентность Go"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

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

> Источник: https://graphlms.ru/go/book/memory-model — курс «Конкурентность Go», GraphLMS.

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

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

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

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

Нужны запуск горутин и ожидание результата из [предыдущей главы](/go/book/goroutines).
Начните с одной связи: данные записаны → подан сигнал → после сигнала данные прочитаны.
В списке правил ниже встретятся `Mutex`, `Once` и atomic; если они пока незнакомы,
считайте список картой и вернитесь к нему после глав [`sync`](/go/book/sync-primitives)
и [atomic](/go/book/atomic).

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

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

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

```go
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)-й отправки. На этом
  правиле держится семафор на буферизованном канале из главы
  [«Паттерны»](/go/book/patterns).
- `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. Через неё обычная запись «цепляется» к
действию синхронизации.

Исправим сломанный пример каналом:

```go play
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) чтение
}
```

```text
hello
```

Пройдём цепочку по шагам. `(1)` раньше `(2)` по порядку строк одной горутины. `(2)`
happens-before `(3)` по правилу закрытия канала. `(3)` раньше `(4)` по порядку строк
главной горутины. По транзитивности запись `msg` happens-before её чтения, поэтому
программа печатает `hello` при любом планировании, а не «обычно».

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

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

```go play
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()
}
```

```text
true готово
```

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

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

Чаще всего happens-before возникает при ожидании через `sync.WaitGroup`. Всё, что
горутина записала до `Done`, видно после `Wait`:

```go play
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)
}
```

```text
[0 1 4 9]
```

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

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

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

```go play
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())
}
```

```text
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):

```go
// 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`.

## Что дальше

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