Модуль: Concurrency · Уровень: Senior

TL;DR#

Гонка данных — это два конкурентных доступа к одной памяти, где хотя бы один — запись, и между ними нет отношения happens-before. В Go такое поведение не определено. Детектор гонок (go test -race, go run -race) построен на ThreadSanitizer: он динамически отслеживает happens-before через векторные часы и сообщает о реальных гонках, наблюдённых на конкретном прогоне. Цена высока (память ×5–10, скорость ×2–20), и он не доказывает отсутствие гонок — только находит те, что фактически случились.

Теория#

Что такое гонка данных#

Формально (по Go memory model) гонка — это:

  1. два доступа к одной ячейке памяти из разных горутин,
  2. хотя бы один из них — запись,
  3. они не упорядочены отношением happens-before (нет синхронизации между ними).

Гонка ≠ просто «конкурентный доступ»: два чтения — не гонка; запись+чтение под общим мьютексом — не гонка (мьютекс создаёт happens-before). Важно: с Go 1.19 спецификация явно говорит, что программа с гонкой данных имеет undefined behavior — компилятор/железо вправе делать что угодно (разорванные значения, «невозможные» состояния).

Happens-before — ядро модели#

Отношение happens-before задаётся:

  • внутри одной горутины — порядком программы;
  • go statement happens-before начала горутины;
  • send в канал happens-before завершения соответствующего receive; close happens-before receive нуля из закрытого канала;
  • Unlock happens-before последующего Lock; release atomic happens-before acquire того же значения; Done happens-before возврата Wait; и т.д.

Если между двумя доступами нет цепочки happens-before — это гонка.

Как работает детектор (ThreadSanitizer)#

-race инструментирует код: компилятор вставляет вызовы в рантайм TSan на каждый доступ к памяти и на каждую синхронизирующую операцию.

  • Shadow memory. Для каждого слова данных TSan хранит «теневые» записи о последних доступах: какая горутина, какой тип (read/write), значение векторных часов.
  • Vector clocks. Каждая горутина и каждый синхро-объект (мьютекс, канал) несут векторные часы. Синхронизация (Unlock/Lock, send/recv) обновляет их, фиксируя happens-before.
  • Детекция. При доступе TSan сравнивает текущие часы с теневыми: если есть предыдущий конфликтующий доступ из другой горутины, не упорядоченный happens-before, — это гонка, печатается стек обоих доступов.

Это динамический анализ: ловит только то, что реально выполнилось в данном прогоне с данным расписанием горутин.

go test -race ./...
go build -race -o app ./cmd/app   # для прод-нагрузочного теста (не для прода!)
go run -race main.go

Пример вывода:

==================
WARNING: DATA RACE
Write at 0x00c0000b4010 by goroutine 7:
  main.(*Counter).Inc()
      counter.go:12 +0x...
Previous read at 0x00c0000b4010 by goroutine 6:
  main.(*Counter).Value()
      counter.go:16 +0x...
==================

Стоимость#

  • Память: ×5–10 (shadow memory).
  • CPU/время: ×2–20 в зависимости от плотности доступов к памяти.
  • Поэтому -race гоняют в CI/тестах и иногда на canary под реальной нагрузкой, но не в обычном проде массово. Только инструментированный бинарь видит гонки.

Что детектор НЕ ловит#

  • Гонки, которых не было в прогоне. Не исполнили путь / расписание не совпало → не увидим. Поэтому нужны нагрузочные/стресс-тесты, разнообразные сценарии, GOMAXPROCS>1.
  • Логические гонки (race conditions), не являющиеся data race. Например, check-then-act через каналы/atomic без нарушения happens-before на одной ячейке: if exists { use } где между проверкой и использованием состояние меняется — это логическая гонка, но не data race.
  • Гонки через unsafe.Pointer в обход системы типов могут быть невидимы или искажены.
  • Гонки в неинструментированном коде: ассемблер, cgo (частично), сторонние бинари.
  • Дедлоки/livelock/leaks — это не его задача (дедлок всех горутин ловит рантайм-паника all goroutines are asleep - deadlock!, но только полный).
  • Неинициализированные данные / атомарность бизнес-инвариантов — вне области.

Подводные камни / gotchas#

  • «Нет варнингов» ≠ «нет гонок». Детектор не верификатор. Отсутствие находок означает только, что в этих прогонах гонок не наблюдалось.
  • Запускайте с реальным параллелизмом. GOMAXPROCS=1 или последовательные тесты прячут гонки. Гоняйте с -race -count=N, стресс-тестами, t.Parallel().
  • Накладные расходы искажают тайминги — гонка, видимая под -race, в обычном бинаре может «не воспроизводиться» (и наоборот). Это не значит, что её нет.
  • Только инструментированный бинарь. Нельзя добавить -race к уже собранному обычному бинарю; нужна пересборка.
  • Глобальный лимит детектора: TSan имеет лимит на число одновременно отслеживаемых синхро-объектов; в гигантских программах возможны пропуски.
  • -race меняет поведение по таймингам — иногда маскирует/демаскирует другие баги (Heisenbug).

Вопросы на собеседовании#

В: Чем гонка данных отличается от race condition? О: Data race — технический термин: неупорядоченные конкурентные доступы к одной памяти, где есть запись (UB по спеке). Race condition — более широкое понятие логической ошибки из-за порядка событий; она может существовать и без data race (например, check-then-act через корректно синхронизированные atomic). Детектор ловит первое, не второе.

В: Как работает -race под капотом? О: Это ThreadSanitizer. Компилятор инструментирует каждый доступ к памяти и синхро-операцию. Рантайм ведёт shadow memory и векторные часы для горутин и синхро-объектов; при доступе сравнивает часы и, если находит конфликтующий доступ без happens-before, рапортует гонку со стеками.

В: Гарантирует ли чистый прогон -race отсутствие гонок? О: Нет. Это динамический детектор: видит только гонки, реально случившиеся в данном расписании и пути исполнения. Нужны разнообразные сценарии, стресс, GOMAXPROCS>1, -count. Отсутствие находок — не доказательство.

В: Почему -race не включают в проде? О: Память ×5–10, скорость ×2–20. Для большинства прод-нагрузок это неприемлемо. Используют в CI/тестах и иногда на ограниченном canary. Плюс нужен специально пересобранный инструментированный бинарь.

В: Что детектор НЕ находит? О: Гонки, не воспроизведённые в прогоне; логические race condition без data race; дедлоки/livelock/утечки горутин; многое через unsafe/cgo/ассемблер. Это узкоспециализированный инструмент именно для data race.

В: Что такое happens-before и почему это центральное понятие? О: Частичный порядок событий, задаваемый синхронизацией (go, каналы, мьютексы, atomic, WaitGroup). Если два конфликтующих доступа им упорядочены — гонки нет. Детектор именно проверяет наличие happens-before между доступами через векторные часы.

В: Как поймать гонку, которая редко воспроизводится? О: Стресс-тесты с высоким параллелизмом (GOMAXPROCS>1), много итераций (-count, -race), фаззинг/рандомизация порядка, t.Parallel(), прогон на canary под реальной нагрузкой с инструментированным бинарём. Увеличение конкуренции повышает шанс наблюдения.

В: Что делает рантайм при «fatal error: all goroutines are asleep - deadlock»? О: Это не детектор гонок, а отдельная проверка планировщика: если все горутины заблокированы и нет работы, рантайм паникует. Частичный дедлок (живёт хоть одна горутина) так не ловится.

На что копают на senior+#

  • Формулировка Go memory model (особенно ужесточение 1.19: data race = UB, ранее «частично определено» для отдельных типов).
  • Внутренности TSan: shadow cells (обычно 4 на слово), векторные часы, как канал/мьютекс переносят happens-before между горутинами.
  • Почему два чтения — не гонка, а publish через atomic-флаг безопасен (release/acquire).
  • Различие data race vs logical race на конкретных примерах (double-check, lost update).
  • Стратегия тестирования конкурентности: детерминизм vs стресс, -race в CI, ограничения и Heisenbug-эффект.
  • Что происходит на железе при гонке: разорванная запись 64-бит на 32-бит, переупорядочивание store/load, видимость кэшей.