Ruby の Timeout.timeout の実装を読む

f:id:masm11:20211006192904p:plain

こんにちは、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 は無力です。

と書いてあるにもかかわらず、割り込めてるんですよね。不思議です…

さて、弊社ではエンジニアを募集しています。詳細は以下からどうぞ。

https://ingage.co.jp/recruit