Ch19.8: The Issues With Legacy C++ Exceptions
1. The List
Ch19.3 taught the mechanism and Ch19.4 and Ch19.5 taught the two libraries that come with it. This page is the bill. Five properties make legacy exceptions unusable as a general error-reporting mechanism, and only the first one is about speed:
| Issue | Consequence |
|---|---|
| 1. The failure path allocates and walks memory | Unbounded latency, a second failure mode, and no constant cost you can budget for |
| 2. It needs a runtime the target may not have | Does not exist on freestanding, embedded, kernels, WebAssembly, or any
-fno-exceptions build |
| 3. The error is not part of the type | No caller can see what a function may fail with; the contract is invisible (Ch6.16) |
| 4. It is unsound when combined with the rest of the language | A throw during unwinding, from a destructor, or out of noexcept calls
::std::terminate |
| 5. Two ABIs, no bridge | An exception cannot cross an Itanium ↔ MSVC boundary; tables, personality routines and object layouts all differ |
2. Issue 1: What One Throw Costs, Measured
A legacy throw is not a jump. It allocates the exception object with
__cxa_allocate_exception — a heap allocation, on the failure path, in
every implementation — then calls __cxa_throw, which starts a two-phase
walk of the stack that consults the personality routine for every frame, matches handlers
through the run-time type information, destroys everything it walked past, and only then
runs the handler.
The program below throws the same error two hundred thousand times: half as a legacy
::std::runtime_error, half as a herbception. Both loops are fed from the same
array so that nothing can be folded away, and both record how many errors reached the
handler.
#include <fast_io.h>
#include <stdexcept>
namespace
{
constexpr ::std::size_t round_count{200000};
constexpr ::std::size_t small_mask{3};
inline ::std::int_least64_t to_ns(::fast_io::posix_timespec64 d) noexcept
{
return static_cast<::std::int_least64_t>(d.tv_sec) * 1000000000 + static_cast<::std::int_least64_t>(d.tv_nsec);
}
/* the values live in memory the optimizer cannot reason about */
::std::size_t count_legacy(::std::uint_least32_t const *values) throws
{
::std::size_t caught{};
for (::std::size_t i{}; i != round_count; ++i)
{
try
{
if (values[i] == 0)
{
throw ::std::runtime_error("riser");
}
}
catch (::std::exception const &)
{
++caught;
}
}
return caught;
}
::std::size_t count_herbception(::std::uint_least32_t const *values) throws
{
::std::size_t caught{};
for (::std::size_t i{}; i != round_count; ++i)
{
try
{
if (values[i] == 0)
{
throw throws ::std::errc::invalid_argument;
}
}
catch throws (::std::error e)
{
++caught;
}
}
return caught;
}
} // namespace
int main()
{
auto *buffer{static_cast<::std::uint_least32_t *>(::std::malloc(round_count * sizeof(::std::uint_least32_t)))};
if (buffer == nullptr)
{
::std::abort();
}
for (::std::size_t i{}; i != round_count; ++i)
{
buffer[i] = static_cast<::std::uint_least32_t>((i * 2654435761u) >> 7) & small_mask;
}
::std::size_t legacy_caught{};
::std::size_t herbception_caught{};
::std::int_least64_t legacy_ns{};
::std::int_least64_t herbception_ns{};
try
{
auto const start{::fast_io::posix_clock_gettime(::fast_io::posix_clock_id::monotonic_raw)};
legacy_caught = count_legacy(buffer);
legacy_ns = to_ns(::fast_io::posix_clock_gettime(::fast_io::posix_clock_id::monotonic_raw) - start);
auto const start2{::fast_io::posix_clock_gettime(::fast_io::posix_clock_id::monotonic_raw)};
herbception_caught = count_herbception(buffer);
herbception_ns = to_ns(::fast_io::posix_clock_gettime(::fast_io::posix_clock_id::monotonic_raw) - start2);
}
catch throws (::std::error e)
{
::fast_io::io::print("error ", e, "\n");
return 1;
}
::std::free(buffer);
::fast_io::io::print("caught ", legacy_caught, " ", herbception_caught, " throws out of ", round_count, "\n"
"total ns ", legacy_ns, " ", herbception_ns, "\n"
"ps per throw ", legacy_ns * 1000 / static_cast<::std::int_least64_t>(legacy_caught), " ",
herbception_ns * 1000 / static_cast<::std::int_least64_t>(herbception_caught), "\n");
}
caught 49999 49999 throws out of 200000
total ns 46726099 44253
ps per throw 934540 885
Roughly one microsecond against one nanosecond. The interesting number is not the ratio; it is that the legacy failure path allocates and walks memory, which means it has a second way to fail (out of memory while reporting an error), a second way to be slow (cache misses on the frame tables), and no constant bound at all. The herbception path is two registers and a conditional jump, which is why the compiler can delete it entirely when nothing uses the result.
There is a version of this measurement that matters more than the average: the worst case. A legacy throw walks every frame between the throw and the handler, consults tables that must be registered with the loader, and can trigger a linear scan of loaded objects on some platforms. In a program with deep stacks and many shared libraries the same throw can cost orders of magnitude more than the nanosecond-per-frame average, which is why legacy exceptions are unsuitable for anything with a latency requirement.
3. Issue 2: What It Requires From the Environment
Compile a legacy-throwing function to an object file and ask what it still needs:
__cxa_allocate_exception
__cxa_throw
__cxa_begin_catch
__cxa_end_catch
__gxx_personality_v0
_Unwind_Resume
Four of those are the exception machinery itself: allocate, throw, begin/end catch, and the
itanium personality routine __gxx_personality_v0 — the function the
unwinder calls to decide whether a frame catches anything (plus
_Unwind_Resume from the platform unwinder). For those calls to work, a binary
also needs:
- an unwinder: a stack walker that understands the target's frame layout;
- frame tables (
.eh_frame) and a language exception table (.gcc_except_table) for every function on the path, registered with the loader so the walker can find them; - a personality routine per ABI, and a runtime that owns the exception object's lifetime;
- RTTI: the type information needed to match a handler.
That is a stack of platform-owned components, and each one can be missing:
| Environment | Why legacy exceptions are not available |
|---|---|
| Freestanding, kernels, bootloaders, embedded | No unwinder, no loader to register tables with, no __cxa_* runtime. The
tables are size you cannot pay, and the failure path would allocate |
| WebAssembly / WASI | Unwinding is not part of the base target: the exception proposal and the JS-hosted
variants all add engine surface, and toolchains ship -fno-exceptions by
default |
Any build with -fno-exceptions |
The compiler refuses the keywords outright. Many toolchains, game consoles, mobile SDKs and C++ standard library builds are configured this way |
| Across a GNU ↔ MSVC boundary | Itanium and Microsoft exception ABIs are different: different tables, different personality routines, different object layout. An exception cannot cross, and the catch site does not exist for it |
Inside a noexcept function |
An exception escaping it calls ::std::terminate |
So the honest statement is not "legacy exceptions are slow". It is:
on most targets you will ship to, they do not exist at all, and where they
do exist they are a heap allocation and a stack walk on the failure path. A library that
reports failures with throw is a library that cannot be used in a kernel, in a
Wasm module, on a board, or in any project compiled with -fno-exceptions. That
is most projects.
4. Issue 3: The Contract Is Invisible
A legacy exception is not in the function's type. This is the issue that costs the most in daily work, and it is the one that has nothing to do with performance:
| Signature | What the caller learns |
|---|---|
int parse(char const *text); |
Nothing |
int parse(char const *text) noexcept(false); |
"It may throw something" — and that is all the language lets it say |
int parse(::std::string_view text) throws; |
The only failures that can arrive are ::std::error values, and the
compiler enforces that every call site handles them |
Every function in a legacy codebase has that second signature whether it is written or not,
so the failure surface of a program is discovered by reading implementations or by running
it. Compare the herbception signature, where the compiler refuses to let a
throws call go unhandled
(Ch6.16): the set of functions that can
fail, and where the failure is handled, is checked mechanically.
5. Issue 4: Unsoundness Around Unwinding
Three situations have no correct behaviour, and the runtime resolves all three by killing the process:
-
A destructor throws while another exception is unwinding. There are now
two exceptions in flight and the runtime cannot choose one, so it calls
::std::terminate. Every legacy codebase contains destructors that "cannot fail" because the alternative is undefined termination. -
A throw escapes a
noexceptfunction. Move constructors arenoexceptfor a reason, and a legacy move that throws turns a container resize into a dead process. -
An exception is thrown where no handler exists in the whole program.
::std::terminateagain, after an unwinding pass that destroyed objects on the way to nothing.
None of this is a bug in an implementation; it is the mechanism. std::terminate
exists precisely because a legacy exception can arrive at a place where the language cannot
proceed — which
Ch19.2 reframes as a plain
fact: some situations end the process, and it is better to end it deliberately.
6. Issue 5: Two ABIs
The Itanium C++ ABI (GCC, Clang, everything on Linux, macOS, and the BSDs) and the Microsoft ABI use different exception tables, different personality routines, different object layouts for the thrown object, and different unwinder entry points. A legacy exception thrown in a GNU-compiled DLL cannot be caught by MSVC-compiled code, and vice versa: the catch site would have to know which runtime to ask, and the object layouts do not agree.
This is the reason the herbception runtime carries
::std::legacy_exception_abi with itanium,
microsoft and platform values
(Ch19.6), and the reason a
two-word ::std::error is the only thing that can cross such a boundary: two
words of C ABI are the same in both worlds, a C++ exception object is not.
7. The Verdict
The mechanisms in this book exist because of these five issues, in that order:
- Errors are two words on the return path, so the failure path is the success path's cost plus a branch — no allocation, no walk, no table (Ch3.16).
- Every function states its contract, and the compiler enforces handling (Ch6.16).
- Corruption ends the process and is not reported as an error (Ch19.2).
- The mechanism works freestanding, which is Ch20's subject.
- The build removes the old mechanism entirely, so it cannot come back (Ch19.7).
You still have to read legacy code, and when a library forces a legacy exception into your program there is one supported way to meet it (Ch19.6). But you never write one, and you never design an interface around one again.