Skip to content
VLSI Mentor

C++ Part I · Module 22 · Error Handling and Diagnostics

Stack Unwinding and What Happens on the Way Out

Chapter 22.2 ended on a sentence that hides the most consequential machinery in the language: a throw runs destructors on the way out. This chapter is what that actually means, and it is marked Critical because almost every design decision in the rest of the module follows from it. When an exception is thrown, control does not simply jump to the handler. The runtime walks the stack frame by frame, and in each frame it destroys every automatic object that has been fully constructed, in exactly the reverse of the order they were constructed — and it does this in frames that contain no try block, no catch clause, and no knowledge that anything can fail. The chapter measures that precisely: five objects spread across three function frames, destroyed in exact reverse order, with two of those frames having no exception-handling code whatsoever. It also measures what unwinding does not do, which is run any statement after a call that propagated. Those two facts together are the entire basis for the next three chapters. If a destructor is guaranteed to run and a statement after a call is not, then cleanup written as a statement is cleanup that will sometimes be skipped, and cleanup written in a destructor is cleanup that cannot be. That is not a style preference about RAII; it is a direct consequence of the mechanism this chapter measures.

Advanced19 min readC++Part IFundamentalsError Handling

Part I · Module 22 · Chapter 22.3 · Stack Unwinding and What Happens on the Way Out

1. The Sequence

When a throw executes, seven things happen in order. The diagram is the sequence; the measurements that follow are the evidence for each step.

The seven stages of stack unwinding, from constructing the exception object through searching for a handler, destroying each automatic object in reverse order frame by frame, to entering the handlerWhat a throw does, in order01COPY THE EXCEPTION OUTThe thrown object is copied out; the frame itwas built in is about to go.02SEARCH OUTWARD FOR A HANDLEREach frame's side table is checked for amatching catch. No code runs yet.03NO HANDLER: TERMINATEThe search precedes destruction, so anunhandled throw can abort mid-stack.04DESTROY AUTOMATICS IN REVERSEMeasured: five objects, three frames, exactreverse construction order.05SKIP STATEMENTS AFTER THE CALLMeasured: zero statements after a throwingcall were reached, in any frame.06REPEAT THROUGH UNAWARE FRAMESMeasured: two of three frames had no try orcatch and still cleaned up.07ENTER THE HANDLERThe catch runs with the exception stillalive; it dies when the handler exits.
Figure 1 — the seven stages, from constructing the exception object to entering the handler. Every automatic object in every frame is destroyed in reverse construction order along the way, and the frames in between need no try or catch of their own to clean up correctly.

Steps 2 and 3 are the part most descriptions omit, and §6 is why they matter.

2. The Measurement

Three nested functions, five automatic objects, one throw. Each object reports its own construction and destruction.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
struct Tracer {
    const char* name;
    explicit Tracer(const char* n) : name(n) { note("construct", name); }
    ~Tracer() { note("DESTROY", name); }
};

static void level3() { Tracer a("level3::a"); Tracer b("level3::b");
                       throw std::runtime_error("device did not respond"); }
static void level2() { Tracer c("level2::c"); level3();
                       note("NOT REACHED", "level2 after call"); }
static void level1() { Tracer d("level1::d"); level2();
                       note("NOT REACHED", "level1 after call"); }

int main() {
    try { Tracer e("main::e"); level1();
          note("NOT REACHED", "main after call"); }
    catch (const std::runtime_error& ex) { note("caught", ex.what()); }
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
construct  main::e
construct  level1::d
construct  level2::c
construct  level3::a
construct  level3::b
throwing   from level3
DESTROY    level3::b
DESTROY    level3::a
DESTROY    level2::c
DESTROY    level1::d
DESTROY    main::e
caught     device did not respond
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
constructions: main::e level1::d level2::c level3::a level3::b
destructions : level3::b level3::a level2::c level1::d main::e
constructed 5, destroyed 5, statements after the throwing call reached: 0

Three facts, each worth stating separately.

Five constructed, five destroyed. Nothing was skipped. Every automatic object that had been fully constructed when the throw happened was destroyed.

Exactly reverse order, across frame boundaries. The destruction list is the construction list reversed, with no regard for which function each object belonged to. level3::b before level3::a within a frame; level3's objects before level2's before level1's across frames.

Zero "NOT REACHED" lines. Three functions each had a statement after the call that propagated, and none ran.

3. The Frames That Knew Nothing

This is the fact the whole module rests on, and it is easy to read past.

level1 and level2 contain no try, no catch, and no mention of exceptions at all. They were written as though nothing could fail. And their automatic objects were still destroyed, correctly, in the right order, as the exception passed through.

That is not an optimisation or a convenience. It is a guarantee the language makes: a function that knows nothing about a failure still cleans up its own automatic objects when one passes through it. The mechanism is the side table from Chapter 22.1 §3 — the compiler emitted, for each frame, a static description of which objects are alive at which instruction and what to call on them, and the unwinder reads it.

So the set of functions that must contain exception-handling code is exactly the set that wants to handle the failure. Every intermediate function is correct by construction, provided its resources are held in objects with destructors. Chapter 22.4 is what happens when they are not.

4. Fully Constructed Is the Qualifier

Step 4 says "every automatic object that has been fully constructed", and the qualifier does real work in two places.

A partially constructed object is not destroyed. If a constructor throws part-way through, the object never existed, so its destructor does not run — but the members and bases that were already constructed are destroyed. This is why a constructor that throws leaves no half-object behind, and Chapter 22.4 §2 is the design consequence.

An object whose construction has not started is not destroyed, which is what makes the reverse order in §2 meaningful: in level3, if Tracer a's constructor had thrown, b would never have been constructed and only a's members would be cleaned up.

Two related limits are worth knowing because they are the holes in the guarantee:

Dynamically allocated objects are not destroyed. Unwinding destroys automatic objects — things on the stack. A raw new with no owning object is simply leaked, which Chapter 22.4 §1 measures.

std::exit and std::abort do not unwind. abort terminates immediately; exit runs static destructors and atexit handlers but does not unwind the stack, so automatic objects in active frames are never destroyed. That is one reason std::terminate on an unhandled exception is a blunt instrument.

5. What the Handler Receives

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
try { throw std::out_of_range("index 12 of 8"); }
catch (const std::exception& ex) { /* ... */ }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
caught as std::exception&: index 12 of 8
dynamic type survived the catch: yes, still out_of_range

The exception object is alive throughout the handler, and catching it by reference at a general level does not change what it is — a dynamic_cast to the derived type succeeded. Chapter 22.2 §4 measured the one thing that does destroy that: catching by value.

The object is destroyed when the handler exits normally. If the handler rethrows with a bare throw;, the same object continues outward — which is Chapter 22.2 §5's measurement.

6. The Search Happens Before the Destruction

Steps 2 and 3 of the diagram are in that order deliberately, and the consequence surprises people.

The runtime first searches outward for a matching handler, consulting side tables without running any code. Only if it finds one does it begin the second pass that actually destroys objects and transfers control.

So when there is no handler anywhere, the implementation is permitted to call std::terminate without unwinding at all. The stack may still be intact, which is why an unhandled exception often produces a usable backtrace in a debugger, and why destructors you were relying on may not have run.

Three practical consequences.

Do not rely on destructors running on the unhandled path. A flush in a destructor is not a flush if nobody catches.

Catch at the top of main — or at the top of each thread — if you want unwinding to be guaranteed and cleanup to happen before exit.

A backtrace from an unhandled exception is more useful than one from a caught-and-rethrown one, because the frames are still there.

This two-phase design is also why Chapter 22.1 §3's measurement came out flat: the first phase reads static tables, so there is nothing to maintain at run time on the path where nothing throws.

7. What It Costs

Chapter 22.1 §4 measured the end-to-end figure: about 3,362 nanoseconds per throw-and-catch, against 0.30 for a returned status. This chapter is where that time goes.

Allocating and copy-constructing the exception object. Walking the side tables, frame by frame — so the cost grows with the depth between throw and handler. Running every destructor on the way, which is your code and can be arbitrarily expensive. And a type match against each catch clause in order, which for a deep hierarchy involves RTTI.

Two implications for design.

Depth matters. Throwing across forty frames costs more than throwing across three, and the destructors in those frames are part of the bill.

Do not use unwinding as control flow. A throw that unwinds a deep recursion to escape a search loop works and is roughly three microseconds. Chapter 22.1 §5's first pathology.

8. The Guarantee, Stated Precisely

Worth having in one place, because the rest of the module cites it constantly.

When an exception propagates out of a scope, every automatic object in that scope that has been fully constructed is destroyed, in reverse order of construction, before control leaves the scope — and this happens in every frame between the throw and the handler, whether or not those frames contain exception-handling code.

What it covers: automatic (stack) objects; members and bases of a partially constructed object; and temporaries whose lifetime ends at the enclosing full expression.

What it does not cover: heap allocations not owned by an automatic object; anything after a propagating call; the whole thing, if there is no handler anywhere (§6).

Every one of those exclusions is a chapter. The first is Chapter 22.4's. The second is why Chapter 22.5's "basic guarantee" is a property you must design for. The third is why Chapter 22.6's noexcept is enforced by terminate rather than by propagation.

9. The Sequence Table

stepwhat happensmeasured
1the thrown object is copied into runtime storagethe throwing frame is destroyed afterwards and the object survives
2search outward for a matching handler; no code runs—
3if none exists, std::terminate — possibly without unwinding—
4destroy each fully constructed automatic object, reverse order5 of 5, exact reverse, across frames
5skip every statement after the propagating call0 of 3 "NOT REACHED" lines appeared
6repeat in each frame, including frames with no try/catch2 of 3 frames had none and still cleaned up
7enter the handler; the object lives until it exitsdynamic type intact under a base-class catch
not covered by unwindingconsequencechapter
heap objects with no ownerleaked22.4 §1 measures it
statements after a propagating callskipped22.5's basic guarantee
anything, if no handler existsmay not unwind at all§6
std::exit / std::abortno unwinding of active frames§4

10. What to Actually Do

  • Hold every resource in an object with a destructor. That is the only cleanup unwinding runs.
  • Assume no statement after a call that can throw will execute.
  • Do not add try/catch to intermediate functions. Measured: they clean up correctly without it.
  • Catch at the top of main and of every thread, so unwinding is guaranteed and cleanup happens.
  • Do not rely on destructors on the unhandled path. Measured behaviour permits terminating without unwinding.
  • Keep destructors cheap, because they run on the unwinding path and are part of its cost.
  • Never throw from a destructor — Chapter 22.6 §5.
  • Do not use a throw to escape a loop or a recursion. About 3,400 ns, and it reads as a failure.
  • Remember a constructor that throws leaves no object, so there is nothing to clean up and nothing to check.
  • Prefer std::exit only where you accept that active frames are not unwound.

11. Mistakes Worth Making Once

A delete after a call that can throw. Measured in Chapter 22.4: the resource leaks, and the happy path is fine.

Expecting a destructor to flush on an uncaught exception. The implementation may terminate without unwinding.

Adding try/catch to every function in a chain because cleanup "needs" it. Measured: it does not.

A throw used to break out of a deep search. Works; ~3,400 ns; and every reader now thinks something failed.

An expensive destructor on a hot type that also has to run on the unwinding path.

Assuming reverse order is per-function. It is global across frames — measured.

Calling std::exit from a worker thread and wondering why the RAII cleanup did not happen.

Throwing from a destructor during unwinding. Chapter 22.6 §5 measures the terminate.

12. Interview Relevance

The frozen curriculum marks this chapter Critical, and it is one of the best questions available for separating real understanding from recitation.

"What happens when an exception is thrown?" The seven steps. The parts that distinguish a real answer are the two-phase search-then-unwind, and the fact that intermediate frames with no try still destroy their objects.

"In what order are objects destroyed?" Exactly reverse construction order, across frame boundaries — not per function. Quoting the measured five-object sequence is the cleanest way to say it.

"Does code after the throwing call run?" No. Measured zero of three.

"Do functions in between need a try block?" No, and that is the point — measured, two of three frames had none and cleaned up correctly.

"Is a heap allocation cleaned up?" Only if an automatic object owns it. A bare new leaks.

"What happens if nobody catches?" std::terminate — and the implementation may not unwind at all, so destructors may not run. That detail is unusual to know.

"Why is RAII described as the only reliable cleanup?" Because a destructor is guaranteed to run and a statement is guaranteed to be skipped. The asymmetry is the argument.

13. What You Should Be Able to Say Now

  • The seven steps, including that the search precedes the destruction.
  • That five objects across three frames were destroyed in exactly reverse construction order, ignoring frame boundaries.
  • That zero statements after a propagating call were reached, in any of three frames.
  • That two of the three frames had no try or catch and still cleaned up correctly — and that side tables are how.
  • That only fully constructed automatic objects are destroyed, and that a partially constructed object's members still are.
  • That heap allocations without an owner are not cleaned up.
  • That with no handler anywhere, the implementation may terminate without unwinding.
  • That the ~3,400 ns from Chapter 22.1 §4 is spent on allocation, table walking, destructors and per-handler type matching, and grows with depth.
  • Why the destructor-versus-statement asymmetry makes RAII a consequence rather than a style.

14. Exercises

Exercise 1 — reproduce it. Build §2 and confirm the five-object sequence. Then add a sixth object in a fourth frame and predict the order before running it.

Exercise 2 — partial construction. Write a class whose constructor throws after initialising two of three members. Instrument all three and show which destructors run.

Exercise 3 — the leak. Replace one Tracer with a raw new and no owner. Confirm with a counter that it is not cleaned up, then fix it with unique_ptr.

Exercise 4 — no handler. Remove the try/catch from main and run under a debugger. Record whether the Tracer destructors ran and whether the stack was intact.

Exercise 5 — depth. Measure throw-and-catch cost across 1, 10 and 100 frames, each holding one object with a non-trivial destructor. Plot it.

Exercise 6 — exit versus return. Call std::exit from level3 instead of throwing and record which destructors run. Then do it with std::abort.

Exercise 7 — the intermediate frames. Add a try/catch(...) with a rethrow to level2 and confirm the destruction order is unchanged. Then argue in two sentences why it should be removed.

15. Summary

  • A throw copies the object out of the frame, searches outward for a handler, and only then unwinds.
  • Measured: five automatic objects across three frames, all five destroyed, in exactly reverse construction order — across frame boundaries, not per function.
  • Measured: zero statements after a propagating call were reached, in any of the three frames.
  • Measured: two of the three frames contained no try or catch and still destroyed their objects correctly — the guarantee is made on behalf of code that never mentions exceptions.
  • Only fully constructed automatic objects are destroyed; a partially constructed object's already-built members and bases still are.
  • Heap objects with no owning automatic object are leaked — Chapter 22.4 §1 measures it.
  • With no handler anywhere, the implementation may call std::terminate without unwinding, so destructors may never run — catch at the top of main and of each thread.
  • std::exit does not unwind active frames; std::abort does nothing at all.
  • The ~3,400 ns from Chapter 22.1 §4 is allocation, table walking, your destructors and per-handler type matching, and it grows with depth.
  • A destructor is cleanup the language runs; a statement is cleanup it skips. That asymmetry is the whole argument of the next chapter.

The guarantee in §8 has one conspicuous exclusion: a heap allocation that no automatic object owns. Chapter 22.4 measures exactly that hole — the same function written three ways, with a resource released by an explicit call, by an explicit call wrapped in a catch(...) that rethrows, and by a destructor. On the path where nothing fails all three are correct. On the path where something throws, one of them leaks, and the chapter counts it.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.