🦀

JoinHandle::joinの結果はresume_unwindするといいのではないかという仮説

2020/09/25に公開

catch_unwindJoinHandle::join

Rustのパニックは catch_unwind (スレッド内) または JoinHandle::join (スレッド間) で捕捉できます。以下は JoinHandle::join で捕捉する例です。

use std::thread;

fn main() {
    let th = thread::spawn(|| {
        panic!("test"); // Error: &str: test

        // panic!("test: {}", 42); // Error: String: test: 42

        // panic!(42); // Error: Box<Any>
    });
    if let Err(e) = th.join() {
        if let Some(e) = e.downcast_ref::<&str>() {
            eprintln!("Error: &str: {}", e);
        } else if let Some(e) = e.downcast_ref::<String>() {
            eprintln!("Error: String: {}", e);
        } else {
            eprintln!("Error: Box<Any>");
        }
    }
}

以下は catch_unwind を使う場合です。

use std::panic::catch_unwind;

fn main() {
    let result = catch_unwind(|| {
        panic!("test"); // Error: &str: test

        // panic!("test: {}", 42); // Error: String: test: 42

        // panic!(42); // Error: Box<Any>
    });
    if let Err(e) = result {
        if let Some(e) = e.downcast_ref::<&str>() {
            eprintln!("Error: &str: {}", e);
        } else if let Some(e) = e.downcast_ref::<String>() {
            eprintln!("Error: String: {}", e);
        } else {
            eprintln!("Error: Box<Any>");
        }
    }
}

これらの関数が返す Result にはパニック時にはパニックペイロード、つまり panic! の引数として指定した値が入っています。 panic! の中には T: 'static な任意の値を含めることができます[1]が、複数引数を与えたときは panic!(format!(...)) として解釈されます。

通常、パニックメッセージはパニックハンドラ側で処理されるため、巻き戻し先で別途処理をする必要はありませんが、もし巻き戻し先でも何らかの処理を行いたい場合は Any の型に関する分岐をすることになります。上の例では &'static strString に関して場合分けをしています。

パニックを伝搬する

さて、上の例では JoinHandle::join() の戻り値に特別な処理をしていましたが、多くの場合はスレッドのパニックを特別にハンドルすることはなく、さらに伝搬するのが妥当な選択肢となるでしょう。

let th = thread::spawn(|| { /* ... */ });
th.join().unwrap(); // スレッドがパニックしていたら、自身もパニックする

しかしこれには2つの問題があります。

  • パニックハンドラが2回呼ばれる。
  • パニックペイロードが変化してしまう。

playground

use std::panic::catch_unwind;
use std::thread;

fn main() {
    let result = catch_unwind(|| {
        let th = thread::spawn(|| {
            // ここで thread '<unnamed>' panicked at... が表示される

            // ここで作ったペイロードは下まで届かない
            panic!("test");

            // panic!("test: {}", 42);

            // panic!(42);
        });
        // ここで thread 'main' panicked at... が表示される
        th.join().unwrap();
    });
    if let Err(e) = result {
        if let Some(e) = e.downcast_ref::<&str>() {
            eprintln!("Error: &str: {}", e);
        } else if let Some(e) = e.downcast_ref::<String>() {
            eprintln!("Error: String: {}", e);
        } else {
            eprintln!("Error: Box<Any>");
        }
    }
}

透過的な巻き戻し

そこで別の方法として、別スレッドで発生したパニックを透過的に巻き戻すという方法を考えます。

use std::panic::{catch_unwind, resume_unwind};
use std::thread;

fn main() {
    let result = catch_unwind(|| {
        let th = thread::spawn(|| {
            // ここで thread '<unnamed>' panicked at... が表示される

            panic!("test"); // Error: &str: test

            // panic!("test: {}", 42); // Error: String: test

            // panic!(42); // Error: Box<Any>
        });
        // ここではパニックハンドラは起動しない
        th.join().unwrap_or_else(resume_unwind);
    });
    if let Err(e) = result {
        if let Some(e) = e.downcast_ref::<&str>() {
            eprintln!("Error: &str: {}", e);
        } else if let Some(e) = e.downcast_ref::<String>() {
            eprintln!("Error: String: {}", e);
        } else {
            eprintln!("Error: Box<Any>");
        }
    }
}

この場合、別スレッドで発生したパニックに起因して別のパニックを起こすのではなく、既に発生したパニックに関する巻き戻し処理を再開するというセマンティックスになります。そのため、パニックハンドラは再度実行はされず、パニックペイロードとしても同じものが使われることになります。

準透過的な巻き戻し

上の例ではパニックハンドラが一回しか表示されないので、ある意味ではメッセージが鬱陶しくないですが、スタックトレースの情報が少なくなるためデバッグしにくくなるかもしれません。別の方法として以下のように自力で panic! を呼ぶという方法も考えられます。

use std::panic::catch_unwind;
use std::thread;

fn main() {
    let result = catch_unwind(|| {
        let th = thread::spawn(|| {
            // ここで thread '<unnamed>' panicked at... が表示される

            panic!("test"); // Error: &str: test

            // panic!("test: {}", 42); // Error: String: test

            // panic!(42); // Error: Box<Any>
        });
        // <del>パニックハンドラは起動するが、ペイロードは維持される</del>
        // → AnyをAnyで包んだものができてしまうので、これではうまくいかない
        th.join().unwrap_or_else(|e| panic!(e));
    });
    if let Err(e) = result {
        if let Some(e) = e.downcast_ref::<&str>() {
            eprintln!("Error: &str: {}", e);
        } else if let Some(e) = e.downcast_ref::<String>() {
            eprintln!("Error: String: {}", e);
        } else {
            eprintln!("Error: Box<Any>");
        }
    }
}

この方法の意図は次の通りです。

  • パニックペイロードは透過的に伝搬する。
  • パニックハンドラは両方のスレッドで呼ぶ。

ただし、上の方法はうまく動きません。実際には Box<dyn Any> をさらに Box で包んだものができてしまうからです。 Any を直接受け取るようなパニックエントリポイントがあればいいのですが、ざっくり探してみた感じではうまく行う方法はなさそうです。

伝搬しないほうがいい場合

Drop 内でスレッドのjoinをする場合などは伝搬せず、ロガーなどに投げるのがいいでしょう。(二重パニックを防ぐため)

impl Drop for MyRAIIGuard {
    fn drop(&mut self) {
        if let Some(th) = self.thread.take() {
	    if let Err(_e) = th.join() {
	        // eからメッセージをいい感じに取り出して出力する (詳細略)
	        log::error!("Thread failed");
            }
	}
    }
}

結局どっちがいいの?

筆者は resume_unwind のほうが良いのではないかと個人的に考えていますが、特に実証したわけでもないので実際のところは不明です。また、この件について同様の提案を聞いたことはないので、普通は th.join().unwrap() していると思います。

まとめ

  • Rustのパニックを捕捉するには、スレッド内で捕捉する catch_unwind とスレッド間で捕捉する JoinHandle::join のどちらかを使うことができる。
  • パニック時にはペイロードが Box<dyn Any> 型の値として含まれている。通常この実体は &'static str または String のいずれかであるが、それ以外の値が入っていることもある。
  • th.join().unwrap() と書くと、パニックハンドラが2回呼ばれてしまうほか、元のペイロードが失われてしまう。
  • th.join().unwrap_or_else(resume_unwind) であれば、パニックハンドラは1回だけで、ペイロードは元の形で伝搬される。ただし、呼び出し側スレッドのスタックトレースが失われてしまう。
  • 両者の中間的な実装が考えられるが、そのために必要なAPIが標準ライブラリに存在しないため実際には実装できない。
脚注
  1. #![no_std] 設定時に提供される core::panic!std::panic! とは実装が異なり、フォーマット文字列でない場合は &str 型しか受け付けません。 ↩︎

Discussion