Гонки данных в Go
Написал классику: одна горутина крутит for !done {}, другая через 50 мс ставит done = true. Никакой синхронизации. Цикл вышел, через 23 444 548 итераций, программа отработала правильно. Именно поэтому баг и опасен. Спрашивают, что такое гонка данных, почему она воспроизводится не всегда и что гарантирует модель памяти.
Стержень: без установленного отношения happens-before нет гарантии, что читатель увидит запись. «У меня работает» доказательством не считается.
// Формулировки: «что такое data race?», «что делает -race?», «почему баг воспроизводится не всегда?»
Что считается гонкой
Гонка данных - это когда две горутины обращаются к одной переменной, хотя бы одно обращение на запись, и между ними нет синхронизации. Мой цикл подходит под определение целиком, хотя отработал правильно.
Программа с гонкой считается некорректной, а поведение неопределённым. Это сильнее, чем «непредсказуемый порядок»: обещаний нет вообще никаких. Компилятор вправе поднять чтение done из цикла наружу, и тогда цикл повиснет навсегда. На моём прогоне не поднял. На другой версии, другой архитектуре или просто в другом окружении кода - поднимет, и никто не нарушит спецификацию.
// Один случай рантайм ловит специально: конкурентная запись в мапу печатает fatal error: concurrent map writes и убивает процесс. Я пробовал накрыть это через recover - не ловится, потому что это fatal error, а не паника.
- гонка данных
- несинхронизированный доступ, хотя бы один на запись
- неопределённое поведение
- никаких обещаний о результате программы
happens-before простыми словами
Модель памяти Go обещает видимость записи только там, где между записью и чтением установлен порядок. Устанавливают его пять вещей: мьютекс, операции с каналом, WaitGroup, sync.Once и atomic. Больше ничего. Ни sleep, ни «оно же на одном ядре», ни «переменная маленькая, запись атомарная».
Без этого порядка компилятор и процессор вправе переставлять и кэшировать операции. Поэтому флаг «работа закончена» через обычный bool - гонка, даже когда тесты зелёные, а замер показывает 23 миллиона успешных итераций. Правильно взять atomic.Bool или просто закрыть канал: закрытие канала видно всем получателям.
// Добавленный sleep гонку не чинит, а прячет. Расписание становится другим, конфликт перестаёт совпадать, а определение гонки продолжает выполняться слово в слово.
- happens-before
- порядок, который гарантирует видимость записи
- переупорядочивание
- право компилятора и процессора менять порядок операций
Детектор: что он видит и сколько стоит
Запустил тот же цикл под -race. Детектор напечатал WARNING: DATA RACE и два стека: запись в одной горутине, чтение в другой. Программа при этом снова отработала правильно. То есть по результату баг невидим, а детектор его показывает.
Работает он динамически: инструментирует реальные обращения и сообщает о конфликте, только если тот действительно случился в этом запуске. Непройденная ветка останется непроверенной, поэтому зелёный прогон с -race доказывает не отсутствие гонок, а лишь то, что в этот раз они не произошли.
// Про цену. В документации Go говорят о типичном замедлении около десяти раз. На моём микробенчмарке чистой блокировки вышло тридцать семь: 14,9 наносекунды превратились в 548,6, а у atomic 7,8 в 131,3. Микробенчмарк - худший случай, там кроме инструментируемых операций ничего и нет. Но вывод один: место -race в CI и на нагрузочном стенде, а не в проде.
- -race
- динамический детектор гонок, замедляет в разы
- динамический анализ
- видит только фактически случившиеся обращения
Как отвечать: «Что такое гонка данных и почему -race не гарантирует её отсутствие?»
Гонка данных это когда две горутины обращаются к одной переменной, хотя бы одно обращение на запись, и между ними нет синхронизации. Причём формулировка тут сильнее, чем «непредсказуемый порядок»: программа с гонкой считается некорректной, поведение неопределённое. Модель памяти Go гарантирует видимость записи только там, где установлено отношение happens-before, а создают его мьютекс, операции с каналом, WaitGroup, Once и atomic. Без этого компилятор и процессор вправе переставлять и кэшировать операции. Люблю показывать это на примере: цикл for !done с обычным bool у меня отработал нормально, вышел после двадцати трёх миллионов итераций, и по результату баг не виден вообще. Под -race тот же код сразу даёт WARNING: DATA RACE с двумя стеками. Правильно там взять atomic.Bool или закрытие канала. Теперь про детектор: он динамический, инструментирует реальные обращения и ловит конфликт, только если тот случился в этом запуске. Непройденная ветка или неудачно не совпавшее расписание - и гонка проходит мимо, поэтому зелёный прогон ничего не доказывает. Плюс он дорогой: на микробенчмарке блокировки я получил замедление в тридцать семь раз, в документации говорят про типичные десять. Отсюда и место: CI и нагрузочный стенд, не прод.
Сильный ответ: дано точное определение, объяснена модель памяти через happens-before с перечнем создающих его примитивов, показано на примере, что «работает» и «корректно» - разные вещи, и разобрана природа детектора вместе с ценой. Это уровень человека, который такие баги чинил.
На чём валят
- −Считают, что зелёный прогон с -race доказывает отсутствие гонок.
- −Синхронизируют флаг завершения обычным bool. У меня он отработал 23 миллиона раз и всё равно остался гонкой.
- −«Чинят» гонку добавлением sleep: она просто перестаёт совпадать.
- −Думают, что на одном ядре гонок не бывает. Переупорядочивание делает и компилятор тоже.
- −Пытаются перехватить concurrent map writes через recover. Это fatal error, recover мимо.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 5, остальные разбираются в тренажёре.
- Что такое гонка данных (data race) в Go?A)Ситуация, когда две горутины соревнуются, кто первой захватит мьютексB)Незащищённый конкурентный доступ к одной памяти, где хотя бы один — записьC)Недетерминированный порядок запуска горутин при каждом старте программыD)Программа ведёт себя по-разному в зависимости от того, какая горутина закончит первой
показать ответ и разбор
+B)Незащищённый конкурентный доступ к одной памяти, где хотя бы один — запись// разбор: Гонка данных — конкурентный доступ двух и более горутин к одной ячейке памяти без синхронизации, где хотя бы одна операция — запись. Результат не определён: от «повезло» до порчи данных и падений. Это уже, чем race condition (та про порядок событий вообще). Лечится синхронизацией — мьютекс, atomic, канал. Детектор -race ловит их на рантайме.
- Что делает флаг -race (go test -race / go run -race)?A)Статически на этапе компиляции доказывает полное отсутствие гонок в программеB)Запрещает запуск горутины, пока предыдущая не завершилась, убирая гонкиC)Переключает мапы и слайсы на потокобезопасные реализации автоматическиD)Инструментирует доступ к памяти в рантайме и репортит случившиеся гонки
показать ответ и разбор
+D)Инструментирует доступ к памяти в рантайме и репортит случившиеся гонки// разбор: -race включает рантайм-детектор (ThreadSanitizer): он инструментирует обращения к памяти и во время выполнения ловит гонки, РЕАЛЬНО случившиеся на этом прогоне, печатая стек обеих сторон. Это не статический анализ и не гарантия отсутствия гонок — что не выполнилось, то не проверено. Замедляет в разы, поэтому гоняют в тестах/CI, а не в проде.
- Несколько горутин пишут в обычную map без синхронизации. Что будет в Go?A)Ничего страшного: встроенные карты в Go потокобезопасны на записьB)Тихая порча данных, но программа продолжит работать без каких-либо сигналовC)Рантайм детектит это и падает: fatal error: concurrent map writesD)Записи сериализуются рантаймом сами, как под неявным мьютексом
показать ответ и разбор
+C)Рантайм детектит это и падает: fatal error: concurrent map writes// разбор: У встроенной map есть лёгкая проверка конкурентного доступа: рантайм замечает одновременную запись (или запись при чтении) и жёстко падает с «fatal error: concurrent map writes». Это не паника — recover её не поймает. Так Go защищает внутреннюю структуру от порчи. Для конкурентной карты — sync.Map или map под RWMutex. Спот-чек подтвердил fatal.
- Тесты с -race прошли зелёными. Значит ли это, что гонок в коде нет?A)Да: детектор анализирует код статически и находит все гонкиB)Да, если прогон был на нескольких ядрахC)Нет: он видит только те обращения, которые реально случились в прогонеD)Нет: детектор работает лишь для map, а другие типы не проверяет
показать ответ и разбор
+C)Нет: он видит только те обращения, которые реально случились в прогоне// разбор: Детектор динамический: он инструментирует обращения к памяти и сообщает о гонке, только если конфликтующие доступы произошли в этом запуске. Редкая ветка или не совпавшее расписание — и гонка останется незамеченной. Отсюда практика: гонять с -race регулярно и под нагрузочными тестами, помня о замедлении в разы и росте потребления памяти.
- Одна горутина пишет переменную, другая её читает — без всякой синхронизации. Почему читатель может вообще не увидеть новое значение?A)Без отношения happens-before нет гарантии видимости записиB)Планировщик Go запрещает чтение переменной во время записиC)Чтение вернёт наполовину записанное значение, склеенное из двух версийD)Значение потеряется из-за сборщика мусора, освобождающего старое
показать ответ и разбор
+A)Без отношения happens-before нет гарантии видимости записи// разбор: Модель памяти Go гарантирует видимость записи только там, где установлено отношение happens-before: через мьютекс, канал, WaitGroup, atomic. Без него компилятор и процессор вправе переставлять и кэшировать операции, поэтому читатель может бесконечно видеть старое значение — программа при этом «работает» и на тестах, и на другой машине ломается.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.