Алексей Меленчук
вся записка
узел 01 · Ядро
дата
объём 6 мин чтения

Race condition: почему гонка видна только на бою

Что такое состояние гонки, почему race condition не воспроизводится локально, чем гонка отличается от дедлока и почему блокировки не дают потокобезопасности.

Коротко. Гонка — это не «забыли поставить замок», а неверно устроенные данные: два шага там, где инвариант требует одного. Мьютекс прячет её ровно до второго процесса.

Тикет выглядит так: «клиенту дважды списали деньги, в логах один запрос». На стенде не воспроизводится — там некому прийти вторым в ту же миллисекунду. В бою повторяется, всегда под нагрузкой. Кто-то добавляет лог перед списанием, чтобы поймать, и становится тихо.

Потом возвращается.

Почему гонка не воспроизводится

Между чтением значения и записью нового есть окно шириной в несколько инструкций процессора. Чтобы баг случился, второй поток должен попасть ровно в него. На стенде с одним пользователем туда никто не заходит. В бою заходят постоянно, и хватает одного попадания.

Отсюда свойство, которое сбивает с толку: гонка не «иногда есть». Она есть всегда — это открытое окно, а не событие. Меняется только вероятность попадания, и зависит она от нагрузки, числа ядер и решений планировщика. Добавленный лог сдвигает тайминг и делает попасть в окно почти невозможно. Почти — не то же самое, что никогда.

Стоит развести два термина, которые в статьях слипаются. Гонка данных (data race) — про память: два обращения к одной ячейке, хотя бы одно из них запись, хотя бы одно — не атомарное, и они не упорядочены отношением happens-before; формальное определение — в модели памяти Go. Такую ловит инструмент. Состояние гонки (race condition) шире: корректность результата зависит от порядка событий, которым никто не управляет. Две транзакции в базе могут быть каждая корректна, не делить ни байта памяти и всё равно потерять обновление — и вот это синхронизацией потоков уже не лечится.

Чем гонка отличается от дедлока

Дедлок объявляет о себе сам. Поток А держит замок 1 и ждёт замок 2, поток Б — наоборот. Всё встало, запрос висит, в стектрейсе видно, кто где застрял.

Гонка молчит. Ничего не падает, сервис отвечает 200, данные просто неправильные. Находится это через месяц, когда расходится сверка.

Поэтому дедлок в сумме дешевле: он отказывает сразу и показывает место, а гонка портит данные и прячет причину. И стоит помнить, откуда дедлок берётся — чаще всего это побочный продукт лечения гонки замками. Поставили второй мьютекс, завели порядок захвата, о котором теперь надо договариваться во всём коде и который никто не проверяет на обзоре.

Почему «добавили мьютекс» переносит проблему

Мьютекс защищает участок кода. Инвариант живёт в данных. Пока эти две границы совпадают, всё работает; расходятся они быстро.

Классика — проверить и сделать. Под замком прочитали баланс, замок отпустили, списали. Формально критическая секция есть. Фактически решение принято по значению, которое к моменту записи устарело.

Случай хуже: приложение выкатили в два пода. Мьютекс живёт в памяти одного процесса, второй про него не знает. Код с замком и код без замка ведут себя одинаково — разница в том, что первый выглядит защищённым.

И цена. Замок сериализует: защищённый участок выполняется по одному, сколько бы ядер вы ни купили. Потоки перестают превращаться в пропускную способность, и вместо редкой порчи данных выходит стабильно медленный сервис.

Из этого не следует, что мьютексы не нужны. Когда состояние лежит в памяти одного процесса, владелец один, а критическая секция короткая — мьютекс ровно то, что надо. Беда не в инструменте, а в том, что его ставят как обезболивающее, не ответив, какой инвариант защищают и где проходит его граница. Замок без сформулированного инварианта — ритуал.

Гонка, в которой нет ни одного потока

Два HTTP-запроса, два процесса, одна строка в базе. Каждый читает остаток, вычитает сумму, пишет обратно. На Read Committed никто не мешает второму прочитать остаток раньше, чем первый закоммитит своё списание, — и тогда одно списание пропадёт. Ни одного общего байта памяти — и полноценная гонка.

Здесь хорошо видно, чем она лечится. Не замком, а формой операции:

UPDATE accounts SET balance = balance - 100
 WHERE id = 42 AND balance >= 100;

Одна команда вместо трёх шагов. Условие и изменение неразделимы, база сама упорядочивает запись в строку, а ноль затронутых строк — честный ответ «не хватило», а не тихая порча.

Второй путь — поднять уровень изоляции. На Repeatable Read PostgreSQL при конкурентном изменении той же строки вернёт ошибку сериализации вместо неверного результата (документация по изоляции транзакций), и конфликт превращается в повтор запроса. Цена и границы такого выбора разобраны отдельно.

Устройство данных вместо расстановки замков

Гонка — симптом того, что данные устроены неправильно. Четыре способа устроить иначе, по возрастанию силы.

Не делить. Состояние, которое видит один поток, в гонках не участвует. Самый скучный и самый надёжный приём.

Одна операция вместо трёх. Атомарный инкремент, UPDATE ... WHERE, compare-and-swap. Инвариант оказывается внутри операции — гонки нет по построению, а не по договорённости.

Версия вместо правки на месте. Новое значение пишется, только если не изменилась версия. Конфликт становится видимым: возвращается вызывающему, а не теряется. Тот же приём убирает гонку при инвалидации кеша — новый ключ вместо удаления старого.

Один владелец. Все изменения сущности идут через одного исполнителя: партиция по ключу, актор, очередь. Параллелизма по ключу нет — значит, нет и гонки.

Ни в одном из четырёх приёмов не понадобилось слово «замок».

Как их всё-таки ловить

Детектор гонок в сборке для тестов (-race в Go, ThreadSanitizer в C и C++; в Rust он пока живёт на nightly — -Zsanitizer=thread с пересборкой стандартной библиотеки) дешевле любого разбирательства постфактум. С оговоркой из самой документации: детектор находит только те гонки, которые реально случились на исполненном пути. Зелёный прогон не доказывает отсутствия гонки — только то, что на этих данных в окно никто не зашёл.

Поэтому нагрузочный тест обязан бить в один ключ, а не в случайные. Гонка живёт в конкуренции за одну сущность, и тысяча запросов по тысяче разных идентификаторов не проверяет ничего.

Самое дешёвое средство — ограничения в базе. CHECK (balance >= 0), уникальный индекс на пару «заказ + ключ идемпотентности». База поймает то, что пропустил код, — громко и в момент нарушения, а не в отчёте через месяц.

Условие, при котором я неправ

Переустройство данных — это переписывание. Если гонка редкая, последствия обратимы, а команде послезавтра сдавать релиз, честный ответ — поставить замок и записать долг. Компромисс не становится ошибкой оттого, что он компромисс.

Ломается позиция и там, где инвариант по-настоящему размазан по нескольким сущностям: перевод между счетами, бронирование последнего места с записью в две таблицы. Свести такое к одной атомарной операции иногда нельзя, и тогда разговор действительно про блокировки и порядок их захвата.

Но начинать стоит не с вопроса «какой замок поставить». Начинать стоит с другого: какой инвариант вы защищаете и почему он вообще способен оказаться нарушенным хотя бы на мгновение? Если ответа в одну фразу нет, замок вы поставите не туда — и следующий тикет придёт с той же формулировкой.