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:

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:

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:

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.