Programming Languages · Language Implementation Tour
Exception Unwinding: Table-Driven Cleanup and the Cost of Try
How a raise becomes a search through per-function tables, why some runtimes walk the stack twice, and what the zero-cost claim about exceptions really means.
A try block does nothing on the happy path in a well-implemented runtime. There is no branch, no
frame flag, no registration call. The cost of handling an exception is paid entirely when one is
thrown, and the mechanism that makes this possible is a per-function table that maps instruction
offsets to handlers. The design is the reason exceptions are cheap to have and expensive to use, and
the reason some runtimes walk the stack twice before any handler runs.
Registration versus tables
Two designs exist. The older one registers handlers dynamically: entering a try block pushes an
entry onto a per-thread list of active handlers, and leaving the block pops it. The entry holds the
handler’s address and the frame it belongs to. Raising an exception walks the list from the top,
calling each handler until one claims the exception.
The dynamic design makes entering a try block cost a push and a pop, which is a few instructions but
occurs in the hot path of code that uses exceptions for control flow. It also makes the handler list a
data structure the runtime must maintain per thread, with consequences for reentrancy and for the
state of the list when a handler itself throws.
The table-driven design moves the information into static data. Each function has an exception table listing ranges of instruction offsets, and for each range the handler to enter if an exception is raised inside it, plus the type filter the handler accepts. When an exception is raised, the runtime finds the current instruction’s offset in the current function’s table. If no entry covers it, the function has no handler for this exception, and unwinding proceeds to the caller. If an entry does cover it, the runtime transfers control to the handler.
The per-function table is the same idea as the JVM’s exception_table in a method’s Code
attribute, and as the language-specific exception tables that the Itanium C++ ABI defines. The JVM’s
version is a list of (start_pc, end_pc, handler_pc, catch_type) quadruples; a athrow instruction
searches the current method’s table for an entry whose range contains the current pc and whose
catch_type matches the thrown object’s class or is a catch-all, then jumps to handler_pc with the
exception object on the operand stack.
typedef struct { int start_pc, end_pc, handler_pc; const char *catch_type; } HandlerEntry;
/* Returns the handler for pc, or NULL when this frame does not catch the exception. */
static const HandlerEntry *find_handler(const Method *m, int pc, const char *type) {
for (int i = 0; i < m->nhandlers; i++) {
const HandlerEntry *e = &m->handlers[i];
if (pc < e->start_pc || pc >= e->end_pc) continue; /* range is half-open */
if (e->catch_type == NULL || strcmp(e->catch_type, type) == 0)
return e;
}
return NULL;
}
The half-open range is what makes nested handlers expressible: an inner try occupies a subrange of
the outer one, and the search order decides which wins. Implementations that scan the table in order
get innermost-first behaviour only if the compiler emits the inner entries first, which is a property
of the code generator rather than of the table format.
| Dynamic registration | Static tables | |
|---|---|---|
| Cost entering a protected region | push and pop of a handler record | none |
| Cost leaving normally | pop | none |
| Cost of a throw with a nearby handler | walk the handler list | decode one table, jump |
| Cost of a throw with a distant handler | walk the list plus the frames | decode every frame’s table |
| Binary size | one record per active handler, in memory | a table entry per function, always present |
Neither column dominates. Registration suits languages whose programs enter protected regions often and throw rarely, because the push and pop are cheap and bounded. Tables suit languages where the protected regions are lexically scoped and the compiler can describe them, because the happy path becomes free and the cost moves to the throw.
The two-phase search
The Itanium ABI’s design, which GCC and Clang implement and which Windows’ structured exception handling resembles, splits the operation into two passes.
Phase one walks the stack from the raise point outward, calling each frame’s personality routine with a “search” action. The personality routine reads the frame’s exception table and decides whether this frame has a handler that matches the exception’s type. It returns one of three answers: continue searching, stop here, or stop and run cleanup. Nothing is unwound during this phase, and no destructors run.
Phase two runs only after a frame has been found that will handle the exception. It walks the stack again, this time calling each frame’s personality routine with a “cleanup” action, which unwinds the frame: running destructors, releasing resources, and restoring the register state saved in the frame.
The reason for two passes is that the language may need to know whether an exception will be caught
before it starts destroying state. If no handler exists anywhere on the stack, the program is going to
terminate, and running every destructor on the way out is wasted work; the ABI permits a
std::terminate path that skips cleanup, and phase one’s failure to find a handler is the signal. The
second reason is more subtle: a destructor that runs during unwinding may itself throw, and the
language has rules about what happens then. Two-phase unwinding lets the runtime know whether the
unwinding is a “real” unwind with a handler before it commits to running cleanup code that could
observe the difference.
A single-phase design unwinds and runs cleanup as it searches, which is cheaper when an exception is caught nearby and more expensive when it is not. Implementations that use it accept that destructors run even when the exception will terminate the program.
The personality routine and the frame’s description
A personality routine is a function named by the unwind tables for each frame. It receives the exception object, the frame’s context, and an action code, and it returns a disposition. It exists because the mapping from instruction offset to handler is language-specific: C++ has catch clauses with type filters and cleanup regions, Ada has exception occurrences, and a mixed-language stack can contain frames from several languages. The personality routine is the hook that lets each language describe its own rules to the unwinder.
The unwind tables themselves are encoded in a compact bytecode, .eh_frame or .gcc_except_table on
ELF systems and a different format on Mach-O and PE. The encoding is deliberately space-efficient
because it is present in every function, whether or not that function uses exceptions. A function
that cannot raise and has no cleanup can still have an entry, since the unwinder must be able to walk
through it to reach a caller that does handle exceptions. This is the sense in which exceptions are
not free: the tables cost space in every binary that supports unwinding, and the unwinder must be
able to decode the format for every frame it passes.
The size is why the tables are compressed. The common case, a function whose prologue and epilogue differ in stack usage by a fixed amount, is encoded in a few bytes; the encodings are delta-coded against the previous entry so that a run of similar functions costs almost nothing per function.
What the zero-cost claim means
The phrase “zero-cost exceptions” describes the happy path: entering a try block emits no
instructions, and leaving it normally emits no instructions. What is not zero is the binary’s size,
since the tables are always present, and the cost of the throw path, which is a stack walk that
touches every frame’s unwind entry.
The walk’s cost is proportional to the number of frames between the raise point and the handler, and each frame requires decoding its unwind entry. On a deep stack, the walk is hundreds of frames, each with a table decode and a personality routine call. That is why exceptions are unsuitable for ordinary control flow: a throw per iteration of a loop over a million elements costs a million stack walks.
The alternative designs make the opposite trade. Dynamic registration puts the cost on entry to the protected region, which suits code that enters and leaves protected regions frequently but throws rarely. Return-code-based error handling puts the cost on every call, since every call must check its result, but has no table and no walk. The choice among them is a choice about which path is hot.
Frames, stack maps, and the interaction with JIT code
An interpreted language’s exception mechanism is simpler than a compiled one’s because the
interpreter’s frames are its own data structures. The JVM’s athrow searches the current method’s
table, and if the method has no handler, the frame is popped, which means the operand stack and
locals are discarded and the frame’s monitor, if any, is released. The exception object travels in a
register or a well-known slot, and the handler entry has a defined stack depth, which the verifier
recorded.
A JIT-compiled frame has no operand stack and no locals array, so the unwinder must reconstruct them. That is the same materialization problem as deoptimization, and the same stack maps are the source of the information. When a compiled frame has a handler, the runtime builds an interpreter frame from the map and jumps to the handler’s bytecode offset. When it does not, the frame is popped and the runtime continues with the caller, using the caller’s map.
The interaction with inlining is the awkward part. An inlined callee’s handler may catch an exception raised in the caller, and the unwinder must know that the logical frame is the inlined one even though there is no physical frame for it. Implementations record the inlining tree so that the unwind can attribute an offset to the correct logical method, which is also what a stack trace needs to print the inlined frames.
Cleanup that must not be skipped
Languages with deterministic destruction require that leaving a scope runs its cleanup, whether by normal return or by exception. The compiled form of that requirement is a cleanup region: an entry in the exception table covering the scope, with a handler that runs the destructors and then resumes the search. Resuming the search is the part that distinguishes cleanup from catch; the personality routine returns “continue searching” after running the cleanup, and the unwinder moves on to the next frame.
The ordering of cleanups is innermost-first, which falls out of the stack walk. The subtlety is that
the cleanup’s own code may throw, and the language must define what happens: the C++ standard
specifies that a destructor that exits by throwing during another exception’s unwinding calls
std::terminate, which is why destructors are conventionally written not to throw. The runtime
implements this by tracking whether unwinding is already in progress, a per-thread flag that the
personality routine checks.
Java’s finally is a different mechanism with a similar purpose. It is compiled as a handler that
runs the block and then rethrows the original exception, and the bytecode must preserve the original
exception across the finally block so that a throw inside the finally does not lose it. The
compiler emits a local variable to hold the pending exception and a code path that restores it before
rethrowing, which is why a finally block that itself throws discards the original exception rather
than chaining it.
The overall shape is a contract between the language’s semantics and the unwinder’s tables. The tables are static, the search is dynamic, and the cost is concentrated on the throw path. The design question for an implementer is not whether to have exceptions but how much of the frame state must be reconstructible when one arrives, and that answer determines how much metadata the compiler must emit and how much of the interpreter’s data structures the compiled code must be able to synthesize on demand.
References
- The Java Virtual Machine Specification, Java SE 8 Edition — Oracle
- Itanium C++ ABI: Exception Handling — Itanium C++ ABI project
- System V Application Binary Interface: AMD64 Architecture Processor Supplement — x86-64 psABI
- Exception Handling in LLVM — LLVM project