Skip to main content
๐Ÿ“œ WAYPOINT LESSON

Exceptions at runtime

โญโญโญ advancedโณ 15 min read๐Ÿ“ Lesson 225 of 265

Two-pass unwinding, finally/fault handlers, and the real cost model of try/catch.

Exceptions at runtime

.NET exceptions use two-pass unwinding, modeled on the Itanium C++ ABI:

  1. Pass 1 (search): walk the stack looking for a frame whose catch matches. No user code runs.
  2. Pass 2 (unwind): re-walk, running each frame's finally/fault handlers up to (and including) the winning catch. Stack frames are torn down as handlers complete.

This explains observable behaviors: state mutations in finally are guaranteed on the unwind path; filters (catch when) run in pass 1 and can decline an exception so an outer handler takes it without unwinding the intermediate frames.

The cost model โ€” what to say when someone says "exceptions are slow":

  • The throw path (construct, pass 1+2, stack walk) costs microseconds โ€” often 100โ€“1000ร— a return-based check. Per-iteration throwing in a loop is a genuine performance bug (the checkpoint measures it honestly).
  • The non-throw path is nearly free: a try block adds a jump-table entry, not a branch. "Put try/catch around hot loops" costs approximately nothing until an exception actually flies.

Design corollary: TryX patterns (int.TryParse) exist for predictable failures; exceptions are for exceptional ones. Request validation that rejects 30% of inputs should use the Try pattern โ€” not because exceptions are "bad", but because microsecond-per-failure multiplies. The runtime itself follows this: dictionary lookups, span parsing, channel reads all offer non-throwing variants.

ExceptionDispatchInfo.Capture(ex).Throw() preserves the original stack โ€” the right way to rethrow after async boundaries, where throw ex; would rewrite the trace.