Модель памяти и гонки
Перед началом: модуль 1 (GMP,
GOMAXPROCS). Инструменты: Go 1.22+,go test -race,go vet. Трудоёмкость: ~4 ч (теория 2 ч · практика 1.5 ч · квиз 0.5 ч).
🔥 Инцидент: конфиг иногда приходил nil, а сервис падал раз в неделю
Сервис раз в несколько дней падал на старте с nil pointer dereference
при чтении конфигурации. Воспроизвести не удавалось: на ноутбуке
разработчика и на CI всё проходило, в проде — примерно раз в неделю.
Конфиг загружался в отдельной горутине, а main ждал флаг готовности:
govar cfg *Config var ready bool go func() { cfg = loadConfig() ready = true }() for !ready { } use(cfg) // иногда nil
Внешне это выглядело как «редкая одновременность»: не пересеклись —
хорошо, пересеклись — падение. На самом деле одновременность ни при
чём. Компилятор и процессор вправе переставить ready = true раньше
записи полей cfg, и читатель увидит флаг, но не данные. На x86
перестановка редка, на ARM — обычное дело; отсюда «раз в неделю».
Это модуль про то, какие пары операций гарантируют видимость (happens-before), чем гонка данных отличается от логической гонки и как ловить обе.
📖 Теория
2.1 Happens-before: порядок, который обещает модель памяти
Модель памяти Go задаёт частичный порядок событий: если A
happens-before B, то B гарантированно видит результаты A,
а компилятор не вправе переставить их местами. Без такого ребра
порядок не определён — даже если в исходнике строка выше.
Пять источников рёбер, которых достаточно на практике:
- Порядок программы: внутри одной горутины операции идут в порядке исходника.
go: всё, что выполнено доgo f(), happens-before стартаf.- Канал: отправка happens-before приёма, который её получил;
close(ch)happens-before приёма нулевого значения изch. - Мьютекс:
Unlockhappens-before следующегоLockтого же мьютекса; дляRWMutex—UnlockпередRLock. atomic:Storehappens-beforeLoad, прочитавшего это значение.
К ним примыкает WaitGroup: Done happens-before возврата Wait.
Отношение транзитивно: A до B и B до C даёт A до C.
Публикация — цепочка «запись данных → барьер → чтение данных»;
в инциденте выше барьера не было вовсе:
gogo func() { cfg = loadConfig() // запись полей ready = true // нет барьера: порядок не гарантирован }() for !ready { // busy-wait, чтение без синхронизации } use(cfg) // может увидеть nil
Корректные способы закрыть этот разрыв:
go// 1. Канал: приём видит всё, что было до отправки. ch := make(chan *Config, 1) go func() { ch <- loadConfig() }() cfg := <-ch // 2. Мьютекс: данные под замком, флаг — atomic. mu.Lock() cfg = loadConfig() mu.Unlock() ready.Store(true)
for !ready {} с обычным bool — не барьер, а сжигание ядра.
С atomic.Bool видимость есть, но планировщик всё равно голодает:
ждите канал, WaitGroup или мьютекс с условием.
2.2 Гонка данных и логическая гонка — разные вещи
Гонка данных (data race) — два обращения к одной ячейке из разных горутин, хотя бы одно — запись, и между ними нет happens-before. В Go это неопределённое поведение; пример:
govar n int go func() { n++ }() // load + add + store go func() { n++ }() // те же три шага без порядка
n++ — не атомарная операция: две горутины читают одно и то же
старое значение и теряют по единице. go test -race сообщает:
textWARNING: DATA RACE Write at 0x00c0000140f8 by goroutine 7: main.main.func1() /tmp/race.go:6 +0x30 Previous write at 0x00c0000140f8 by goroutine 6: main.main.func2() /tmp/race.go:7 +0x30
Читать отчёт нужно по двум стекам: это два конфликтующих доступа к одному адресу, у каждого — горутина и строка. Между ними нет синхронизации — значит, нужен барьер, а не «повезёт».
Логическая гонка (race condition) — шире: результат зависит от порядка событий, даже когда все обращения атомарны. Пример check-then-act без гонки данных:
goif !cache.Has(k) { // RLock внутри v := load(k) // долго, блокировка не держится cache.Set(k, v) // двое успеют загрузить одно и то же }
Гонки данных здесь нет, но два потока сделают лишнюю работу или
запишут разные версии. -race такую ошибку не найдёт: он видит
только несинхронизированный доступ к памяти, а не нарушение
инварианта. Поэтому детектор — фильтр грубых ошибок, а не
доказательство корректности.
Что важно знать про go test -race:
- Инструментирует только выполненные пути: непройденная ветка не будет проверена. Нужны тесты с реальной конкурентностью.
- Замедляет код в 5–10 раз и требует больше памяти — запускайте в CI и на интеграционных тестах, не в горячем проде.
- Находит именно гонки данных, но не дедлоки, не утечки горутин и не логические гонки. Для них — отдельные приёмы (модули 4, 17).
2.3 Atomic: одна переменная и явные пары Load/Store
Пакет sync/atomic даёт операции над одной ячейкой без мьютекса.
Для счётчиков и флагов хватает Add, Load, Store; для
«прочитал — проверил — записал» нужен цикл CAS:
govar n atomic.Int64 for { old := n.Load() if n.CompareAndSwap(old, old+1) { break // успели; иначе значение уже другое — пробуем снова } }
CompareAndSwap меняет значение, только если оно всё ещё равно
old. Под нагрузкой попыток будет несколько, поэтому CAS-цикл
дороже одного Add; для простого счётчика берите Add.
Через atomic удобно публиковать неизменяемые снимки:
govar current atomic.Pointer[Config] func reload() { c := loadConfig() // новый объект, никто не видит его до Store current.Store(c) } func get() *Config { return current.Load() }
Читатели не блокируются. Условие: после Store объект нельзя
менять — иначе читатель получит гонку. Правка конфига — это новый
объект и новый Store, а не мутация старого.
atomic не заменяет мьютекс, когда менять нужно несколько
полей согласованно: две атомарные записи не образуют одну
транзакцию. И atomic.Value/atomic.Pointer требуют, чтобы тип
значения не менялся между Store.
2.4 Барьеры: канал, мьютекс, WaitGroup
Барьер — это операция, которая создаёт ребро happens-before. Выбор барьера определяет, кто кого ждёт и как выглядит выход:
- Канал — барьер и передача владения: отправка завершается, когда значение принято, и приём видит его целиком.
- Мьютекс — барьер для shared state:
Unlockвиден только следующемуLock, а не «всем вообще». sync.WaitGroup— барьер завершения:Donehappens-before возвратаWait;Add— строго до запуска горутины.sync.Once— барьер инициализации: вернувшийся изDoвидит результат, даже если функция шла в другой горутине.
В сниппете модуля показаны три рабочих схемы. Первая — данные
под sync.Mutex, готовность в atomic.Bool; читатель сначала
дожидается флага, затем берёт замок и видит конфиг целиком.
Вторая — канал: ch <- config{...} и got := <-ch образуют
пару, после которой все поля видны. Третья — CAS-цикл: четыре
горутины по тысяче инкрементов дают ровно 4000, потому что
неудачная попытка повторяется, а не теряется.
2.5 Компромиссы
atomicvs мьютекс: атомик дешевле и не блокирует, но годится для одной переменной; мьютекс защищает инвариант из нескольких полей. Если сомневаетесь — начинайте с мьютекса.- Канал vs мьютекс для публикации: канал выражает передачу
владения и дружит с
select; мьютекс удобнее для постоянного чтения shared state. Смешивать «флаг в atomic, данные без защиты» — классическая ошибка. - Неизменяемый снимок vs блокировка:
atomic.Pointerдаёт чтение без ожидания ценой аллокации на каждое обновление; мьютекс экономит память, но сериализует читателей с писателем. -raceв CI vs всегда: детектор замедляет в разы, поэтому его место — тесты и ночные прогоны; в проде включают точечно.
💻 Практика
- Воспроизведите инцидент. Соберите пример с
cfg *Configи флагомready boolбез синхронизации, запуститеgo run -raceи найдите два стека. Почините:atomic+ мьютекс, канал,WaitGroup. - Сравните счётчики. 8 горутин по 100 000 инкрементов:
n++,mutex,atomic.Add, CAS-цикл. Корректные версии дают 800 000. Замерьте время и объясните, почемуn++«почти работает», а CAS-цикл медленнееAdd. - Опубликуйте снимок конфига.
atomic.Pointerна неизменяемую структуру: писатель раз в 100 мс создаёт новый объект, десять читателей вызываютLoad. Прогоните с-race, затем добавьте мутацию послеStore— детектор поймает ошибку. - Запустите сниппет модуля. Ожидаемый вывод:
atomic-флаг + мьютекс: 3 500ms,через канал: 5 1s,CAS-счётчик (4×1000): 4000. Первые две строки — пары «барьер + данные», третья — CAS без потерь;-raceчист.
🧩 Quiz
WARNING: DATA RACE?✅ Итоги модуля
Студент должен:
- Объяснять happens-before и перечислять его источники
- Отличать гонку данных от логической гонки
- Читать отчёт
go test -raceпо двум стекам - Публиковать данные через канал, мьютекс или
WaitGroup - Использовать
atomic.Load/Storeи CAS-цикл осознанно - Публиковать неизменяемые снимки через
atomic.Pointer - Ответить на все вопросы Quiz
🔗 Мостик к Модулю 3
Барьер — это не только «дождаться», но и «передать владение». Самый выразительный инструмент — канал: очередь, направление и правило, кто вправе закрыть. Дальше — каналы и fan-in.