Состояние гонки (race condition) - самая коварная разновидность дефектов в системном программировании. Программа отлично работает неделями, проходит тесты, выдерживает нагрузочные испытания, а потом в «неудачную миллисекунду» два потока оказываются в неправильном месте в неправильное время - и данные повреждены. Хуже того: на машине разработчика баг не воспроизводится, под отладчиком исчезает, а добавленное для диагностики логирование его «лечит». Эта статья разбирает механику гонок от уровня машинных инструкций до моделей памяти процессоров, объясняет, почему инкремент счётчика не является атомарной операцией, почему двойная проверка блокировки сломана без volatile, и как диагностировать то, что не хочет диагностироваться.
Check-then-act и классическая проблема TOCTOU
Наиболее распространённый сценарий гонки - паттерн check-then-act, «проверь, затем действуй». Код сначала проверяет некоторое условие, а затем выполняет действие, исходя из того, что условие всё ещё истинно. Между проверкой и действием проходит время, и за это время планировщик потоков может вытеснить текущий поток в любой точке - прямо между двумя строками, которые автор кода мысленно считал единым целым.
Классический пример из банковской предметной области. Поток первый читает баланс счёта - 100 рублей, проверяет, что списание 80 рублей допустимо, и в этот момент теряет квант времени. Поток второй читает те же 100 рублей, успешно списывает 70, баланс становится 30. Управление возвращается к первому потоку, который уже «знает», что денег хватает, и списывает свои 80. Баланс уходит в минус 50, хотя каждый поток по отдельности вёл себя безупречно корректно. Ошибка не в логике отдельного потока - она в предположении, что наблюдаемое состояние останется неизменным до момента действия.
Эта разновидность гонки носит имя TOCTOU (time-of-check to time-of-use) - «время проверки против времени использования». Тот же дефект встречается при работе с файлами: программа проверяет существование файла или его права, затем открывает его, а между этими системными вызовами другой процесс заменяет файл символической ссылкой куда-нибудь в системный каталог. В операционных системах подобные гонки десятилетиями служили источником уязвимостей повышения привилегий. Общий рецепт защиты один: проверку и действие нужно объединить в одну неделимую операцию - атомарное сравнение с обменом, открытие файла с флагами, исключающими ссылки, либо сериализацию доступа через блокировку, которая удерживается на весь участок «проверил - сделал».
Неатомарность инкремента и read-modify-write
Интуиция подсказывает, что выражение counter++ - мелочь, одна операция, где уж тут гонке развернуться. На уровне машинного кода это три инструкции: загрузить значение из памяти в регистр, прибавить единицу в регистре, записать регистр обратно в память. Такая последовательность называется read-modify-write, и прерывание может случиться между любыми двумя её шагами.
Пусть счётчик равен 42. Поток A читает 42 в свой регистр. Поток B читает те же 42 в свой регистр. Оба прибавляют единицу, оба записывают 43. Два инкремента дали приращение на единицу - одно обновление потеряно. В цикле из миллиона инкрементов на двух потоках результат почти никогда не равен двум миллионам, и чем сильнее нагружена система, тем дальше фактическое значение от ожидаемого. Это не теоретическая экзотика, а обычный эксперимент, который воспроизводится на любой многопроцессорной машине за секунды.
Решение - атомарные операции. В Win32 API это семейство Interlocked-функций: InterlockedIncrement, InterlockedDecrement, InterlockedCompareExchange. В языке C++ начиная со стандарта C++11 - std::atomic<int> с операциями fetch_add и compare_exchange. В .NET - класс Interlocked. Под крышей на x86 это инструкция add или xadd с префиксом lock, который резервирует линию кэша и гарантирует, что между чтением и записью никто другой не изменит ячейку. Важно понимать: атомарность достигается не колдовством, а явной инструкцией процессору, и она стоит дороже обычного сложения - отсюда соблазн «сэкономить», за который программа расплачивается потерянными обновлениями.
Модели памяти и почему баги всплывают на ARM
Атомарность - только половина беды. Вторая половина - видимость и порядок. Современный процессор не выполняет обращения к памяти строго в программном порядке: компилятор переставляет чтения и записи ради оптимизации, а сам процессор переупорядочивает их аппаратно, чтобы не простаивать в ожидании кэш-промахов. Однопоточная программа этого не замечает - ей гарантирована иллюзия последовательности. Многопоточная замечает немедленно: поток-наблюдатель может увидеть запись в переменную flag раньше, чем запись в данные, которые этот флаг «защищает».
Архитектуры различаются строгостью модели памяти. x86 - архитектура с сильной моделью: она гарантирует, что записи видны другим ядрам в порядке выполнения, и запрещает большинство перестановок чтений. Именно поэтому код с грубыми нарушениями синхронизации годами «работает» на настольных Intel-машинах. ARM - слабая модель памяти: чтения и записи могут переупорядочиваться почти свободно, и тот же самый код на ARM-планшете или телефоне начинает рассыпаться с плавающими ошибками. Отсюда классическая ситуация: приложение безупречно на десктопе и загадочно падает на ARM-устройстве, и виновата не платформа, а давно существовавшая гонка, которую сильная модель x86 просто прятала.
Инструмент упорядочивания - memory barrier, ограждение памяти. Ограждение запрещает компилятору и процессору переносить обращения через заданную точку: все записи до барьера становятся видимыми прежде записей после него. В C++ ту же роль выполняют операции над std::atomic с явным указанием порядка - memory_order_acquire для чтений, memory_order_release для записей, seq_cst по умолчанию как самый строгий и дорогой вариант. В .NET - Interlocked.MemoryBarrier и Thread.MemoryBarrier. Правильная пара acquire/release дешевле полного барьера и выражает намерение точно: поток-писатель «отпускает» данные, поток-читатель «приобретает» их.
Heisenbug и терапевтический эффект отладчика
Гонки славятся неуловимостью, и этому есть физическое объяснение. Ошибка проявляется лишь при определённом чередовании инструкций потоков, а любое вмешательство меняет тайминги. Пошаговый отладчик растягивает выполнение до человеческого темпа - планировщику просто негде вклиниться «неудачно». Точка останова, остановившая один поток на несколько минут, выстраивает остальные потоки в удобную очередь. Дефект исчезает ровно в тот момент, когда за ним начинают наблюдать - за такое поведение его прозвали Heisenbug, по аналогии с принципом, согласно которому измерение меняет измеряемую систему.
Логирование действует так же. Один вызов записи в лог - это форматирование строки, системный вызов, ожидание диска, а часто ещё и внутренняя блокировка логгера, сериализующая потоки. Добавив лог «для диагностики», разработчик нечаянно вставил в критическое место задержку и синхронизацию - гонка перестаёт укладываться в прежнее временное окно и затаивается. Баг не вылечен, он ушёл в глубину и проявится под нагрузкой на боевом сервере. Практический вывод: если добавление логов «чинит» проблему - это почти наверняка гонка, и лог надо выбросить, а не запирательствовать им дефект.
Диагностика сводится к тому, чтобы искусственно расширить окно уязвимости. Стресс-тесты создают множество потоков и гоняют сценарий миллионы раз, заставляя редкие чередования случаться чаще. Приём случайных пауз - вставка Thread.Sleep со случайной задержкой или вызовы переключения контекста в подозрительных точках - смещает потоки относительно друг друга и перебирает варианты переплетения. Инструментарий сильнее ручных методов: Thread Sanitizer (флаг -fsanitize=thread в Clang и GCC) инструментирует все обращения к памяти и сообщает о паре неконтролируемых доступов к одной ячейке хотя бы с одной записью; исследовательская система CHESS от Microsoft Research систематически перебирает сценарии планирования, подменяя планировщик, и гарантированно воспроизводит гонку, если она достижима за разумное число переключений. Для ядра Windows актуален свой пласт проблем: драйвер исполняется на разных IRQL, на уровне DISPATCH_LEVEL нельзя ждать на диспетчерских объектах и нельзя обращаться к выгружаемой памяти, поэтому там применяются спин-блокировки; ошибка с уровнем IRQL или гонка за разделяемую структуру между обработчиком прерывания и потоком чаще всего заканчивается не исключением, а синим экраном - KeBugCheckEx.
Double-checked locking и роль volatile
Шаблон двойной проверки блокировки, double-checked locking, - знаменитая ловушка. Цель благородная: инициализировать одиночный объект лениво, не платя за блокировку при каждом обращении. Код проверяет указатель на null, входит в блокировку, проверяет ещё раз и создаёт объект. Логика выглядит железной, но в ней скрыта дыра: строка new вовсе не атомарна. Она состоит из выделения памяти, вызова конструктора и записи ссылки в переменную - и компилятор либо процессор вправе переставить последние два шага, публикую ссылку до того, как объект сконструирован.
Наблюдающий поток тогда проходит первую проверку - указатель уже не null - и получает объект, поля которого ещё не инициализированы: вместо готового значения там нули или мусор. На x86 такое почти не воспроизводится благодаря сильной модели, на ARM - вполне реально. Лекарство - слово volatile (в C#) либо std::atomic с семантикой acquire/release (в C++): оно запрещает переупорядочивание публикации относительно чтений и гарантирует, что не-null ссылка всегда указывает на полностью построенный объект. В Java этот дефект был признан настолько важным, что модель памяти языка в пятой версии переработали специально ради корректной работы volatile в данном паттерне. Современная практика проще: где фреймворк даёт готовую ленивую инициализацию - Lazy<T> в .NET, std::call_once в C++ - берите её и не изобретайте синхронизацию вручную.
Правила безопасного параллельного мышления
Бороться с гонками инструментами можно бесконечно, но надёжнее спроектировать систему так, чтобы гонке было негде возникнуть. Несколько принципов проверены десятилетиями системного программирования.
- Предпочитайте неизменяемые данные. Объект, который нельзя изменить после создания, можно свободно разделять между любым числом потоков без единой блокировки - читать неизменное безопасно всегда.
- Заменяйте разделяемое состояние передачей сообщений. Потоки, обменивающиеся копиями данных через очереди и каналы - channels, BlockingCollection, очереди сообщений, - не конкурируют за память, и гонке просто не за что зацепиться.
- Минимизируйте разделяемое состояние. Каждая глобальная переменная и каждое статическое поле - потенциальная точка соревнования; то, что не разделяется, не нужно и синхронизировать.
- Если блокировка неизбежна - делайте критическую секцию короткой и дисциплинируйте порядок захвата нескольких блокировок, чтобы не получить взаимоблокировку в нагрузку к гонке.
- Полагайтесь на готовые абстракции: атомики, потокобезопасные коллекции, пулы задач. Рукописная синхронизация оправдана только там, где готового решения действительно нет.
Общий знаменатель этих приёмов формулируется коротко: не разделяй состояние, а если разделяешь - не изменяй, а если изменяешь - делай это атомарно и с явным упорядочиванием. Программист, который мыслит категориями владения данными, а не категориями «успеет - не успеет», пишет параллельный код, который не разваливается ни на ARM-планшете, ни под нагрузкой, ни в «неудачную миллисекунду». Состояние гонки перестаёт быть источником ночных вызовов дежурной смены и становится просто классом ошибок, устранённых ещё на этапе проектирования.