C++ Exit-Time Destructors
maskray.me
maskray.me
C++26 is going to introduce erroneous behavior.
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/p27...
As for non-zero runtime overhead, that is long gone, since stuff like std::map, std::regexp and refusal from compiler vendors to break an ABI that isn't even defined on the ISO level, yet it blocks any proposal that has the side effect of breaking it.
Could not agree more. Modules are a perfect example of this.
> As for non-zero runtime overhead, that is long gone, since stuff like std::map, std::regexp
I have a little sympathy for this - they're libraries that are replaceable (yet you'd think we'd have learned from map, regex, unique_ptr that getting it right first time is impossible, and _maybe_ we should start putting these things in the language), and the overhead there is opt-in. In this case, if I have the following code:
char buf[SOME_VERY_LARGE_CONSTANT];
bool result = fill_buf_from_c_library(buf, sizeof(buf) / sizeof(buf[0]));
assert(result); // we've guaranteed at runtime that this memory is initialized
now comes with a significant performance hit with a compiler upgrade, and requires code changes to fix> refusal from compiler vendors to break an ABI that isn't even defined on the ISO level, yet it blocks any proposal that has the side effect of breaking it.
And yet this is true at the same time. Madness.
In effect unlike the C stdlib, where sure - there's almost nothing in the freestanding library, the C++ stdlib is full of higher level features even in freestanding, more like Rust's core. It's not as rich as Rust's core, but it's definitely in that direction.
So that means there's a responsibility for Quality of Implementation on those features, they're not really "replaceable" because they're in the fundamental standard library even on bare metal which means a "replacement" is just a parallel implementation. That's probably fine to some extent for std::unordered_map, but it's very silly for std::function, and I'd argue it's even silly for std::unique_ptr and std::string.
> now comes with a significant performance hit with a compiler upgrade, and requires code changes to fix
It's a problem that it took C++ until now to fix this, but that's still on them. There is a proposal (which I'd guess you'll hate) to have a Rust-style uninitialized wrapper type so that you can write what you meant here and it's clear to the compiler and to future human maintainers that those ain't chars, those are a kiss of death until after that C function successfully does what it says it does.
The C definition of assert, which C++ inherits, is problematic here, because it's a no-op in release builds.
I think we agree here - you can replace std::unordered_map with absl::map etc, so the impact is lower. I do wish we'd stop doing it, though - see fmt.
I agree that it's incredibly stupid for std::function and std::unique_ptr to not be language level features - unique_ptr is the poster child for something that could faster and safer with less compile time overhead if implemented in the language rather than in the library.
> There is a proposal (which I'd guess you'll hate)
No, I think that's great actually, but I don't want that _and_ noinit/indeterminite (which is proposed in the link above) _and_ a new category of behaviour. I want one option, and I want failure of adherence to it to either be compiler enforced, implelentation defined, or Undefined Behaviour, _not_ "erroneus behaviour".
> The C definition of assert, which C++ inherits, is problematic here, because it's a no-op in release builds.
Sorry, yeah you're totally right. my day to day C++ work has a custom set of assertions that in release builds are basically the following (simplified for posting here) -
#define our_assert(conditional) \ if (!conditional) { \ log_stuff_and_flush() \ std::abort() \ }
> because it's a no-op in release builds.
This falls into the same group of back compat as the unwillingness to break ABI IMO - you can have an optimised build without NDEBUG, or an unoptimised build with NDEBUG, and the side effects of disabling bounds checking is turning assert into a noop.
I hope this renewed focus on safety will lead to the default for what is currently undefined behaviour being more in line with what was originally intended - sort-of arbitrary behaviour dependent on the platform's state at the time of code execution. As in, less constrained than "implementation defined", since there is no guarantee it will produce the same result every time, but more constrained than UB, since it does not invalidate the program.
The discipline required to not shoot your foot off in a non trivial project was far greater than than the extra code needed when writing plain C, or, for bigger projects, C# and Java.
I still reach for C first, and if I need higher level abstractions, I'm reaching for something other than C++.
Since there is no limitation on the no_destroy type, you can create any type with it, including those which have a non-trivial constructor. Of course, you could use a concept to restrain it to only trivially constructible types, or you could omit the launder in an if constexpr branch and still be compliant.
std::launder addresses a specific situation: when an object is replaced by another object of the same type but compiler doesn't know that the first object's lifetime has ended and the same address points to a different object. In this case compiler may still use cached values of constant or reference fields of the dead object or its vptr.
In this case the byte array merely provides storage for an object of type T created by the placement new operator and that object is never replaced by anything. The lifetime of the byte array itself doesn't end when a nested object is created.
Yet the other situation is precisely this one [0] [1]. The byte array does provide storage for the T, but a pointer to the byte array is not a pointer to the T. You need to launder the pointer to get from the former to the latter. Such are the rules when you're bound as strictly to type-based aliasing as standard C++ is.
(Not that any real compiler is nearly as strict about byte arrays in practice: if they were, then many Unix networking APIs would be practically unusable. Indeed, people care about it so little that Linux is happily adding new syscalls that write data into variable-length buffers and expect the user to arbitrarily cast them back into structs.)
The solution is to always terminate all threads before exiting. This requires a bit of internal infrastructure that's missing from many projects. If a cross-platform library is being used, it may start its own untracked threads.
On iOS, app processes only terminate in special circumstances. When this issue yields a crash it's (currently) imperceptible to the user. However, it's still tracked in the crash statistics.
- Executing static destructors without crashing is the ideal result.
- On Mac, there are factors that make the thread-termination approach preferable, such as using cross-platform libraries or a framework like Qt.
- On iOS, none of these apply, as process termination is out of your hands.
Worth noting that global constructors and destructors are generally sucky for a bunch of reasons. Don’t use them unless you really have to. Avoid them in libraries.
Reason to avoid global constructors: there’s no laziness to them; they definitely run on program start even if the global is never used. For small projects, no big deal. For large projects it means a large startup time penalty. Successful projects get big eventually, so if you’re playing the long game you want to assume you’re a big project. If you’re a library writer, it’s probable that you’ll be linked into something big. If you don’t have global ctors then your clients will thank you.
Atexit anything is just pointless, IMO. The best way for a process to exit is to just exit. Trying to “destruct” your state at exit risks race conditions and weird bugs but buys you very little. Even things like flushing or syncing a file probably don’t belong in atexit since you should assume your program will run a long time so you want important stuff flushed and synced way before the exit happens. Also, global dtors are implemented by having a global ctor that does __cxa_atexit, so see previous paragraph.
And as with all programming advice, your mileage may vary and some advice is best ignored. If global ctors/dtors achieve what you want and/or startup time isn’t an issue, then do what you feel, bro
This is a pretty myopic view of things? There are lots of other things you might wish to do when the program terminates besides releasing resources...
Anything the kernel does not do on program exit, which is most things.
This was actually my main reason for using C++ to program the control software: the fact that constructors and destructors run deterministically, especially when something bad happens and an exception is thrown. And while the language doesn't specify in which order they run for global variables in different compilation units, you still know how they are ordered with respect to `main()`, and if you pay attention to how the linker works, you can actually know how they will all be ordered.
Also... can't you make that argument for anything? "Avoid unique_ptr -- can't you just free the memory before exiting?"
Guard g;
// ...call subprocess...
// Guard::~Guard will run at exitedit: to clarify, I'm not dogmatic, I have nothing against implmenting clean shutdown procedures for example to avoid alerts and allow quick restarts that don't need to roll-forward replay logs.
I just think that clean shutdown should be best-effort and never required for consistency/durability/safety.
We are probably in agreement.
Of course, I wouldn't put that clean up in an atexit.
I definitely want buffers to be flushed to disk if the user Ctrl-C's the program. The OS only ensures that the resources are released at the OS level, but does nothing to prevent the persistent application state becoming inconsistent at the application level.
The really hardcore way to do this is to structure your whole program so that all state is recorded as deltas appended to a WAL, with a background thread/process periodically merging this into "current state" -- in that case, even pulling the power cord can't leave your application in an inconsistent state. But that's a whole lot of work, and just best-effort flushing buffers on exit gets you 80% of the way there.
Since it's unspecified, musl's behavior is just as correct as glibc's or BSD's or ...
I actually have both a hard time imagining a better language (not saying it's impossible, just that it's not trivial for me to see) and also how ownership would resolve this issue. Could you elaborate?
libvirt has long been linked with -Wl,-z -Wl,nodelete to avoid this:
https://gitlab.com/libvirt/libvirt/-/commit/8e44e5593eb9b89f...
Another case in libnbd which really demonstrates how hard this is:
https://gitlab.com/nbdkit/libnbd/-/commit/368e3d0d5a8871aad5...
How is this different from holding a pointer to an object being deleted?
I came to accept that in languages like C and C++, the best path for dynamic loading code is really via OS IPC and external processes, the decrease in hardware resources and simpler programming model of using shared libraries as plugins, isn't worth the endless amount of debugging hours regarding program crashes.
Haven’t looked into the bug closely but I will say that WebKit compiles with thread safety on statics turned off, which means they are in a non-standard language mode with far more races.
In many cases, they pull in 100+ kB of useless code that only runs on exit. I've only got 128 kiB of RAM and my program doesn't exit, but good luck convincing the compiler this is true. It's so aggravating.
Sadly, small oversights (e.g., exception handling for undefined virtual methods) can cause disproportionate bloat.
https://isocpp.org/wiki/faq/ctors#static-init-order
If you have two static objects in two different files then good luck if one depends on the other, as the initialization order is not well defined.
If both files are in the same executable then you can get around that by linking the object files in the desired order - well mostly, kind of.
i have hit this problem at some point...
Foo &getInstance() { static Foo instance; return instance; }
Guaranteed to be threadsafe by the standard, even.