open atlas
↑ К треку
Go с нуля до senior GO · 02 · 03

Модель памяти Go, гонки данных и детектор -race

Go гарантирует поведение только программам без гонок: happens-before дают каналы, мьютексы, атомики. Любая гонка данных — неопределённое поведение; «безобидных» гонок нет. Гоняйте -race в CI (в 2–20 раз медленнее). Мьютексы стерегут состояние; каналы передают владение.

GO Senior ◷ 20 min
Уровень
ОсновыJuniorMiddleSenior

Обработчик завершения ставил done = true; цикл воркера проверял for !done — обычный bool, записанный один раз, читаемый в цикле. «Это же просто флаг, — гласил комментарий в ревью, — худший случай — одна лишняя итерация». В продакшене воркер не остановился никогда. Компилятор, видя цикл, который не пишет в done, вынес чтение за цикл и закрутился на регистре навечно; деплой завис на graceful shutdown, пока оркестратор не прислал SIGKILL посреди записи, и команда день обвиняла Kubernetes. В том же квартале другой сервис еженощно падал с fatal error: concurrent map writes — две горутины трогали map-кэш, а этот краш рантайм ловит лишь иногда, когда его дешёвой выборочной проверке повезёт. Оба бага прошли ревью со словами «безобидная гонка». У модели памяти Go ответ прямой: у программы с гонкой данных нет определённого поведения, о котором можно рассуждать. Безобидных гонок не бывает — бывают гонки, с последствиями которых вы ещё не встретились.

Happens-before: единственный существующий порядок

Когда проверяешь конкурентный код и что-то кажется «очевидно нормальным», спроси себя: что здесь создаёт ребро happens-before (гарантию видимости между горутинами)? Если не можешь назвать — компилятор тоже этого не видит и будет действовать соответственно. Модель памяти Go (go.dev/ref/mem) определяет, когда чтение гарантированно видит запись: только когда запись happens-before (произошла-до) чтения. Внутри одной горутины это бесплатно даёт порядок программы. Между горутинами happens-before создаётся исключительно синхронизацией:

  • отправка в канал happens-before завершения соответствующего приёма (а для небуферизованных каналов приём happens-before возврата отправки);
  • close канала happens-before приёма, вернувшего из-за него нулевое значение;
  • Unlock у sync.Mutex happens-before любого последующего захватившего Lock;
  • sync.WaitGroup.Done happens-before освобождаемого им Wait; функция sync.Once happens-before возврата любого Do;
  • атомарные операции из sync/atomic ведут себя как крошечные синхронизированные доступы — горутина, увидевшая атомарную запись, видит и всё, что предшествовало этой записи.

Больше порядок не создаёт ничто. Ни time.Sleep, ни «запись же очевидно случилась секунды назад», ни print, который «почему-то чинит». Без ребра компилятор вправе агрессивно переупорядочивать и кэшировать (вынос чтения флага из вступления легален), а каждое ядро CPU может отдавать чтения из собственного буфера записи. Модель формулирует явно: если два доступа к одной ячейке из разных горутин не упорядочены happens-before и хотя бы один — запись, это гонка данных, и поведение всей программы не определено — Go обещает последовательно согласованную семантику только программам без гонок. Многословные значения делают «не определено» осязаемым: значение интерфейса или заголовок слайса — это два-три машинных слова, и гоночная запись может быть увидена полуобновлённой — указатель типа от нового значения с данными от старого, то есть порча памяти, а не устаревшее чтение.

Детектор гонок: как работает и сколько стоит

Соберите или тестируйте с -race — компилятор инструментирует каждый доступ к памяти; рантайм (на основе ThreadSanitizer — динамического анализатора гонок из проекта LLVM) ведёт векторные часы на горутину и теневую память, помнящую последние доступы к каждому слову. На каждом доступе проверка: был ли прежний доступ к этому адресу из другой горутины, хотя бы один из них запись, и нет ли между ними пути happens-before? Если да — печатаются оба стека: гоночные чтение и запись плюс места создания горутин. Для практики важны два свойства:

  • Нет ложных срабатываний. Он обнаруживает фактические несинхронизированные доступы по формальной модели, а не эвристики. Каждый отчёт — реальный баг; «мы про этот знаем» — не категория триажа.
  • Только ложные пропуски. Он помечает гонки, которые исполнились, а не которые возможны. Гонка на пути, который тест не прогнал, или в переплетении, которое не случилось, остаётся невидимой. Силу -race дают покрытие и реалистичная конкурентность в тестах.

Цена: обычно в 2–20 раз медленнее и в 5–10 раз больше памяти (горутины к тому же стартуют с большими стеками). Для большинства продакшен-флотов это тяжело, для CI — тривиально: go test -race ./... обязан жить в каждом пайплайне, а канарейка, собранная с -race, на стейджинговом трафике — сениорский ход для ловли переплетений, которых тесты не порождают.

// Баг из вступления, в минимальном виде. `go test -race` воспроизводит его детерминированно,
// когда обе горутины запущены; без -race это может «работать» месяцами.
var done bool // разделяемая, без синхронизации

func worker() {
	for !done { // гоночное чтение — компилятор может вынести, ядро может закэшировать
		work()
	}
}

func shutdown() { done = true } // гоночная запись

// Фикс 1 (атомарный флаг):  var done atomic.Bool … done.Store(true) / done.Load()
// Фикс 2 (канал):            done := make(chan struct{}) … close(done) /
//                             select { case <-done: return; default: }
// Фикс 3 (ctx):              передать context.Context и делать select на ctx.Done()
Викторина

Горутина крутится в цикле по обычному bool-флагу, который выставляет другая горутина. Коллега утверждает, что гонка безобидна: худший случай — одна устаревшая итерация. Что на самом деле разрешает модель памяти?

Мьютексы, RWMutex, атомики: охрана состояния

sync.Mutex — рабочая лошадь: Lock/Unlock создают рёбра happens-before, делающие всё сделанное в критической секции видимым следующему захватившему. Без конкуренции мьютексы Go дёшевы (~десятки наносекунд, один атомарный CAS), под конкуренцией — нечестны, но ограниченно (после ~1 мс ожидания включается режим голодания, отдающий лок самому давнему ожидающему). Они нереентерабельны — повторный Lock из той же горутины даёт дедлок. Держите критические секции крошечными и никогда не держите лок через I/O или операцию с каналом; по конвенции мьютекс объявляют прямо над полями, которые он стережёт.

sync.RWMutex пускает читателей параллельно, писателей — эксклюзивно. Честный компромисс: его учёт тяжелее, чем у Mutex, так что он выигрывает только когда секции чтения длинные или читателей много, а записи редки; для наносекундного чтения пары полей обычный Mutex обычно быстрее, а часто записываемый RWMutex медленнее Mutex. Для read-mostly данных, меняющихся целиком (конфиг, таблицы маршрутизации), atomic.Pointer[T] — читатели загружают указатель на снапшот, писатель строит новое неизменяемое значение и сохраняет его — даёт чтения без локов ценой одной аллокации на обновление. Типы sync/atomic (atomic.Bool, atomic.Int64, atomic.Pointer) покрывают флаги и счётчики; а попытка собрать на атомиках вручную инвариант из нескольких переменных — родина lock-free багов: если два поля должны меняться вместе, это работа мьютекса.

lesson.inset.deep-dive

Почему рантайм ловит concurrent map writes, но не вашу гонку на флаге: map и так несёт слово состояния хэша, которое рантайм трогает на каждой операции, поэтому он дёшево ставит и проверяет там бит «идёт запись» — best-effort-растяжка, которая ничего не стоит, ловит лишь часть переплетений и валит процесс невосстановимой fatal error, а не паникой, именно потому, что внутренности map могут быть уже испорчены. Обобщить эту проверку на каждую переменную — это и есть детектор гонок с его ценой 2–20x. Асимметрия объясняет продакшен-фольклор: гонки на map падают громко (иногда), все остальные гонки портят тихо (всегда-рано-или-поздно). Ни то ни другое — не стратегия синхронизации.

Каналы или мьютексы — лозунг с оговорками

«Не общайтесь через разделяемую память; разделяйте память через общение» — это совет по проектированию, а не запрет. Честное правило выбора: мьютексы защищают состояние на месте; каналы передают владение данными или координируют жизненные циклы. Счётчик, кэш, набор полей, меняемых на месте, — территория мьютекса, и прогонять это через горутину-владельца канала значит добавить задержку, лишнюю горутину под управление и API, где каждое чтение — round-trip. Стадия пайплайна, передающая работу, пул воркеров, отмена, «ровно одна горутина может сейчас трогать этот буфер» — территория каналов, и эмулировать её мьютексами с condition variable значит заново изобретать то, чем каналы уже являются. Тест на запах в кодовой базе: структура с мьютексом и дюжиной методов lock-изменить-unlock — нормальна; канал, используемый как мьютекс (буфер 1, отправка = захват, приём = освобождение), — признак того, что применили лозунг вместо правила. В любом случае оба инструмента компилируются в одну валюту модели памяти: рёбра happens-before. Берите тот, что делает владение очевидным читателю.

Викторина

Ваш CI гоняет go test -race ./... и зелен уже год. Затем отчёт детектора срабатывает в стейджинг-канарейке. Что на самом деле говорит год зелёного CI?

Выбери лучший вариант

У вас общий кэш в памяти (map), который читают 50 горутин и обновляет один фоновый обновитель каждые 30 с. Какой примитив синхронизации подходит лучше всего?

Вспомните перед уходом
  1. 01
    Что создаёт happens-before в Go и почему гонка на обычном bool-флаге не безобидна?
  2. 02
    Как работает детектор -race, сколько стоит и что означают его отчёты и его молчание?
Итог

Модель памяти Go определяет ровно один механизм видимости между горутинами: рёбра happens-before, создаваемые синхронизацией и ничем больше. Отправки в канал happens-before завершения их приёмов, close happens-before вызванного им приёма нулевого значения, Unlock мьютекса happens-before последующих Lock, у WaitGroup и Once — аналогичные гарантии, а операции sync/atomic действуют как синхронизированные доступы. Реальное время, sleep и кажущаяся очевидность порядка не создают — поэтому обычный bool-флаг завершения, читаемый в цикле, есть гонка данных, и компилятор вправе вынести чтение так, что цикл никогда не увидит запись: вот как graceful shutdown висит до SIGKILL. Модель обещает последовательно согласованное поведение только программам без гонок; у гоночной программы поведение не определено, включая полузаписанные значения интерфейсов и заголовки слайсов — порчу памяти, а не просто устаревание. Краш рантайма на concurrent map writes — дешёвая best-effort-растяжка только для map; всё остальное портится тихо. Детектор -race инструментирует каждый доступ и сверяет векторные часы с теневой памятью: ложных срабатываний нет (каждый отчёт — реальное нарушение модели), есть только пропуски (он видит лишь исполнившиеся переплетения), цена — 2–20x по скорости и 5–10x по памяти, что прописывает его в CI и стейджинг-канарейки, а не в продакшен. Для починки гонок: sync.Mutex стережёт многополевые инварианты крошечными критическими секциями без I/O под локом; RWMutex окупается только на длинных или многочисленных чтениях при редких записях; атомарные типы — для флагов и счётчиков; atomic.Pointer со снапшотами — для read-mostly конфига. Лозунг про разделение памяти через общение, честно оговорённый: мьютексы защищают состояние на месте, каналы передают владение и координируют жизненные циклы — оба чеканят одну валюту, happens-before, и прав тот выбор, который делает владение очевидным. Теперь, когда увидишь в ревью «безобидный» флаг, счётчик без лока или map, к которому тянутся две горутины, — назови примитив синхронизации прежде, чем одобрить правку. Если назвать не получается — добавь -race в прогон тестов: детектор ответит за тебя.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.