Проблема, с которой мы начинаем

Простой вопрос: является ли эта программа хорошо определённой?

int main() {
    while (true)
        ;
}

До C++26 ответ был отрицательный. Цикл while (true); без побочных эффектов считался неопределённым поведением. Компиляторы могли предполагать, что он завершится, и некоторые — в частности Clang — просто удаляли его при оптимизации, с впечатляющими последствиями:

// https://godbolt.org/z/WYMxxeW1T
#include <iostream>

int main() {
    while (true)
        ;
}

void unreachable() {
    std::cout << "Hello world!" << std::endl;
}

В Clang это выведет "Hello world!". Компилятор удаляет бесконечный цикл, `main` падает дальше в память, и функция `unreachable()`, размещённая линкером, выполняется. Это не баг компилятора — это просто неопределённое поведение, всё же лучше, чем "nasal demons".

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

Как мы сюда пришли

История начинается с гарантии прямого прогресса (forward progress guarantee), введённой в C++11 вместе с поддержкой многопоточности. Стандарт говорит ([intro.progress]), что реализация может предполагать, что любой поток в конечном итоге сделает одно из следующего: завершится, вызовет функцию библиотеки I/O, обратится к волатильному lvalue, или выполнит синхронизацию или атомную операцию.

Цикл while (true); ничего из этого не делает. Под правилами прямого прогресса до C++26 выполнение, которое остаётся в таком цикле бесконечно, имело неопределённое поведение. Оптимизатор мог предположить, что выполнение никогда не застревает там, что позволяло преобразования, удаляющие цикл и помечающие путь как недостижимый.

Забавно, что язык C сделал это правильно. И C++11, и C11 ввели правила прямого прогресса, но C включил ещё одно: циклы, управляющее выражение которых является constant expression, могут не предполагаться завершающимися. Таким образом, while (1); был хорошо определён в C11 и с тех пор.

C++ никогда не принял это дополнительное правило. Результат — ненужное расхождение: while (1); был хорошо определён в C, но неопределённое поведение в C++.

Но почему вообще кто-то пишет while (true);?

Это распространено во встроенном и kernel коде как pattern остановки при ошибке. Когда происходит фатальная ошибка и нет операционной системы для выхода, вы просто останавливаетесь:

if (hardware_init_failed()) {
    log_error("fatal: hardware init failed");
    while (true)
        ;  // halt — there's nothing left to do
}

Что меняет C++26

C++26 не просто копирует правило C. Такой подход рассматривался и отклонён. C защищает гораздо более широкий набор циклов — примерно все циклы с constant expression в управляющем выражении — что могло бы помешать полезным оптимизациям. Вместо этого P2809R3 определяет умышленно узкую категорию: тривиальный бесконечный цикл. Он определяется двумя условиями:

  1. Цикл должен быть тривиально пустым оператором итерации — его тело буквально пусто (; или {}). Любое непустое утверждение в теле, даже бессмысленное выражение вроде "a string";, дисквалифицирует его.

  2. Управляющее выражение должно быть constant expression, вычисляющимся в true. Для `for` цикла без условия `true` подразумевается.

Когда оба условия выполнены, тело цикла заменяется вызовом std::this_thread::yield(). Это даёт выполнению цикла семантику прямого прогресса, которой ему прежде не хватало.

Вот что квалифицируется, а что нет:

КодТривиальный бесконечный цикл?
while (true);Да
for (;;);Да
do {} while (true);Да
constexpr bool go = true; while (go);Да — `go` это constant expression
while (true) { "a string"; }Нет — тело содержит утверждение
while (true) if (done) break;Нет — тело не пусто
while (true) if constexpr (false) break;Нет — не соответствует синтаксису тривиально пустого оператора итерации
bool done = false; while (!done);Нет — не constant expression

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

Оговорка для freestanding реализаций

На freestanding реализациях определяется на уровне реализации, происходит ли вообще замена на std::this_thread::yield(). Это важно для bare-metal систем: превращение преднамеренного цикла остановки в кооперативный yield может привести к поведению, которого программист никогда не предполагал.

Заключение

То, что while (true); было неопределённым поведением, всегда удивляло всех, кто об этом слышал. Это было ненужное расхождение с C, оно сломало настоящий встроенный код, и компиляторы действительно его эксплуатировали. C++26 это исправляет — тривиальные бесконечные циклы теперь хорошо определены, и компилятор больше не может их оптимизировать.