こんにちは、masm11 です。
Ruby の Timeout モジュールは便利で、
gs = TCPServer.open(0) Timeout.timeout(5) do gs.accept end
このように自由にタイムアウトを設定できます。
今回はこの実装について見ていきたいと思います。
実装を見る
Ruby のバージョンは以下のとおりです。
% ruby --version ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [x86_64-linux]
では実装を見ていきます。
def timeout(sec, klass = nil, message = nil) #:yield: +sec+ return yield(sec) if sec == nil or sec.zero?
sec が nil の場合、または 0 の場合は、そのままブロックを呼び出しています。 この場合はタイムアウトはなしですね。
message ||= "execution expired".freeze
メッセージが指定されていない場合は、デフォルトのメッセージを設定しています。
from = "from #{caller_locations(1, 1)[0]}" if $DEBUG
これは後でスレッドの名前に使っています。
e = Error
e を Error クラスにしています。e は最終的に投げる例外を保持しているようです。
bl = proc do |exception|
ブロックを呼び出す周辺のコードがここにまとめられています。
begin x = Thread.current
これは自分自身のスレッドです。
y = Thread.start { Thread.current.name = from begin sleep sec rescue => e x.raise e else x.raise exception, message end }
これは別スレッドを起動して、指定時間が過ぎたら 元のスレッドで例外を発生させています。
return yield(sec)
指定ブロックを実行しています。 例外が発生しなかった場合はそのまま return しています。
ensure if y y.kill y.join # make sure y is dead. end end end
別スレッドの終了処理です。
if klass begin bl.call(klass) rescue klass => e bt = e.backtrace end else bt = Error.catch(message, &bl) end
例外クラスが指定されている場合は、それを引数として上記 proc を呼び出しています。 そのクラスの例外が発生した場合は、backtrace を bt に入れています。
例外クラスが指定されてない場合は else の方で、Error.catch を呼び出しています。 Error クラスは同じファイル中のすぐ上で定義されています。 catch/throw による大域脱出ができるようですが、 指定ブロックに tag が渡されるわけでもなく、意味はなさそうです。 そして何故返り値が bt として使えるのか全く解りませんでした。
ここから先は、タイムアウトした場合の処理です。
level = -caller(CALLER_OFFSET).size-2 while THIS_FILE =~ bt[level] bt.delete_at(level) end raise(e, message, bt) end
caller は backtrace を取得するメソッドだそうです。 これを使って bt から余分なものを削除し、 それを backtrace として例外を投げ直しています。
個人的には、例外の backtrace は発生した場所を正確に表していて欲しく、 余計な処理だと思います。
まとめ
Timeout.timeout が何をしているかはわかりました。ただし、
https://docs.ruby-lang.org/ja/latest/method/Timeout/m/timeout.html
timeout による割り込みは Thread によって実現されています。
ここはその通りでしたが、
C 言語レベルで実装され、 Ruby のスレッドが割り込めない処理に対して timeout は無力です。
と書いてあるにもかかわらず、割り込めてるんですよね。不思議です…
さて、弊社ではエンジニアを募集しています。詳細は以下からどうぞ。