Ruby の Timeout.timeout に例外クラスを指定する時の注意

ちょっとハマったのでメモ。

Ruby の Timeout ライブラリを使うと、一定の時間が過ぎても終わらない処理を中断することができます。

require 'timeout'

def hoge
  sleep
end

def main
  Timeout.timeout(3) do
    hoge
  end
rescue Timeout::Error => e
  p 'main: timeout', e.backtrace.first
end

main

このスクリプトを実行すると3秒たってから終了します。

% ruby t1.rb
"main: timeout"
"t1.rb:4:in `sleep'"

バックトレースから 4行目の sleep 中でタイムアウトが発生したことがわかります。

次に Timeout.timeout を入れ子にしてみます。

require 'timeout'

def hoge
  Timeout.timeout(5) do
    sleep
  end
rescue Timeout::Error => e
  p 'hoge: timeout', e.backtrace.first
end

def main
  Timeout.timeout(3) do
    hoge
  end
rescue Timeout::Error => e
  p 'main: timeout', e.backtrace.first
end

main

main 中のタイムアウトは3秒で、hoge 中のタイムアウトは5秒です。 ですので sleep 実行中に main のタイムアウトが発生します。 この場合 main と hoge のどちらの rescue で Timeout::Error をキャッチできるのでしょうか。 プログラムを見ると sleep に近い hoge の rescue でキャッチできそうに思えますが、実行してみると次のようになります。

% ruby t2.rb
"main: timeout"
"t2.rb:5:in `sleep'"

正解は main の rescue でした。

使い勝手としてはこの方が望ましいでしょう。外側で指定されたタイムアウトが切れた場合に内側のタイムアウト処理が動いてしまっては混乱してしまいますし、使用しているライブラリが内部で Timeout を使用しているかどうかを調べないといけないというのは大変です。

Timeout ライブラリが内部でうまいことやって、利用者にとって自然な振る舞いになるような仕組みになっています。

ところで Timeout.timeout にはタイムアウト時に発生する例外クラスを指定することができます。 何も指定しないと上記のように Timeout::Error が発生します。

ところが、Timeout::Error を渡してみると動きが異なります。

require 'timeout'

def hoge
  Timeout.timeout(5) do
    sleep
  end
rescue Timeout::Error => e
  p 'hoge: timeout', e.backtrace.first
end

def main
  Timeout.timeout(3, Timeout::Error) do
    hoge
  end
rescue Timeout::Error => e
  p 'main: timeout', e.backtrace.first
end

main
% ruby t3.rb
"hoge: timeout"
"t3.rb:5:in `sleep'"

hoge の rescue が実行されてしまいました。

Timeout.timeout に例外クラスを指定した場合は、「Timeout ライブラリが内部でうまいことやってる仕組み」が働かず、そのまま指定した例外が発生するためです。

Timeout.timeout を入れ子にしてなくても、次のプログラムを実行すると、

require 'timeout'

def hoge
  sleep
rescue
  p '何か失敗した!'
end

class OreOreTimeout < StandardError
end

def main
  Timeout.timeout(3, OreOreTimeout) do
    hoge
  end
rescue OreOreTimeout
  p 'タイムアウト!'
end

main

「タイムアウト!」ではなく「何か失敗した!」が表示されます。

% ruby t4.rb
"何か失敗した!"

OreOreTimeout は StandardError のサブクラスなので rescue で拾われてしまうためです。 StandardError ではなく Exception のサブクラスにすれば rescue で拾われないため、「タイムアウト!」になります。

Timeout.timeout に例外クラスを指定する場合は注意しましょう。