概要: Rustのパニック機構は異常終了の処理を安全に行うためにある。この動作を詳しくみていく。
パニックとは何か
Rustには2つの異なる例外処理機構があります。
発生源 | 対処方法 | 例 | |
---|---|---|---|
パニック | プログラミングエラー | 原則として捕捉しない | assert!() 境界外参照 |
Result |
例外的な入力 | 必要に応じて捕捉 | I/Oエラー (File::read ) パースエラー ( str::parse ) |
パニックとResult
の関係についてはTRPL第2版第9章、未定義動作とパニックの関係についてはRustonomiconのUnwindingの章などが参考になります。
パニックを想定した安全性
Rustではたとえパニック状態でも未定義動作だけは絶対に避ける必要があります。そのため以下の関数は不健全 (unsound)です。
use std::ptr; // この関数はRustではunsound (やってはいけない) pub fn replace_with<T, F>(x: &mut T, f: F) where F: FnOnce(T) -> T { unsafe { ptr::write(x, f(ptr::read(x))); } }
このコードは x
から一時的に所有権を取り出し、 f
にかけてから x
に書き戻します。これは一見すると安全ですが、 f
がパニックしたときに x
が無効な値を指した状態で返されてしまいます。どうしてもこのような関数を用意したい場合には、関数自体をunsafeにして、規約を明記する必要があります。
use std::ptr; /// `x` の値を `f` で変換します。 /// /// # 安全性 /// /// `f` がパニックした場合、 `x` の指すメモリ領域は未定義になります。 /// /// 呼び出し元は、 `f` がパニックしないよう担保するか、 `f` がパニックした /// 場合に二重dropが発生しないように追加の処理をする必要があります。 pub unsafe fn replace_with<T, F>(x: &mut T, f: F) where F: FnOnce(T) -> T { ptr::write(x, f(ptr::read(x))); }
二重パニックの抑止
Rustでは二重パニックは特に気をつけて避ける必要があります。Rustのパニックは「原則として捕捉しない」だけで実際には捕捉可能ですが、二重パニックの捕捉はできません。そのため二重パニックは通常のパニックに比べてデバッグが難しくなる可能性があります。
二重パニックは主に誤った drop
実装によって発生しえます。単純化された例では次のようなものが考えられます。
use std::ops::Drop; struct A; impl Drop for A { fn drop(&mut self) { println!("drop(A)"); panic!("drop(A)"); } } fn main() { let _x = A; assert_eq!(0, 1); }
このように Drop::drop
内ではパニックを使うことができませんが、戻り値が ()
であることから Result
を用いたエラー通知もできません。つまり、 Drop::drop
内で発生したエラーを適切に通知する手段はありません。これが困る例として BufWriter
があります。
#![feature(termination_trait)] // `io::Result` を main の戻り値として使う use std::io::{self, BufWriter, Write}; use std::fs::File; fn main() -> io::Result<()> { let mut f = BufWriter::new(File::create("foo.txt")?); writeln!(f, "hoge")?; f.flush()?; // 明示的にflushしないとエラーを捕捉できない Ok(()) }
BufWriter
はデータをすぐに書き込まずバッファに蓄えるため、dropされた時点で自動的に残りを書き込みます。しかしこのとき発生したエラーを通知する手段はありません。たとえば、上の例でファイル名を /dev/full
にして実行するとエラーが表示されますが、 f.flush()?
を消すとエラーは握り潰されてしまいます。
不変条件に気をつける
データに対して、常に保たれていてほしい性質のことを不変条件といいます。たとえば、何らかの理由で単調増加な配列を扱いたい場合、この「単調である」という性質は不変条件の一種です。
ところで不変条件はプログラムの実行中に一時的に壊れる場合があります。たとえば以下の処理を考えます。
// 単調増加な配列に2を足す fn plus2(a: &mut [i32]) { for x in a { *x += 2; } } fn main() { let mut a = [3, 5, 6]; plus2(&mut a); assert_eq!(&a[..], &[5, 7, 8]); }
この plus2
の前後で「単調増加である」という性質は保たれます。ところが、この plus2
の実行中にはこの不変条件は一時的に壊れています。2項目目まで処理し終えたときの配列は [5, 7, 6]
ですから単調増加ではありません。
Rustのパニック機構では、このような論理的な不変条件が壊れる可能性がありますが、その影響が極力波及しないような工夫があります。例えば、先ほどの「単調増加である」という性質を再び考え、以下のようなプログラムを考えます。
// 単調増加な配列を三乗する fn cube(a: &mut [i32]) { for x in a { *x = x.pow(3); } } fn main() { let mut a = [3, 5, 6]; cube(&mut a); assert_eq!(&a[..], &[27, 125, 216]); }
このプログラムに [1290, 1291, 1292]
という配列を渡すと、2番目の処理中にオーバーフローでパニックが発生します(デバッグビルドの場合)。このパニック発生時点での配列の中身は [2146689000, 1291, 1292]
なので、不変条件が壊れています。しかしこの場合は、パニックを捕捉していないので、不変条件が壊れたデータをユーザープログラムが目にすることはありません。
不変条件が壊れたデータをプログラムが目にする機会には次の2つがあり、どちらも防止策があります。
- スレッド内でパニックが捕捉された場合。これは
UnwindSafe
トレイトによって抑止されている。 - 他のスレッドがデータを参照した場合。これは
Mutex
/RwLock がもつpoisoningの仕組みによって抑止されている。
なお、どちらの仕組みも転落防止柵くらいの簡易なもので、簡単にオプトアウトできるようになっています。Rustでは論理不変条件の保護を徹底する気はなく、たとえ論理不変条件が壊れていても安全性不変条件さえ守られていれば未定義動作が起こらないようにしなければなりません。
UnwindSafe
トレイト
UnwindSafe
トレイトは、後述する catch_unwind
を使うさいに、不変条件が壊れたデータが漏れないようにする仕組みです。
たとえば、次のように catch_unwind
を使うとpanicの巻き戻し処理を一時中断することができます。
use std::panic::{catch_unwind, resume_unwind}; // 単調増加な配列を三乗する fn cube(a: &mut [i32]) { for x in a { *x = x.pow(3); } } fn main() { let result = catch_unwind(|| { let mut a = [1290, 1291, 1292]; cube(&mut a); }); // 上の処理がpanicしても実行される println!("do something"); // panic処理をレジュームする result.unwrap_or_else(|e| resume_unwind(e)); }
ここで a
をクロージャの外に出せば、壊れた a
を観測できそうですが、 &mut T: !UnwindSafe
であることからコンパイルエラーになります。
use std::panic::{catch_unwind, resume_unwind}; // 単調増加な配列を三乗する fn cube(a: &mut [i32]) { for x in a { *x = x.pow(3); } } fn main() { let mut a = [1290, 1291, 1292]; // ここでコンパイルエラーになる let result = catch_unwind(|| { cube(&mut a); }); // 上の処理がpanicしても実行される println!("a = {:?}", a); // panic処理をレジュームする result.unwrap_or_else(|e| resume_unwind(e)); }
かわりに AssertUnwindSafe(&mut a)
をキャプチャーするようにすればこの仕組みをオプトアウトできます。
use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind}; // 単調増加な配列を三乗する fn cube(a: &mut [i32]) { for x in a { *x = x.pow(3); } } fn main() { let mut a = [1290, 1291, 1292]; let result = { // &mut a の壊れた値を観測するためにAssertUnwindSafeする let aref = AssertUnwindSafe(&mut a); catch_unwind(move || { cube(aref.0); }) }; // 上の処理がpanicしても実行される (a = [2146689000, 1291, 1292]) println!("a = {:?}", a); // panic処理をレジュームする result.unwrap_or_else(|e| resume_unwind(e)); }
Mutex
, RwLock
のpoisoning
Mutex
のロックや RwLock
の書き込みロックを持ったスレッドがパニックすると、その途中の値は不変条件が壊れている可能性があります。これを他のスレッドが間違って読まないようにするため、 Mutex
と RwLock
はロック(書き込みロック)を持ったスレッドがパニックした場合、poisonedフラグが立てられ、次回以降のロックが失敗するようになります。
poisonedフラグを元に戻す方法はないようですが(多分)、poisonedフラグによりエラーになった場合、返されたエラーから強制的に壊れた値を読みに行くことはできるようになっています。
パニックの流れ
ここまで、パニックに関連する基本的な注意事項をまとめました。ここからはパニックの仕組みを調べていきます。
まず、ユーザープログラムから見たパニックの流れを説明します。パニックはスレッドごとに処理されます。各スレッドの正常系をここでは次のような図であらわすことにします:
パニック処理の基本的な流れ (-C panic=unwind
) は以下のようになります。
ただし、 -C panic=abort
が指定されているときは以下のようになります。
パニックハンドラ
パニックハンドラはパニック時にまず呼ばれる処理で、主にスタックトレースの表示などを担当します。デフォルトの処理は以下のようになっています。
- もしパニックカウントが2なら、常にフルのバックトレースを表示する。
- パニックカウントが1なら、
RUST_BACKTRACE
の値に応じて以下の処理をする。0
または定義されていないならバックトレースを表示しない。full
ならばフルのバックトレースを表示する。- それ以外(
1
など)ならシンプルなバックトレースを表示する。
Box<Any + Send>
が&'static str
もしくはString
だったときは、これをエラーメッセージとして表示する。それ以外のオブジェクトだったときは、"Box<Any>"
とだけ表示する。
std::panic::set_hook
を用いてパニックハンドラを設定できます。なお、パニックハンドラ自体は全スレッドで共通です。
巻き戻し
巻き戻しはLLVMの例外機構 (C++の例外にも使われている) を用いて実装されています。プラットフォームごとにGCC互換やSEHなどとして実装されるようです。
巻き戻しでは基本的にスタックトレースに沿って drop
が呼ばれていきます。したがって最終的に以下のいずれかになります。
- スレッドの先頭まで巻き戻る
catch_unwind
の呼び出しにぶつかるdrop
が再びパニックする (二重パニック)
スレッドの先頭まで巻き戻った場合、そのスレッドだけが異常終了したとみなされ、呼び出し元スレッドからの JoinHandle::join
呼び出しに対して Err
として報告されます。
catch_unwind
の呼び出しにぶつかった場合、通常処理に復帰したとみなされます。しかし前述のように、パニックハンドラによって既にエラーメッセージが表示されてしまっているので、これはエラーを握り潰すのには向いていません。想定されている用途はFFIバウンダリなどで一時的に巻き戻しを止める等で、基本的に resume_unwind
と対にして使います。
drop
が再びパニックした場合、二重パニックになります。二重パニックの場合はパニックハンドラ呼び出し後にプロセス全体が強制終了になります。
図を見てわかるように、パニックハンドラでパニックが起こる可能性もあり、この場合は三重パニックの可能性があります。
巻き戻さない
-C panic=abort
を指定すると巻き戻さずに常にプロセスを強制終了するようになります。RFC 1513に動機が説明されています。
パニック処理の場所
パニックの処理は標準ライブラリとコンパイラの複数の箇所に分散しており追跡が困難です。ここに概要をまとめておきます。
- パニックの主な処理は
libstd
にあります。#[no_std]
なバイナリを作る方法の説明にパニック処理が入ってしまうのはそのためです。 std::panic
に公開APIがありますが、非公開のルーチンはstd::panicking
という別のモジュールに記載されています。これがパニック処理の本体です。libcore
の一部の処理はそれ自体がパニックする必要があり、std
への逆依存の関係になってしまいます。そこでまずstd::panicking::rust_begin_panic
はextern "C"
であり、#[lang = "panic_fmt"]
という印がつけられています。libcoreのcore::panicking::panic_fmt
はこの#[lang = "panic_fmt"]
を目印にして、std
のそれとリンクされるようになっています。std::panicking
の巻き戻し処理は__rust_maybe_catch_panic
と__rust_start_panic
という2つの関数に移譲されています。これは-C panic=
フラグに応じてpanic_unwind
またはpanic_abort
のどちらかにリンクされます。panic_abort
は簡単で、巻き戻さずに常にプロセスを強制終了します。panic_unwind
はLLVMの例外機構を用いてプロセスの巻き戻しのための関数を提供しています。ここで利用されているcore::intrinsics::try
はcatch_unwind
の実体です。 コンパイラのrustc_trans::intrinsics::try_intrinsic
がこれを生成しています。- また、
-C panic=unwind
の場合は関数にuwtable
属性が付与されます。
まとめ
パニック機構について説明しました。前半ではパニック機構の注意事項(Result
との比較、安全性、二重パニック、不変条件)について説明し、後半ではパニック機構の内部構造を説明しました。