Оптимизации компилятора C++: где -O2 ломается первым
Компилятор переставляет чтения памяти, выкидывает «мёртвые» проверки и склеивает вызовы. Под многопоточной нагрузкой это оборачивается гонками и зависаниями, которых в -O0 не было.

Код собирается в -O2, проходит тесты, уходит в прод — и на трёхстах одновременных потоках цикл ожидания флага перестаёт завершаться, хотя другой поток флаг давно выставил. В отладочной сборке -O0 всё работает. Это не «плавающий баг» и не железо: компилятор имеет право переставить и выкинуть ваши обращения к памяти, и на горячем пути он этим пользуется. Разберём, какие именно оптимизации GCC 13/14 и Clang 17/18 делают за вас на уровнях -O2 и -O3, и где они первыми расходятся с тем, что вы имели в виду.
Что вообще разрешено компилятору
Стандарт C++ описывает поведение программы через abstract machine и правило as-if: компилятор может переписать код как угодно, лишь бы наблюдаемое поведение однопоточной программы без UB совпадало с эталонным. Наблюдаемое — это ввод-вывод, обращения к volatile и синхронизация. Всё остальное — чтения и записи обычной памяти, порядок вычислений, существование локальных переменных — компилятор волен менять.
Отсюда два практических следствия. Первое: пока в программе нет неопределённого поведения и нет параллелизма, оптимизации не видны — результат тот же, просто быстрее. Второе: как только появляется UB или второй поток, невидимое становится видимым. Ломается не оптимизация — ломается предположение, что компилятор исполняет строки по порядку.
Отладочная сборка, которая «работает», в многопоточном коде ничего не доказывает. Она просто прячет гонку, отключив оптимизации, которые её проявляют.
Оптимизации, которые кусаются первыми
1. Подъём чтения из цикла (LICM)
Классический сломанный спин-луп:
// GCC 13, -O2. flag — обычный bool, БЕЗ atomic
bool flag = false;
void wait_loop() {
while (!flag) {
// ждём, пока другой поток выставит flag
}
}
Loop-invariant code motion видит, что внутри цикла flag не меняется, и поднимает чтение наружу. Получается фактически:
// во что это превращается по смыслу
void wait_loop() {
bool tmp = flag;
if (!tmp) {
while (true) {} // вечный цикл
}
}
Компилятор прав: в однопоточной модели flag действительно не может измениться внутри цикла, а параллельная запись без синхронизации — это data race, то есть UB. Лечится не volatile, а std::atomic<bool>: тогда каждое чтение становится наблюдаемой операцией и поднимать его нельзя.
2. Устранение «мёртвого» кода после разыменования
Если вы разыменовали указатель, компилятор считает его ненулевым — иначе было бы UB. И выкидывает проверку, которая идёт после:
int read_value(int* ptr) {
int value = *ptr; // раз разыменовали — ptr не null
if (ptr == nullptr) { // значит, эта ветка мёртвая
return -1; // и её удалят вместе с проверкой
}
return value;
}
На -O2 проверка на nullptr исчезает целиком. Если ptr действительно приходит нулевым, программа падает на *ptr, а не возвращает -1, как вы рассчитывали читая исходник. Проверять надо до первого использования.
3. Переупорядочивание записей и store-to-load forwarding
Компилятор свободно меняет порядок записей в разные ячейки, если между ними нет зависимости по данным. Пара «данные + флаг готовности», написанная наивно, разъезжается:
int data = 0;
bool ready = false;
void producer() {
data = 42; // (A)
ready = true; // (B) — B может уехать перед A
}
В однопоточной программе порядок A и B неразличим. Для второго потока, читающего ready, — принципиален. Барьер задают atomic-переменные с нужным memory order, а не расстановка строк.
4. Автовекторизация и strict aliasing
На -O3 (и на -O2 в свежих Clang) циклы векторизуются в SIMD. Векторизация опирается на правило строгого алиасинга: указатели разных несовместимых типов не ссылаются на одну память. Код, который «переинтерпретирует» float через int*, работает в -O0 и молча даёт мусор в -O3. Безопасный способ переинтерпретации битов — std::memcpy или std::bit_cast (C++20), а не приведение указателей.
Как это выглядит под нагрузкой
Симптомы предсказуемы по типу оптимизации:
- Зависание одного потока при росте параллельности — почти всегда поднятое из цикла чтение неатомарного флага (LICM). В
-O0не воспроизводится. - Редкий сегфолт под нагрузкой — удалённая проверка на null или гонка на общем указателе; частота растёт с числом ядер.
- Неверные данные без падения — переупорядоченные записи или нарушение strict aliasing; выглядит как «иногда приходит старое значение».
- Разное поведение GCC и Clang на одном коде — верный признак UB: два компилятора по-разному воспользовались одной и той же свободой.
Чем ловить: не глазами, а инструментами
Читать ассемблер построчно на большом проекте бессмысленно. Работают три вещи.
- Санитайзеры. Собрать под нагрузочный тест с
-fsanitize=thread— ThreadSanitizer ловит именно data race, тот самый сломанный спин-луп. Отдельно-fsanitize=undefinedловит UB вроде разыменования null и переполнения. Санитайзеры замедляют в разы, поэтому их гоняют на стенде, а не в бою. - Godbolt (Compiler Explorer). Вставить подозрительную функцию, поставить нужную версию компилятора и флаги, посмотреть, что осталось от вашей проверки в ассемблере. Быстрее, чем спорить в ревью.
- Диагностики компилятора.
-Wall -Wextra, а для алиасинга —-fstrict-aliasing -Wstrict-aliasing. Часть будущих сюрпризов компилятор предскажет предупреждением ещё на сборке.
Что где ломается: сводка
| Оптимизация | Уровень | Симптом под нагрузкой | Что писать вместо |
|---|---|---|---|
| Подъём чтения из цикла (LICM) | -O1 и выше | Поток зависает в while | std::atomic для общего флага |
| Удаление мёртвой проверки после разыменования | -O2 | Редкий сегфолт вместо возврата ошибки | Проверять null до использования |
| Переупорядочивание записей | -O2 | Читатель видит старые данные | atomic с memory order |
| Автовекторизация + strict aliasing | -O3 | Мусор в результатах, только в релизе | memcpy / std::bit_cast |
Когда оптимизации не при чём
Не всякий баг в релизе — вина компилятора. Если код собирается с -fno-strict-aliasing и не содержит гонок, а расхождение с -O0 остаётся — вероятнее ошибка в самой логике, которая в отладке маскируется нулевой инициализацией стека или другим раскладом памяти. Прежде чем винить -O2, прогоните санитайзеры: если они молчат, а на Godbolt ваши проверки на месте, ищите UB в другом месте.
Отдельная граница — volatile. Он запрещает выбрасывать и переупорядочивать обращения относительно других volatile, но не даёт межпоточной синхронизации и не является заменой atomic. Для регистров железа — да, для общих флагов между потоками — нет.
Что делать дальше
Заведите в CI отдельную сборку под -fsanitize=thread и -fsanitize=undefined, которую гоняете на нагрузочных сценариях, а не только на юнит-тестах — большинство описанных проблем проявляется лишь при реальной параллельности. Пройдитесь по коду в поисках общих переменных без std::atomic и мьютексов: это первые кандидаты на поломку при повышении уровня оптимизации. И держите привычку: если в релизе поведение отличается от отладки, сначала Godbolt и санитайзеры, потом гипотезы. Инвалидация предположений о памяти между версиями компилятора — отдельная тема; здесь мы разобрали только то, что стабильно ломается на -O2 и -O3 уже сегодня.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: GCC Optimize Options (официальная документация опций оптимизации), cppreference: std::memory_order
Частые вопросы
У меня GCC 12, а не 13 — эти оптимизации тоже работают?
Поможет ли volatile вместо std::atomic для флага между потоками?
Санитайзеры молчат, но баг в релизе остаётся. Что дальше?
Можно ли просто собирать прод с -O0, чтобы не ловить эти сюрпризы?
Как отличить баг компилятора от UB в моём коде?
-O3 всегда быстрее -O2?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.