Ruby で長い時間掛かるかも知れない処理のタイムアウトを行うにはこんな感じにします。
require 'timeout' begin Timeout.timeout(3) do # 3秒でタイムアウト hoge # 何かの処理 end rescue Timeout::Error puts 'なげーよ' # タイムアウト発生時の処理 end
Timeout.timeout はブロック開始時にスレッドを作成し、そのスレッドで指定された秒数だけ sleep して、sleep から復帰してもまだブロックが終わってなければ作成元のスレッドに対して Timeout::Error 例外を発生させます。
指定時間以内に処理が終わる場合:
timeout(X) │ スレッド作成 ─┐ │ │ ブロック実行 sleep X │ │ スレッドkill→ 🕱 │ timeout復帰
指定時間以内に処理が終わらない場合:
timeout(X) │ スレッド作成 ─┐ │ │ ブロック実行 sleep X : │ : ← 元スレッドに Timeout::Error :
仕組みはシンプルです。ですが、実際に timeout.rb を読んでみると中では結構複雑なことをしてました。
たとえば、次のスクリプトを実行すると、'hoge' ではなく 'main' が出力されます。
require 'timeout' def hoge sleep 5 rescue Timeout::Error p 'hoge' # こっちは表示されない end begin Timeout.timeout(1) do hoge end rescue Timeout::Error p 'main' # こっちが表示される end
hoge 内の sleep 中で Timeout::Error が発生したなら、普通は hoge 内の rescue で処理されるはずなのに、実際には main 側の rescue で処理されています。
確かに使う側からしたらこっちの方が便利なんですけど、Timeout は内部的にどのようにしてこのような処理を実現しているのでしょうか。
例外オブジェクトを作成して、それを元スレッドに対して raise してるのは合ってるんですけど、元スレッドで例外が発生しようとしたら無理やり throw に切り替えてました。
timeout.rb のやってることを簡単に書くとこんな感じです(実際の処理とは異なります):
class Timeout::Error < RuntimeError def exception(*) # 例外発生時には Exception#exception が呼ばれるので throw self # それをフックして代わりに throw する end end def timeout(sec) begin x = Thread.current # 元のスレッド(timeout を呼び出したのと同じ) err = Timeout::Error.new y = Thread.new do sleep sec x.raise err # 元のスレッドに対して例外発生(実際には throw される) end catch(err) do return yield # ブロックが復帰したら return end raise err # タイムアウト時はここで改めて例外発生 ensure y.kill # タイムアウト用スレッド終了 end end
raise - rescue と違って、throw - catch は まったく同じオブジェクト(object_id が同じもの)でないと catch しないので、別の timeout メソッドが作った例外オブジェクトはスルーするんですね。若干トリッキー感が否めませんが。
今回の学び
- Thread#raise で別のスレッドに対して例外を発生させることができる。
- Exception#exception を上書きすれば例外発生時に任意の処理を実行できる。