Модуль: Concurrency · Уровень: Senior
TL;DR#
Гонка данных — это два конкурентных доступа к одной памяти, где хотя бы один — запись, и между ними нет отношения happens-before. В Go такое поведение не определено. Детектор гонок (go test -race, go run -race) построен на ThreadSanitizer: он динамически отслеживает happens-before через векторные часы и сообщает о реальных гонках, наблюдённых на конкретном прогоне. Цена высока (память ×5–10, скорость ×2–20), и он не доказывает отсутствие гонок — только находит те, что фактически случились.
Теория#
Что такое гонка данных#
Формально (по Go memory model) гонка — это:
- два доступа к одной ячейке памяти из разных горутин,
- хотя бы один из них — запись,
- они не упорядочены отношением happens-before (нет синхронизации между ними).
Гонка ≠ просто «конкурентный доступ»: два чтения — не гонка; запись+чтение под общим мьютексом — не гонка (мьютекс создаёт happens-before). Важно: с Go 1.19 спецификация явно говорит, что программа с гонкой данных имеет undefined behavior — компилятор/железо вправе делать что угодно (разорванные значения, «невозможные» состояния).
Happens-before — ядро модели#
Отношение happens-before задаётся:
- внутри одной горутины — порядком программы;
gostatement happens-before начала горутины;- send в канал happens-before завершения соответствующего receive; close happens-before receive нуля из закрытого канала;
Unlockhappens-before последующегоLock; release atomic happens-before acquire того же значения;Donehappens-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, видимость кэшей.