C++26: Trivial infinite loops are no longer undefined behaviour
20 points by raymii
20 points by raymii
I was hoping for a discussion in P2809 of why C++ can’t “just” adopt C’s version of the rule, but I didn’t find one.
The forward progress guarantee seems weird to me. I understand its value as a property of the infrastructure on which the program is running (the locking primitives, the scheduler, etc.) but I don’t understand why it’s necessary to extend the requirement to programs that don’t necessarily need or want that guarantee.
The idea is that you can implement multi threading in C++ in user space without parallelism by context switching at an observable checkpoint (e.g. I/O, volatile, synchronization primitive).
That way you can just round-robin between threads and it is not observable that you don't actually have parallelism.
If you just allow infinite loops, one thread being stuck would prevent the other ones from making progress. So instead, a subset of them are transformed into essentially repeated yield calls.
I don't see how that's nearly strong enough motivation to make this UB.
First of all, only a small fraction of programs will actually be executed in such a way, but the fwd progress UB is something every C++ programmer needs to deal with.
Secondly, if we compare Rust (no "UB for infinite loops" of any sort) with C++ for this usecase, we have:
The full UB can lead to the compiler reordering things in ways that make debugging this a nightmare. I'll take the Rust variant of this over the C++ variant any day.
A program with an infinite "pure" loop has a liveness bug, yes. But I think it is a terrible idea to turn that liveness bug into UB. That kind of bug amplification is completely misguided IMO.
How does Rust handle infinite loops? The reason that they're UB in C/C++ is that they make a load of seemingly simple optimisations unsound. Most optimisations that move things from loop bodies or before a loop to after a loop are sound on the assumption that the loop eventually terminates. If the loop is permitted to never terminate, these transforms are unsound. These are very simple transforms to state and so have been done by most compilers for decades but the implicit assumption is always that loops terminate. This also means that functions can be assumed to eventually return (C and C++ now have attributes for explicitly stating that they don't).
If the language says infinite loops are permitted then the compiler can do these transforms if and only if it can statically prove that the loop terminates. This is impossible in the general case and turns out to be very hard in a bunch of other cases.
If anything, I'd have thought that Rust's ownership model increased the set of loops that can't be statically proven to not terminate but can be statically proven to not have globally-visible side effects, since most Rust loops can be statically proven to not mutate aliased state, which makes this assumption more important.
This was actually broken for a long time in Rust, meaning you could get UB-like behavior from safe code such as loop { } (In a language-lawyery way, it was not UB, simply because the language says safe code has no UB. In a practical way, it behaved exactly like it). The reason was that LLVM had indeed baked the assumption that loops eventually terminate or do side-effects deeply into its optimizations.
However, starting with LLVM 12, that assumption is no longer hardcoded, or even the default. You need to use the mustprogress attribute to indicate to LLVM that it can assume forward progress. What rustc does now is simply not emit that attribute, just like Clang does for loops whose controlling expression is an integer constant expression. Of course, if the compiler frontend can statically prove that the loop eventually makes progress, it can still soundly emit that attribute, but then LLVM itself is pretty good at control flow analysis too so maybe it would just figure that out anyway.
So tl;dr how Rust handles infinite loop without side effects is to completely remove the UB (which is basically forced by the design constraint that safe code has no UB) and to eat the potential missed optimizations.
Is there any data on how much this costs in terms of performance? I'd be surprised if it's small given how fundamental the transforms that it enables are to enabling later optimisations.
I'm also somewhat curious of the extent to which LLVM actually skips doing optimisations if they're unsound in the presence of infinite loops. Given that none of these transforms are formally verified, I wouldn't be surprised if it's still doing all of the transforms under unsound assumptions, just not folding infinite loops to trap.
When we looked at this for another language, our conclusion was that we'd impede too many optimisations if the compiler had to prove that every loop it wanted to move data across would terminate.
Finally :)
Reminds me somehow how this C++ UB made into a bug in rustc because of LLVM.
yeah, for all the llvm is a "generic compiler backend" it is really geared around C/C++ semantics. e.g. null dereferences are UB through out the optimization pipeline, because it's ub in C.
There is the "null dereference is not UB" model (you get it from -fno-delete-null-checks in clang), but that's super conservative. The model it presents is that null pointers are valid pointers, which means if you have:
int *p = ...;
*p = ...;
if (!p) { ... }
Does not remove the null check (the model is null is a valid address to access, so the dereference proves nothing about the value).
Note that the actual UB-ness around null pointers in C/C++ does make sense in totality, e.g
int *p = 0;
int *q = p + 100;
*q = 1;
q is not the null pointer, but the result is UB because the dereference is fundamentally a null dereference - which is more clear when you think about it as being p[100] = 1.
It's gotten a lot better though. Rust has much less UB than C and C++, especially around (raw) pointer use, and we can use LLVM for this just fine these days.
Hmm, I'm not sure this is an improvement all things considered. Yes it removes UB from infinite loops but only in "trivial" cases, and the definition of trivial seems a bit non-trivial. Worse, they use a different definition than C (even showing examples in the paper of loops that are fine in C but will continue to be UB in C++26).
So there is technically less UB but the edge has become more jagged and the cognitive load to avoid it is more. It can be better to have a somewhat overly restrictive rule that is easier to remember and follow.
I do like that they threw a bit of heat in the paper:
goto, setjmp/longjmp, infinite tail recursion, nor looping with signal handlers. While they are also forms of loops, infinite versions of them are not idiomatic in C and C++. Functional programmers might be upset at this fact.
looping with signal handlers
How impertinent to say this prizewinning masterpiece is not idiomatic! https://www.ioccc.org/1987/wall/index.html
It really is a surprisingly spicy paper.
CWG pointed out that some loops might be infinite at compile-time but not runtime, and felt very good about themselves.