Rustのパニック機構

概要: 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 の書き込みロックを持ったスレッドがパニックすると、その途中の値は不変条件が壊れている可能性があります。これを他のスレッドが間違って読まないようにするため、 MutexRwLock はロック(書き込みロック)を持ったスレッドがパニックした場合、poisonedフラグが立てられ、次回以降のロックが失敗するようになります。

poisonedフラグを元に戻す方法はないようですが(多分)、poisonedフラグによりエラーになった場合、返されたエラーから強制的に壊れた値を読みに行くことはできるようになっています。

パニックの流れ

ここまで、パニックに関連する基本的な注意事項をまとめました。ここからはパニックの仕組みを調べていきます。

まず、ユーザープログラムから見たパニックの流れを説明します。パニックはスレッドごとに処理されます。各スレッドの正常系をここでは次のような図であらわすことにします:

f:id:qnighy:20180218213528p:plain

パニック処理の基本的な流れ (-C panic=unwind) は以下のようになります。

f:id:qnighy:20180218213656p:plain

ただし、 -C panic=abort が指定されているときは以下のようになります。

f:id:qnighy:20180218213741p:plain

パニックハンドラ

パニックハンドラはパニック時にまず呼ばれる処理で、主にスタックトレースの表示などを担当します。デフォルトの処理は以下のようになっています。

  • もしパニックカウントが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に動機が説明されています。

パニック処理の場所

パニックの処理は標準ライブラリとコンパイラの複数の箇所に分散しており追跡が困難です。ここに概要をまとめておきます。

まとめ

パニック機構について説明しました。前半ではパニック機構の注意事項(Resultとの比較、安全性、二重パニック、不変条件)について説明し、後半ではパニック機構の内部構造を説明しました。