Jewel-mmo開発日記

RubyでMMORPGを作る過程を記録する日記。 Yokohama.rb 発起人。
2011-01-08

[ruby]RubyのFiberを使ってマイクロスレッドでタスクを管理する

@fum1h1ro の開発した社内スクリプト言語ではコルーチンが簡単に書ける。 この言語を使ってゲームを開発するようになって、ようやくこのコルーチンの便利さが理解できた。

RiteVMが出ることだし、RubyのFiberについて調べてみるとまさにこれ!ということがわかり、 ちょっと触ってみたのでメモしておく。

そもそもFiberがどんな時に便利なのかピンと来ない人も多いかもしれない。 オレ自身ごく最近までその便利さが理解できなかった。 だが、もはやこれがないと(ゲームの)プログラムが書けないと言っていいくらい必須なものとなりそうなのだ。

Fiber(コルーチン)が使えるのはこんなケースだ。

1回ボタンが押されると3発のミサイルが発射される仕様のゲームがあったとする。

  1. 1発目のミサイルはボタンを押した瞬間に発射される
  2. 2発目のミサイルはボタンを押した1秒後に発射される
  3. 3発目のミサイルはボタンを押した2秒後に発射される
  4. ボタンが押されてから3秒間はミサイルを発射することができない

今までならカウンタ変数を設けて、カウンタが一定値に達したことを条件としてミサイルの発射処理を実行していた。

ここでFiberを用いると上記のような処理を直接的かつシンプルに表現できる。

以下にFiberを使ったコードを具体的に示す。TaskクラスはFiberを継承したクラスでその小さな実装はあとで紹介する。

maintask = Task.new do
  is_ready = true
  is_over = false
  Task.create { Task.sleep 5; is_over = true }

  until is_over
    if is_ready #and pad_pushed?(:a)
      is_ready = false
      puts "fire1!"
      Task.create do
        Task.sleep 1
        puts "fire2!"
        Task.sleep 1
        puts "fire3!"
        Task.sleep 1
        is_ready = true
      end
    end
    Task.yield
  end

  Task.join
  Task.yield true
end

until maintask.resume
  sleep 0.1
  puts "."
end

タスクは親子関係を持っていて、タスクの中ではTask.createを用いていくつでも子タスクを作ることができる。 Task.yieldが呼ばれるとそこでいったん処理が中断し、 Task.sleepは与えられた秒数だけ処理を中断する。

パッドのボタンが押されたとき、ミサイルの発射処理を行うのは以下の部分だ。

      is_ready = false
      puts "fire1!"
      Task.create do
        Task.sleep 1
        puts "fire2!"
        Task.sleep 1
        puts "fire3!"
        Task.sleep 1
        is_ready = true
      end

簡単に説明すると、まず最初のミサイルを発射した後、新しいタスク(fiber) 作成している。 タスクの中では1秒待って次のミサイルを発射し、もう1秒待ってミサイルを発射し、もう1秒待ってミサイル発射準備を整えているわけだ。is_readyがtrueになるまではここに来ないので次のミサイルを発射することはできない。

ここで作られたタスクは裏で動くことになるので、Task.createメソッド自体はすぐに処理が終了して帰ってくる。

is_overがtrueになるとループが終了するが、 メインタスクが起動してから5秒後にis_overをtrueにするという処理も冒頭でタスク化している。

ちなみに、ミサイルの発射部分のタスクは次のようにそれぞれ別のタスクとして書くこともできる。

      Task.create { Task.sleep 1; puts "fire2!" }
      Task.create { Task.sleep 2; puts "fire3!" }
      Task.create { Task.sleep 3; is_ready = true }

このすれば、最初にパッドが押されたタイミングを基準として発射時間を管理することができる。

maintaskの最後に次の2行がある。

  Task.join
  Task.yield true

joinはタスクの終了を待つメソッドだ。引数を省略すると子スレッドすべての終了を待つ。 最後でTask.yieldにtrueを渡しているのはmaintask.resumeの戻り値をプログラムの終了条件としているためだ。

Fiberを使って処理を書いていくといろんなものがタスク化出来ることに気づく。 きっと発射されたミサイルもそれぞれが独立したタスクとして管理されることだろう。

このように少なくともゲームにおけるキャラクター操作、そしてエフェクト表示やサウンド再生のタイミングなどは、Fiberを用いることでとてもシンプルに管理できるようになるのだ。

RiteVMがでればゲームにRubyを組み込むことはこれまでとは比較にならないほど容易になるだろう。 Rubyで本格的なゲームを作れる日は近いはず。

最後に上記コードを走らせるのに必要なTaskクラスの実装を載せておく。 joinとjoinに関連した部分で長くなっているが、それでも50行ほどの小さなコードだ。

class Task < Fiber
  @@current_task = nil
  attr_accessor :tasks, :status

  def initialize(*args)
    super
    @tasks = []
    @status = :run
  end

  def alive?
    @status
  end

  def resume(*args)
    @@current_task = self
    r = super(self)
    @@current_task = nil
    dead_tasks = []
    @tasks.each do |t|
      begin
        t.resume(t)
      rescue FiberError
        t.status = nil
        dead_tasks << t
      end
    end
    @tasks -= dead_tasks
    r
  end

  def create_task(*args, &block)
    @tasks << Task.new(*args, &block)
  end

  def join(*args)
    args = tasks if args.empty?
    Task.yield while args.flatten.any?(&:alive?)
  end

  def self.create(*args, &block)
    @@current_task.create_task(*args, &block)
  end

  def self.join(*args)
    @@current_task.join(*args)
  end

  def self.sleep(sec)
    t = Time.now
    self.yield while Time.now - t < sec
  end
end

ツッコミは @dan5ya まで。