Ruby の Timeout の仕組み

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 を上書きすれば例外発生時に任意の処理を実行できる。