Ruby 3 の Ractorを使って Non blocking readを実現する

3 min read読了の目安(約2800字

趣味でつくっているシミュレーションゲームがあり、その中で Ractor(Ruby3に入った並列処理を実現する機構)を使えそうな場面があったのでやってみた。

Ractorで何ができるかは、下記を参照してほしい。

やりたかったこと

ゲームの中で、コンスタントに「ゲーム内時間」を生成する仕組みがほしかった。
現実時間とゲーム内時間は対応しているが、加速・遅延させることもしたい。

イメージは

  1. ゲーム開始時は、現実世界と同じ時間の流れ方
  2. アドミンユーザーのインプットによって、ゲーム内時間の変更が可能
  3. その時点でのゲーム内時間に応じて、特定の処理をさせたい

時間をtimestampのArrayで実現している想定。
もっとlazyに、時間の判定が必要なロジックが走るまでは全て暗黙的にしておいた方がパフォーマンスは良さそうだが、とりあえず今回はこんな設定。

Ruby3のRactorで書いてみた

Rubyに慣れ親しんでいる人にとって、https://github.com/ruby/ruby/blob/master/doc/ractor.md はすんなり読めると思う。
ちょっと工夫が必要だったのはnon blockingなreadをどう実現するか。

  1. Main ractor (main process)
  2. Game tick ractor (produces game even every n seconds)

という二つのRactorがいたとして、2 -> 1へのメッセージが発生するまで1をブロックすることは避けたい。ということで、もう一つ "Filler" というRactorを用意して、Ractor.selectでそちらからもメッセージを読めるようにした。

詳しくはラインごとにコメントをつけているので下記を読んで欲しい。

# Ruby 3.0.1 で動作確認済み
class Ticker
  def initialize
    # non blocking readをするためのRactor生成クラス。クラス・モジュールはRactor間でSharableなのでこうした。
    @filler = FillerMaker.make
    # ゲーム時間を生成するRactorをつくっておく。
    @ticking = make_ticking
  end

  # ゲーム時間の加速、遅延を受け付けるメソッド。<<は`Ractor#send`のAlias。
  def change_speed(speed)
    raise unless speed.is_a?(Numeric)
    @ticking << speed
  end

  # Non blocking read. Ractor.currentにメッセージが届いていればそれを読むし、でなければ'noop'を返すためだけのfillerから読む。
  # いつ呼び出すかによってメッセージがどれくらい溜まっているかは可変なので、whileなどを使って実際には使うイメージ。
  def reads
    _sender, yielded = Ractor.select(Ractor.current, @filler.tap { |f| f << 'noop' })
    yielded
  end
  
  # 主にDebug/test用のblocking read。
  def blocking_reads
    Ractor.current.take
  end

  private

  module FillerMaker
    def make
      # https://github.com/ruby/ruby/blob/master/doc/ractor.mdではPipeとなっている実装。入れた瞬間、入れたものを返すRactor。
      Ractor.new do
        loop do
          Ractor.yield Ractor.receive
        end
      end
    end
    module_function :make
  end

  def make_ticking
    # Ractor自身はSynchronizationが内部実装されているらしい(どういう意味だろう)ので、main ractorをmessage receieverとして渡す。
    Ractor.new Ractor.current do |main|
      filler = filler_maker.make
      current_speed = 1

      elapsed = Time.now
      loop do
        # ゲームスピードの変更を受け付けるselect.
        _sender, speed = Ractor.select(Ractor.current, filler.tap { |f| f << 'noop-speed' })
        if speed.is_a?(Numeric)
          current_speed = speed
        end
        now = Time.now
        if ((now - elapsed) * current_speed) >= 3600
	  # main ractorにゲーム内発行イベントを送る
          main << [now, current_speed]
          elapsed = Time.now
        end
      end
    end
  end
end

おわり

Thread/Fiber使った方が普通そう。でもRactorを使ってみたかった(そもそもThread programming本気でしたことがない)。

Productionで運用するならRedisとかでRactorで書いた部分は実装すると思う😇 でも、Rubyだけで色々できる、という自信が今後もRubyを使い続けたいなら大事だと思う。

素直に書けたので、RactorのAPIが好きになった。
こんなことしなくてもやりたいこと書けるよ、という方はぜひコメントください。