Неопределённое поведение в C++: почему оптимизатор ломает код под нагрузкой
Сервис работал в отладочной сборке и падал в релизе только под пиковой нагрузкой. Разбираем, как UB даёт компилятору право переписывать логику и почему проблема всплывает именно под нагрузкой.

Сервис на C++ (сборка Clang 17, -O2) держал десятки тысяч запросов в секунду и раз в несколько часов выдавал SIGSEGV в месте, где по коду разыменования нулевого указателя быть не могло. В отладочной сборке (-O0) воспроизвести не удавалось неделю. Причина оказалась не в аллокаторе и не в гонке в очевидном смысле — компилятор при оптимизации удалил проверку на nullptr, потому что ниже по коду указатель уже разыменовывался. Это неопределённое поведение (UB), и под нагрузкой оно перестало быть теоретическим.
Разбор про то, почему UB в C++ проявляется именно под нагрузкой и на высоких уровнях оптимизации, что при этом делает компилятор с вашим кодом и как это ловить. Тонкости самой модели памяти C++ — отдельная большая тема, здесь только то, что напрямую бьёт в проде.
Что компилятор считает невозможным
Стандарт C++ разрешает компилятору предполагать, что UB не происходит никогда. Из этого предположения оптимизатор делает выводы, которые в исходнике не написаны, но логически следуют.
Классический пример — проверка после разыменования:
// Clang 17 / GCC 13, -O2
int read_value(int* ptr) {
int result = *ptr; // разыменование
if (ptr == nullptr) { // проверка ПОСЛЕ
return -1;
}
return result;
}Компилятор рассуждает так: если бы ptr был nullptr, строка *ptr — это UB, а UB не бывает. Значит, к моменту проверки ptr гарантированно не ноль. Проверку можно выкинуть целиком. Ветка return -1 исчезает из ассемблера. В -O0 проверка остаётся, и код «работает» — до релизной сборки.
UB — это не «программа сделает что-то плохое». Это «компилятор имеет право считать, что этого не случится, и переписать код исходя из этого». Разница принципиальная: сломаться может участок, который вы даже не трогали.
Почему именно под нагрузкой
UB редко детерминирован. Три механизма делают его «нагрузочным»:
- Гонки данных. Одновременный доступ к одной переменной из нескольких потоков без синхронизации — это UB по стандарту (не «может быть, а как повезёт», а формально UB). При низком RPS потоки почти не пересекаются во времени, при пиковом — пересекаются постоянно.
- Редкие входные данные. Переполнение знакового
int— UB. Оно случается только на определённых значениях, которые под нагрузкой из широкого потока запросов рано или поздно приходят. - Состояние аллокатора. Use-after-free и выход за границу буфера при низкой нагрузке часто «прощаются»: освобождённая память ещё не переиспользована. Под нагрузкой аллокатор крутится быстро, и та же память тут же занимается другим объектом.
Знаковое переполнение: тихая бомба в счётчиках
Переполнение signed int — UB, переполнение unsigned — определено (обёртка по модулю 2^n). На этом строится опасная оптимизация циклов.
// GCC 13, -O2
bool will_overflow(int base, int step) {
// Программист думает: проверяю переполнение
return base + step < base;
}Если step > 0, то base + step < base может быть истинным только при переполнении. А переполнение — UB. Значит, компилятор вправе считать выражение всегда ложным при положительном step и вернуть константу false. Ваша защита от переполнения удалена.
Под нагрузкой это выстреливает в счётчиках и таймстампах: int-счётчик запросов, растущий на пике, доходит до INT_MAX, и дальнейшее поведение не определено — не «станет отрицательным», а именно неопределено, потому что оптимизатор мог построить вокруг счётчика предположения.
Как проверять переполнение правильно
Не полагайтесь на результат сложения. Проверяйте до операции или используйте встроенные функции с контролем переполнения:
// GCC/Clang: builtin с проверкой переполнения
int sum;
if (__builtin_add_overflow(base, step, &sum)) {
// переполнение произошло, sum содержать мусор
return handle_overflow();
}
// начиная с C++20 доступно <numeric>: std::midpoint и т.п.
// для более широкого набора смотрите документацию вашей стандартной библиотекиГонка данных: почему -O2 меняет наблюдаемое поведение
Флаг завершения между потоками без атомарности — частый источник «работало в дебаге»:
// UB: неатомарный доступ из двух потоков
bool stop_flag = false;
void worker() {
while (!stop_flag) { // читает в цикле
do_work();
}
}
void stopper() {
stop_flag = true; // пишет из другого потока
}Компилятор видит, что внутри цикла stop_flag не меняется, и вправе загрузить его один раз в регистр перед циклом. Получается вечный цикл, который не реагирует на запись из другого потока. В -O0 чтение остаётся в памяти на каждой итерации, и код «работает». Исправление — std::atomic<bool> с явной семантикой памяти, а не volatile: volatile не даёт гарантий потокобезопасности.
Таблица: что определено, а что нет
| Операция | Статус по стандарту | Когда всплывает под нагрузкой |
|---|---|---|
Переполнение signed int | UB | Счётчики и таймстампы на пике |
Переполнение unsigned | Определено (обёртка) | Логическая ошибка, но не UB |
| Гонка данных без атомиков | UB | При росте параллелизма |
| Use-after-free | UB | Когда аллокатор быстро переиспользует память |
| Выход за границу массива | UB | На редких входных индексах из потока |
Разыменование nullptr | UB | Проверки удаляются оптимизатором |
| Сдвиг на >= ширины типа | UB | Динамический сдвиг на данных |
Как ловить это до прода
Санитайзеры от Clang и GCC — не отладочная роскошь, а обязательный этап CI для C++-сервисов под нагрузкой.
- UBSan —
-fsanitize=undefined. Ловит знаковое переполнение, сдвиги, разыменованиеnullptr, выход за границы. Прогоняйте тесты и нагрузочный сценарий с ним. Работает в связке с-O1и выше. - ASan —
-fsanitize=address. Ловит use-after-free и выход за границы буфера. Замедляет примерно в 2 раза и увеличивает потребление памяти, поэтому в проде его не держат, но в нагрузочном тесте — обязательно. - TSan —
-fsanitize=thread. Ловит гонки данных. Именно он находит неатомарные флаги и разделяемое состояние. Прогоняйте под тем уровнем параллелизма, что близок к боевому. - Warnings как ошибки.
-Wall -Wextra -Werrorплюс-Wstrict-aliasing. Часть UB компилятор диагностирует статически.
Собирать релиз одновременно со всеми санитайзерами нельзя — ASan и TSan несовместимы в одной сборке. Делайте отдельные конфигурации CI.
Замеры: что и как мерить
Точные цифры замедления зависят от вашего кода и данных, поэтому измеряйте на своём нагрузочном стенде, а не берите числа со стороны. Ориентиры из документации санитайзеров: ASan замедляет исполнение примерно вдвое и заметно поднимает пиковое потребление памяти; TSan — сильнее по CPU и по памяти. Прогоняйте нагрузочный тест той же длительности и с тем же профилем запросов, что и в проде: короткий прогон не успеет вскрыть UB, зависящий от переиспользования памяти или редких входов.
Где подход ломается
Санитайзеры находят UB только на тех путях исполнения, которые реально прошли во время прогона. Ветка, не покрытая тестом или нагрузочным профилем, останется непроверенной — покрытие здесь так же важно, как и сами санитайзеры. UBSan не ловит всё: например, нарушение strict aliasing он видит частично. И ни один динамический инструмент не гарантирует отсутствие UB — он лишь фиксирует то, что случилось при конкретном прогоне.
Отдельная ловушка — оптимизации, зависящие от уровня -O. Код без UB-предупреждений на -O1 может повести себя иначе на -O3, если где-то остался тонкий UB, который агрессивный инлайнинг вытащил на поверхность. Тестируйте на том уровне оптимизации, с которым собираете релиз.
Что делать дальше
Заведите три отдельные конфигурации CI: UBSan+тесты, ASan+нагрузочный прогон, TSan+нагрузочный прогон под боевым уровнем параллелизма. Включите -Werror и -Wstrict-aliasing. Для новых счётчиков и арифметики на данных из запросов используйте __builtin_*_overflow или проверки до операции, а разделяемое между потоками состояние держите в std::atomic с осознанным выбором семантики памяти. Следующий шаг для чтения — раздел про UB и оптимизации в документации Clang (UndefinedBehaviorSanitizer) и главы стандарта про модель памяти, если вы работаете с lock-free структурами.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Clang UndefinedBehaviorSanitizer documentation, GCC Instrumentation Options (sanitizers)
Частые вопросы
У меня GCC, а не Clang. Санитайзеры работают?
Санитайзеры ничего не нашли, но сервис всё равно падает под нагрузкой. Что делать?
Достаточно ли поставить volatile вместо atomic для флага между потоками?
Переполнение unsigned — это тоже UB?
Можно ли собрать один бинарник со всеми санитайзерами сразу?
Почему на -O0 всё работает, а на -O2 ломается — значит, баг в компиляторе?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.