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),
уникальный индекс на пару «заказ + ключ идемпотентности». База поймает
то, что пропустил код, — громко и в момент нарушения, а не в отчёте
через месяц.
Условие, при котором я неправ
Переустройство данных — это переписывание. Если гонка редкая, последствия обратимы, а команде послезавтра сдавать релиз, честный ответ — поставить замок и записать долг. Компромисс не становится ошибкой оттого, что он компромисс.
Ломается позиция и там, где инвариант по-настоящему размазан по нескольким сущностям: перевод между счетами, бронирование последнего места с записью в две таблицы. Свести такое к одной атомарной операции иногда нельзя, и тогда разговор действительно про блокировки и порядок их захвата.
Но начинать стоит не с вопроса «какой замок поставить». Начинать стоит с другого: какой инвариант вы защищаете и почему он вообще способен оказаться нарушенным хотя бы на мгновение? Если ответа в одну фразу нет, замок вы поставите не туда — и следующий тикет придёт с той же формулировкой.