例外処理の実装(テーブル駆動とスタック巻き戻し)
投げていない間は一切遅くならないのに、なぜ throw できるのか。アンワインドテーブル・ランディングパッド・デストラクタ呼び出しまで、ゼロコスト例外の内部機構を原理から押さえる。
- 1.ゼロコスト例外は try に入る命令を一切挿入せず、戻り番地ごとの後始末情報を別領域のアンワインドテーブルに静的に持つ。正常系は完全に無コストで、コストは throw した瞬間にだけ発生する。
- 2.throw は二段階で動く。探索フェーズでテーブルを引いて捕捉するハンドラを持つフレームを上から探し、巻き戻しフェーズで各フレームのランディングパッドを呼んでデストラクタを実行しつつスタックを破棄する。
- 3.対する setjmp/longjmp 方式は try ごとに環境を保存するため正常系にも常時コストがかかる。現代のC++・Rust・Swiftはテーブル駆動を採用し、正常系の速度と引き換えに throw を相対的に重くしている。
「ゼロコスト」とは何がゼロなのか
例外処理の実装を語るとき必ず出てくる「ゼロコスト例外」という言葉は、誤解を招きやすい表現です。ゼロなのは 例外を投げなかった場合の実行コスト であって、投げたときのコストではありません。むしろ throw 時のコストは後述のとおり相当に重く、設計はその重さと正常系の軽さを交換しています。
正常系がゼロコストになる鍵は、try ブロックに入るときも出るときも、後始末のための命令を一切挿入しないことです。「いまどの try の中にいるか」「例外が来たらどのデストラクタを呼ぶべきか」といった情報を実行時に変数として持ち回るのではなく、**コンパイル時に確定する静的な表(テーブル)**として実行ファイルの別セクションに格納します。命令列そのものは例外がない前提で素直に並び、例外という稀な事態の処理は表の参照に追い出すわけです。これが「テーブル駆動(table-driven)」と呼ばれる理由です。エラーの伝え方の概観は例外処理(エラーハンドリング)を参照してください。本稿はその throw/catch が機械語レベルで何をしているかを扱います。
アンワインドテーブルという静的データ
throw が起きたとき、ランタイムは「いまスタック上に積まれている各フレームについて、捕捉するハンドラがあるか、解放すべきオブジェクトは何か」を知る必要があります。この情報源が アンワインドテーブル(unwind table)です。x86-64 Linux/macOSなどが採る Itanium C++ ABIでは、これは .eh_frame や .gcc_except_table といったセクションに置かれます。
テーブルは戻り番地(プログラムカウンタの値)を鍵に引けるようになっています。あるフレームの戻り番地が分かれば、テーブルを検索して「この命令位置はどの try 範囲に属し、どのデストラクタを呼ぶべきで、どの型を catch するハンドラへ飛べばよいか」が判明します。重要なのは、この対応付けが 実行時の状態に依存せず、PC(コード上の位置)だけで決まる点です。だからこそ正常系で状態を記録しておく必要がなく、ゼロコストが成り立ちます。スタックにフレームが積まれる仕組みそのものはスタックとヒープのメモリ管理を、戻り番地や呼び出し規約の前提はABI・呼び出し規約とリンカの仕組みを併せて読むと立体的になります。
スタック(throw 発生時、上が呼び出しの末端):
[ frame C ] PC=0x4012a0 ← throw した関数
[ frame B ] 戻り番地=0x401180
[ frame A ] 戻り番地=0x401090 ← ここに合致する catch があるとする
各戻り番地でアンワインドテーブルを引く:
0x4012a0 → 呼ぶデストラクタ: ~Buffer / catch なし
0x401180 → 呼ぶデストラクタ: ~Lock / catch なし
0x401090 → 呼ぶデストラクタ: なし / catch (std::exception)
二段階のアンワインド——探索と巻き戻し
C++ の throw は内部的に __cxa_throw を呼び、ランタイム(パーソナリティルーチン)が 二段階 でスタックを遡ります。この二段階構成には明確な理由があります。
第一が 探索フェーズ(search phase)です。スタックを末端のフレームから上へ仮想的に辿り、各フレームのパーソナリティルーチンに「あなたはこの例外型を catch するか」と問い合わせます。捕捉するフレームが見つかるまで、まだ何も破壊しません。ここで誰も catch しないと判明すれば、スタックを一切壊さないまま std::terminate へ進めます。これにより、未捕捉例外のデバッグ時に呼び出し履歴がそのまま残るという利点が得られます(破壊してから「捕まえる人がいなかった」と気づくのでは手遅れです)。
第二が 巻き戻しフェーズ(cleanup/unwind phase)です。捕捉先が確定したので、今度は実際に末端から捕捉フレームまで各フレームを破棄していきます。各フレームでテーブルが指す ランディングパッド へ制御を移し、そのフレームが所有するローカルオブジェクトのデストラクタを呼び、フレームを取り除いて次へ進みます。
| 観点 | 探索フェーズ | 巻き戻しフェーズ |
|---|---|---|
| 目的 | 捕捉するハンドラを持つフレームを特定 | そこまでのフレームを実際に破棄 |
| スタックへの影響 | 壊さない(読むだけ) | デストラクタを呼びフレームを破棄 |
| 誰も捕捉しない場合 | 破壊前に terminate へ(履歴が残る) | そもそも到達しない |
| 何を呼ぶか | パーソナリティに型一致を問う | ランディングパッド=後始末コード |
各言語・各フレームには「例外をどう扱うか」を知る関数が割り当てられており、これを パーソナリティルーチン(personality routine)と呼びます。C++ なら __gxx_personality_v0 が代表で、アンワインドテーブルを解釈し、型の一致判定やランディングパッドへの分岐を担います。言語をまたいだ巻き戻し(C++ から呼んだ C、Rust と C++ の境界など)が成立するのは、共通のアンワインド ABIの上で各言語が自分のパーソナリティを差し込めるからです。
ランディングパッドとデストラクタの呼び出し
ランディングパッド(landing pad)は、巻き戻し時にそのフレームで実行される後始末コードの入口です。コンパイラは try を含む関数をコンパイルするとき、正常系の命令列とは別に「例外が来たらここへ着地し、生存中のローカルのデストラクタを順に呼び、catch 節があればそこへ、なければ上のフレームへ巻き戻しを継続する」というコードを生成し、その位置をテーブルに登録しておきます。
着地後に「どのオブジェクトが構築済みでデストラクタを呼ぶべきか」は、関数のどこで例外が飛んだかによって変わります。これを管理するため、コンパイラはフレームに小さな整数(しばしば state/index と呼ばれる)を持たせ、構築が進むたびに更新します。ランディングパッドはその値を見て、解放すべき範囲だけを過不足なく後始末します。デストラクタによる確実な解放はC++のRAIIの根幹で、スマートポインタの破棄を通じた参照カウントの減算もこの巻き戻しの中で起こります。
void f() {
Lock g(mtx); // ① g 構築。以後 state を「g 生存」へ
Buffer b(1024); // ② b 構築。state を「g,b 生存」へ
may_throw(); // ③ ここで throw → 巻き戻しが b,g の順にデストラクタを呼ぶ
} // (構築の逆順。catch はこの関数にないので上へ継続)
巻き戻しの最中にデストラクタがさらに例外を投げると、ランタイムは「巻き戻し中の新たな例外」を処理しきれず、既定では std::terminate を呼んで即座にプログラムを終了させます。これがC++で「デストラクタは例外を投げるな(投げるなら内部で握りつぶせ)」と強く戒められる根本理由です。Rust でも巻き戻し中の panic(drop 内の二重 panic)はプロセス abort を招きます。
setjmp/longjmp 方式との比較
テーブル駆動が普及する前から使われてきた対照的な実装が、setjmp/longjmp(SjLj)方式 です。setjmp は現在のレジスタ・スタックポインタなどの実行環境を保存し、longjmp がそこへ大域ジャンプして復帰します。例外を「保存した地点への強制復帰」として実装するわけです。
この方式の決定的な違いは、コストの発生場所にあります。SjLjでは try に入るたびに環境を保存し、後始末対象を連結リストへ登録する必要があるため、例外を投げなくても正常系に常時オーバーヘッドがかかります。一方テーブル駆動は正常系に命令を挿入しないので、コストは throw した瞬間(テーブル検索と巻き戻し)に集中します。例外が稀であるという現実的な前提の下では、後者が圧倒的に有利です。
| 観点 | テーブル駆動(ゼロコスト) | setjmp/longjmp 方式 |
|---|---|---|
| 正常系のコスト | ゼロ(命令を挿入しない) | try ごとに環境保存で常時発生 |
| throw 時のコスト | 重い(テーブル検索+巻き戻し) | 比較的軽い(保存地点へ復帰) |
| 必要なデータ | 別セクションの静的テーブル | 実行時の環境バッファと登録リスト |
| コードサイズ | テーブルの分だけ増えるが命令は素直 | try 周辺に保存・登録コードが膨らむ |
| 主な採用 | 現代のC++/Rust/Swift | 古い処理系や一部の組込み環境 |
「ゼロコスト例外のゼロは何を指すか」と問われたら、答えは 例外を投げない正常系の実行時コスト です。throw 自体は安価ではありません(テーブル検索と巻き戻しで、関数戻りより桁違いに遅い)。だからホットパスでの制御フローに例外を使うのはアンチパターンであり、「例外は例外的事象に」という原則は性能の観点からも裏づけられます。なお、コードサイズ(テーブル)というメモリコストは正常系でも存在する点に注意してください。
まとめ
ゼロコスト例外の正体は、後始末情報を実行時の状態ではなくコンパイル時の静的テーブルへ追い出すという設計判断です。正常系には命令を一切挿入せず、try の中にいるかどうかは戻り番地(PC)からアンワインドテーブルを引いて事後的に復元します。throw は探索フェーズで捕捉フレームを壊さずに特定し、巻き戻しフェーズで各フレームのランディングパッドへ着地してデストラクタを構築の逆順に呼びながらスタックを破棄します。二段階に分ける狙いは、誰も捕まえないときに履歴を残したまま terminate できる点にあります。対する setjmp/longjmp 方式は正常系に常時コストを負わせるため、例外が稀という前提では不利です。「ゼロコスト」とは無料という意味ではなく、コストを正常系から異常系へ移し替えた結果だと理解すれば、なぜ throw が重く、なぜそれでも採用されるのかが一貫して見えてきます。
プログラミング Article
例外処理の実装(テーブル駆動とスタック巻き戻し)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
例外処理
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
throw は二段階で動く。探索フェーズでテーブルを引いて捕捉するハンドラを持つフレームを上から探し、巻き戻しフェーズで各フレームのランディングパッドを呼んでデストラクタを実行しつつスタックを破棄する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「例外処理 / アンワインド」に近いか確認する。
- 強みである「ゼロコスト例外は try に入る命令を一切挿入せず、戻り番地ごとの後始末情報を別領域のアンワインドテーブルに静的に持つ。正常系は完全に無コストで、コストは throw した瞬間にだけ発生する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。