Вы уже умеете запускать горутины и пересылать данные через каналы. Эта глава —
про невидимый слой под всем этим: правила, по которым одна горутина видит (или
не видит) то, что записала другая. Эти правила называют моделью памяти Go.
Звучит абстрактно, но вопрос предельно практичный. Вы пишете в переменную в одной
горутине и читаете её в другой. Когда чтение гарантированно увидит свежее
значение? Интуитивный ответ «ну, сразу же» — неверный. Правильный ответ: только
если между записью и чтением протянута цепочка синхронизации. Без неё запись
может быть невидима другой горутине сколь угодно долго, и это не «иногда
глючит» — это формально неопределённое поведение.
Дальше разберём, почему так устроено, что такое happens-before и как им
пользоваться, и почему data race и race condition — совершенно разные звери,
хотя звучат похоже.
Кажется, что горутина запишет msg, потом done, а главная горутина увидит
done == true и напечатает "hello". На практике этот код может:
крутиться в for !doneвечно — главная горутина читает свою кэшированную
копию done и не видит чужую запись;
увидеть done == true, но напечатать пустую строку — запись done
«долетела», а запись msg ещё нет.
Почему? Между записью в одной горутине и чтением в другой нет ни одного акта
синхронизации. А раз так, в дело вступают три силы, каждая из которых имеет полное
право переставить или спрятать вашу запись:
Компилятор оптимизирует. Он видит, что внутри for !done {} тело done не
меняется, и вправе прочитать done один раз в регистр, а потом крутить
бесконечный цикл по регистру.
Процессор переупорядочивает инструкции и откладывает записи в буферах, чтобы
не ждать медленную память.
Кэши ядер не обязаны мгновенно согласовываться между собой.
Всё это — легальные оптимизации для одной горутины: её собственный результат
не меняется. Проблемы начинаются ровно там, где на эти записи смотрит другая
горутина без синхронизации.
Запомните главное: отсутствие синхронизации означает не «работает чуть медленнее
и не по порядку», а «никаких гарантий видимости вообще».
Чтобы рассуждать строго, в модели памяти есть одно центральное отношение —
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 mainimport "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 mainimport ( "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 живёт в одной
горутине, а мьютекс защищает критическую секцию, а не передаёт владение между
горутинами. Воспринимайте этот пример как иллюстрацию правила про мьютекс, а не
как паттерн для копирования.
На практике happens-before чаще всего возникает там, где вы и не задумываетесь —
при ожидании горутин через sync.WaitGroup. wg.Done() happens-before возврата
из wg.Wait(), поэтому всё, что горутина записала до Done, видно после Wait:
package mainimport ( "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 в конце.
Если нужно просто безопасно поделить счётчик или флаг, не обязательно тащить
мьютекс — хватит sync/atomic. Атомарная запись happens-before
атомарного чтения, которое её увидело, плюс операция неделима (её нельзя
«разорвать»):
package mainimport ( "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 — это про память. Две горутины обращаются к одной ячейке памяти
конкурентно, хотя бы одна из них пишет, и между ними нет 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 intvar wg sync.WaitGroupfor 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). Третьего безопасного варианта нет.
Примитивы синхронизации — мьютексы, RWMutex, WaitGroup,
Once: чем именно строить happens-before на практике.
Atomic-операции — когда хватает атомиков вместо мьютекса и какие
гарантии они дают.
Гонки и утечки — как увидеть data race глазами и
доказать его через go test -race.
Эта глава — фундамент под топиком 2 (задачи 5–8), где вы выбираете между мьютексом
и атомиками и обязаны понимать happens-before каждого, и под топиком 6 (21–22), где
гонки нужно распознавать и ловить детектором. Любое «решение без синхронизации, но
вроде проходит» — это ловушка, которую вы теперь умеете видеть.