C++26: Trivial infinite loops are no longer undefined behaviour

int main() {
    while (true)
        ;
}

Als je "ja" zei, dan heb je het fout — althans, vóór C++26. Een while (true); lus zonder zijeffecten was ongedefinieerd gedrag (undefined behaviour of UB). Compilers mochten er vanuit gaan dat de lus termineert, en sommige — in het bijzonder Clang — zouden de lus volledig weg-optimaliseren, met spectaculaire gevolgen:

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

int main() {
    while (true)
        ;
}

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

In Clang print dit "Hello world!". De compiler verwijdert de oneindige lus, main loopt door en de door de linker geplaatste unreachable() functie wordt uitgevoerd. Dit is geen compilerbug — het is simpelweg UB, wat nog steeds beter is dan "nasale demonen".

Onlangs schreef ik over hoe C++26 ongedefinieerd gedrag vermindert, waarbij ik wijzigingen behandelde zoals foutief gedrag bij ongeïnitialiseerde leesacties en het onmogelijk maken van het verwijderen van incomplete types (incomplete-type deletes). Ik was dit specifieke punt volledig vergeten. Ik realiseerde me dit pas tijdens de voorbereiding van een komende CppCon-presentatie over C++26-functies — dus hier is het nu.

C++26 lost dit op met P2809R3. Triviale oneindige lussen zijn nu goed gedefinieerd. Het genoemde voorstel is ook geaccepteerd als een defectrapport, dus implementaties kunnen de correctie mogelijk ook toepassen op eerdere C++-modi. Dat is de reden waarom je het oude gedrag mogelijk niet kunt reproduceren op een recente compiler, zelfs niet in de C++20-modus.

Hoe zijn we hier terechtgekomen?

Het verhaal begint bij de forward progress guarantee (voorwaartse voortgangsgarantie), geïntroduceerd in C++11 samen met threading-ondersteuning. De standaard stelt ([intro.progress]) dat de implementatie mag aannemen dat elke thread uiteindelijk een van de volgende acties zal uitvoeren: termineren, een library I/O-functie aanroepen, een volatile glvalue benaderen, of een synchronisatie- of atomaire operatie uitvoeren.

Een while (true); lus doet niets van dat alles. Volgens de regels voor voorwaartse voortgang van vóór C++26 heeft een executie die eeuwig in zo'n lus blijft ongedefinieerd gedrag. De optimizer kan daarom aannemen dat de executie daar nooit in vastloopt, wat transformaties mogelijk maakt die de lus verwijderen en het pad als onbereikbaar markeren.

Het grappige is dat C dit correct had. Zowel C++11 als C11 introduceerden regels voor voorwaartse voortgang, maar C voegde één extra regel toe: lussen waarvan de controlerende expressie een constante expressie is, mogen niet worden beschouwd als terminerend. Dus while (1); is goed gedefinieerd in C11 en alle versies daarna.

C++ heeft die extra regel nooit overgenomen. Het resultaat was de onnodige divergentie die zojuist is beschreven: while (1); was goed gedefinieerd in C, maar ongedefinieerd gedrag in C++.

Waarom zou iemand while (true); schrijven?

Wat ik ontdekte is dat dit gebruikelijk is in embedded- en kernelcode als een halt-on-error patroon. Wanneer een fatale fout optreedt en er geen besturingssysteem is om naar terug te keren, stop je simpelweg:

if (hardware_init_failed()) {
    log_error("fatal: hardware init failed");
    while (true)
        ;  // halt — er is niets meer te doen
}

Dit is niet alleen een veelvoorkomend patroon op bare metal — het was in C++ ook ongedefinieerd gedrag. De consequenties zijn niet theoretisch. Wanneer de optimizer de lus verwijdert, loopt de executie door in whatever code de linker erachter heeft geplaatst — zoals het "Hello world!"-voorbeeld aan het begin van dit artikel demonstreert. In een embedded systeem betekent dit dat een fatale foutafhandelaar het apparaat niet daadwerkelijk stopt. De hardware blijft draaien in een corrupte staat en voert de instructies uit die toevallig volgen. In beveiligingskritische code is dat een reëel kwetsbaarheid.

Wat C++26 verandert

C++26 kopieert echter niet simpelweg de regel van C. Die aanpak is overwogen en afgewezen. C beschermt een veel bredere set lussen — grofweg lussen waarvan de controlerende expressie een constante expressie is — wat nuttige optimalisaties zou kunnen belemmeren. In plaats daarvan definieert P2809R3 een bewust smalle categorie: de triviale oneindige lus (trivial infinite loop). Deze wordt gedefinieerd door twee voorwaarden:

  1. De lus moet een triviaal lege iteratiestatement zijn — wat betekent dat het lichaam letterlijk leeg is (; of {}). Elke niet-lege statement in het lichaam, zelfs een betekenisloze expressiestatement zoals "a string";, diskwalificeert de lus.
  2. De controlerende expressie moet een constante expressie zijn die evalueert naar true. Voor een for-lus zonder conditie is true impliciet.

Wanneer aan beide voorwaarden is voldaan, wordt het lichaam van de lus vervangen door een aanroep naar std::this_thread::yield(). Dit geeft de executie van de lus de voorwaartse voortgangssemantiek die deze voorheen miste.

Hier is een overzicht van wat wel en niet kwalificeert:

CodeTriviale oneindige lus?Opmerking
while (true);Ja
for (;;);Ja
do {} while (true);Ja
constexpr bool go = true; while (go);Jago is een constante expressie
while (true) { "a string"; }NeeLichaam bevat een statement
while (true) if (done) break;NeeLichaam is niet leeg
while (true) if constexpr (false) break;NeeKomt niet overeen met de syntaxis van een triviaal lege iteratiestatement
bool done = false; while (!done);NeeGeen constante expressie

De wijziging actualiseert ook de voorwaartse voortgangsgarantie zelf: een thread mag nu "de executie van een triviale oneindige lus voortzetten" als een van de zaken waarvan wordt aangenomen dat deze uiteindelijk gebeuren. De optimizer kan een triviale oneindige lus dus niet langer behandelen als ongedefinieerd gedrag en aannemen dat de executie erlangs loopt.

De beperking voor freestanding implementaties

Bij freestanding implementaties is het implementatie-afhankelijk of de vervanging door std::this_thread::yield() überhaupt plaatsvindt. Dat is belangrijk voor bare-metal systemen: het veranderen van een bewuste halt-lus in een coöperatieve yield zou gedrag kunnen introduceren dat de programmeur nooit heeft bedoeld.

Conclusie

Dat while (true); ongedefinieerd gedrag was, was een van die C++-feiten die iedereen verrasten die erover hoorde. Het was een onnodige divergentie van C, het brak echte embedded-code en compilers maakten er daadwerkelijk gebruik van. C++26 lost dit op — triviale oneindige lussen zijn nu goed gedefinieerd en de compiler kan ze niet langer weg-optimaliseren.