Ruby 3 の Ractorを使って Non blocking readを実現する
趣味でつくっているシミュレーションゲームがあり、その中で Ractor(Ruby3に入った並列処理を実現する機構)を使えそうな場面があったのでやってみた。
Ractorで何ができるかは、下記を参照してほしい。
やりたかったこと
ゲームの中で、コンスタントに「ゲーム内時間」を生成する仕組みがほしかった。
現実時間とゲーム内時間は対応しているが、加速・遅延させることもしたい。
イメージは
- ゲーム開始時は、現実世界と同じ時間の流れ方
- アドミンユーザーのインプットによって、ゲーム内時間の変更が可能
- その時点でのゲーム内時間に応じて、特定の処理をさせたい
時間をtimestampのArrayで実現している想定。
もっとlazyに、時間の判定が必要なロジックが走るまでは全て暗黙的にしておいた方がパフォーマンスは良さそうだが、とりあえず今回はこんな設定。
Ruby3のRactorで書いてみた
Rubyに慣れ親しんでいる人にとって、https://github.com/ruby/ruby/blob/master/doc/ractor.md はすんなり読めると思う。
ちょっと工夫が必要だったのはnon blockingなreadをどう実現するか。
- Main ractor (main process)
- 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が好きになった。
こんなことしなくてもやりたいこと書けるよ、という方はぜひコメントください。
Discussion