C++26 introduceert een belangrijke wijziging via voorstel P2809R3: triviale oneindige lussen zijn niet langer ongedefinieerd gedrag (UB). Voorheen konden compilers, zoals Clang, lussen zonder zijeffecten volledig weghalen, wat ertoe leidde dat het programma simpelweg doorliep naar volgende instructies in het geheugen.
Belangrijkste punten uit het artikel:
- Forward Progress Guarantee: Vóór C++26 moest een thread voorwaartse voortgang tonen (zoals I/O-functies aanroepen of atomaire operaties uitvoeren). Een lege
while(true); lus voldeed hier niet aan, wat resulteerde in UB.
- Verschil met C: Terwijl C++ dit als UB behandelde, was
while(1); in C al langer goed gedefinieerd.
- Risico's voor Embedded systemen: In bare-metal en kernelcode wordt
while(true); vaak gebruikt als halt-on-error patroon. Wanneer een compiler deze lus weg-optimaliseert, blijft de hardware draaien in een corrupte staat, wat een beveiligingsrisico vormt.
- De oplossing: C++26 definieert een 'triviale oneindige lus' als een lus met een leeg lichaam en een constante expressie die
true is. Deze lussen worden nu behandeld als een aanroep naar std::this_thread::yield(), waardoor ze legitieme voorwaartse voortgang bieden en niet langer verwijderd mogen worden door de optimizer.
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:
- 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.
- 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:
| Code | Triviale oneindige lus? | Opmerking |
while (true); | Ja | |
for (;;); | Ja | |
do {} while (true); | Ja | |
constexpr bool go = true; while (go); | Ja | go is een constante expressie |
while (true) { "a string"; } | Nee | Lichaam bevat een statement |
while (true) if (done) break; | Nee | Lichaam is niet leeg |
while (true) if constexpr (false) break; | Nee | Komt niet overeen met de syntaxis van een triviaal lege iteratiestatement |
bool done = false; while (!done); | Nee | Geen 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.
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:
- 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.
- 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:
| Code | Triviale oneindige lus? | Opmerking |
while (true); | Ja | |
for (;;); | Ja | |
do {} while (true); | Ja | |
constexpr bool go = true; while (go); | Ja | go is een constante expressie |
while (true) { "a string"; } | Nee | Lichaam bevat een statement |
while (true) if (done) break; | Nee | Lichaam is niet leeg |
while (true) if constexpr (false) break; | Nee | Komt niet overeen met de syntaxis van een triviaal lege iteratiestatement |
bool done = false; while (!done); | Nee | Geen 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.