сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Горутины и каналы

Гонки данных в 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, остальные разбираются в тренажёре.

  1. #go_data_race1 / 5
    Что такое гонка данных (data race) в Go?
    A)Ситуация, когда две горутины соревнуются, кто первой захватит мьютекс
    B)Незащищённый конкурентный доступ к одной памяти, где хотя бы один — запись
    C)Недетерминированный порядок запуска горутин при каждом старте программы
    D)Программа ведёт себя по-разному в зависимости от того, какая горутина закончит первой
    показать ответ и разбор
    +B)Незащищённый конкурентный доступ к одной памяти, где хотя бы один — запись

    // разбор: Гонка данных — конкурентный доступ двух и более горутин к одной ячейке памяти без синхронизации, где хотя бы одна операция — запись. Результат не определён: от «повезло» до порчи данных и падений. Это уже, чем race condition (та про порядок событий вообще). Лечится синхронизацией — мьютекс, atomic, канал. Детектор -race ловит их на рантайме.

  2. #go_data_race2 / 5
    Что делает флаг -race (go test -race / go run -race)?
    A)Статически на этапе компиляции доказывает полное отсутствие гонок в программе
    B)Запрещает запуск горутины, пока предыдущая не завершилась, убирая гонки
    C)Переключает мапы и слайсы на потокобезопасные реализации автоматически
    D)Инструментирует доступ к памяти в рантайме и репортит случившиеся гонки
    показать ответ и разбор
    +D)Инструментирует доступ к памяти в рантайме и репортит случившиеся гонки

    // разбор: -race включает рантайм-детектор (ThreadSanitizer): он инструментирует обращения к памяти и во время выполнения ловит гонки, РЕАЛЬНО случившиеся на этом прогоне, печатая стек обеих сторон. Это не статический анализ и не гарантия отсутствия гонок — что не выполнилось, то не проверено. Замедляет в разы, поэтому гоняют в тестах/CI, а не в проде.

  3. #go_data_race3 / 5
    Несколько горутин пишут в обычную map без синхронизации. Что будет в Go?
    A)Ничего страшного: встроенные карты в Go потокобезопасны на запись
    B)Тихая порча данных, но программа продолжит работать без каких-либо сигналов
    C)Рантайм детектит это и падает: fatal error: concurrent map writes
    D)Записи сериализуются рантаймом сами, как под неявным мьютексом
    показать ответ и разбор
    +C)Рантайм детектит это и падает: fatal error: concurrent map writes

    // разбор: У встроенной map есть лёгкая проверка конкурентного доступа: рантайм замечает одновременную запись (или запись при чтении) и жёстко падает с «fatal error: concurrent map writes». Это не паника — recover её не поймает. Так Go защищает внутреннюю структуру от порчи. Для конкурентной карты — sync.Map или map под RWMutex. Спот-чек подтвердил fatal.

  4. #go_data_race4 / 5
    Тесты с -race прошли зелёными. Значит ли это, что гонок в коде нет?
    A)Да: детектор анализирует код статически и находит все гонки
    B)Да, если прогон был на нескольких ядрах
    C)Нет: он видит только те обращения, которые реально случились в прогоне
    D)Нет: детектор работает лишь для map, а другие типы не проверяет
    показать ответ и разбор
    +C)Нет: он видит только те обращения, которые реально случились в прогоне

    // разбор: Детектор динамический: он инструментирует обращения к памяти и сообщает о гонке, только если конфликтующие доступы произошли в этом запуске. Редкая ветка или не совпавшее расписание — и гонка останется незамеченной. Отсюда практика: гонять с -race регулярно и под нагрузочными тестами, помня о замедлении в разы и росте потребления памяти.

  5. #go_data_race5 / 5
    Одна горутина пишет переменную, другая её читает — без всякой синхронизации. Почему читатель может вообще не увидеть новое значение?
    A)Без отношения happens-before нет гарантии видимости записи
    B)Планировщик Go запрещает чтение переменной во время записи
    C)Чтение вернёт наполовину записанное значение, склеенное из двух версий
    D)Значение потеряется из-за сборщика мусора, освобождающего старое
    показать ответ и разбор
    +A)Без отношения happens-before нет гарантии видимости записи

    // разбор: Модель памяти Go гарантирует видимость записи только там, где установлено отношение happens-before: через мьютекс, канал, WaitGroup, atomic. Без него компилятор и процессор вправе переставлять и кэшировать операции, поэтому читатель может бесконечно видеть старое значение — программа при этом «работает» и на тестах, и на другой машине ломается.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.