Ruby の Ractor で ActiveObject を作る

公開:2020/10/29
更新:2020/10/30
5 min読了の目安(約5000字TECH技術記事

Ractor とは

2020年12月に出ると噂されている Ruby3 で入るかもしれない並行制御機能です。簡単に並行並列プログラミングが作れるようになる、といいなぁ、と思って作っています。

詳しくはこちら: https://github.com/ruby/ruby/blob/master/doc/ractor.md
仕様はリリースまで変わる予定で、リリース後も多分変わります(つまり、Ruby 3.0 は Ractor がさくっと使えるものではありません)。

作ってばかりだとつらいので、ちょっと遊んでみました。

ActiveObject とは

えーと、すみません、あんまりしっかり定義を知りません。米澤研の方はきっとよくご存じなんだろうと思います。聞くところによると、ActiveObject pattern なるものがあるとも聞いたのですが、それについて書いてある本が高そうで買えませんでした。

43 years of actors: a taxonomy of actor models and their key properties | Proceedings of the 6th International Workshop on Programming Based on Actors, Agents, and Decentralized Control によくまとまってます。

3つのメッセージタイプがあり、past は投げっぱなし、now はすぐに返値を待つ、future は他言語における future/promise みたいなものを返すっぽいです。

なんかメッセージを送ると、並行に実行してくれるオブジェクトみたいなものととらえます。分散オブジェクトと何が違うんだろう。

ActiveObject を作ってみる

使い勝手を検討する

とりあえず、簡単にできそうな past 型のメッセージを送る Ractor::ActiveObject というものを作ります。設計としては、そのクラスを派生したクラスに、適当にメソッドを定義すると、そのクラスへのメソッドは(呼び出し側とは)並行並列に走る、というものです。

こんな感じで Greeterを定義すると、

class Greeter < Ractor::ActiveObject
  def initialize hello = 'Hello'
    @hello = hello
  end
  def hello name
    p "#{@hello} #{name}"
  end
end

こんな感じで呼べるといいなぁ、というものです。

g1 = Greeter.new
g2 = Greeter.new('こんにちは')

g1.hello 'ko1'
#=> "Hello ko1"

g2.hello 'ko2'
#=> "こんにちは ko2"

で、hello メソッドは並列に実行されます。... 並列に実行してもつまらないですね、これ。

いつもの例でつまんないですけど、n 番目の fibnacci 数を求める ActiveObject を作ってみます。

class Fibonar < Ractor::ActiveObject
  def fib_ n
    if n < 2
      1
    else
      fib_(n-1) + fib_(n-2)
    end
  end

  def fib n
    p fib_(n)
  end
end

fs = 10.times.map{Fibonar.new}
fs.each{|fibonar| fibonar.fib(30)}

fs.each{|fibonar| fibonar.fib(30)} この行で、作った10個の Fibonar オブジェクトに対して fib を呼んでいます。こいつらが、それぞれ並列に走ってくれるとよさそうな気がします。なお、Ractor::ActiveObject を継承しなければ、これ、そのまま動きます。

では、Ractor::ActiveObjectを作ってみる

では、作ってみます。

class Ractor
  class ActiveObject
    class Dispatcher
      def initialize klass, *new_args
        @class = klass
        @r = Ractor.new klass, new_args do |klass, new_args|
          aobj = klass.new_obj *new_args

          loop do
            msg = Ractor.receive
            mid, *args = *msg
            aobj.__send__(mid, *args)
          end
        end
      end

      def method_missing id, *args
        @r.send([id, *args])
      end
    end

    class << self
      alias new_obj new
      def new *args
        Dispatcher.new(self, *args)
      end
    end
  end
end

主なアイディアは:

  • Greeter.new とか Fibonar.new すると、それぞれのインスタンスではなく、Ractor を作ってしまい、呼ばれたメソッドを、その Ractor へメッセージを送る dispatcher オブジェクトを返す。
  • Ractor.new の中で、実際にオブジェクトを生成し(klass.new_obj)、送信されたメソッドをそっちで動かす。

これで、上記プログラムが動くようになります。少ない行数でできましたね。これ、返値見ないから簡単なんですよねぇ。

now 型の呼び出しをしたいなら、ちょっと変えればよさそうです。ただ、future どうやって作りましょうね。

future 型

やっぱり、返値欲しいですよねぇ、ということで作ってみました。

class Ractor
  class ActiveObject
    class Dispatcher
      def initialize klass, *new_args
        @class = klass
        @ret_r = Ractor.new do
          loop do
            Ractor.yield Ractor.receive
          end
        end

        @r = Ractor.new klass, new_args, @ret_r do |klass, new_args, ret_r|
          aobj = klass.new_obj *new_args

          loop do
            msg = Ractor.receive
            mid, *args = *msg
            ret_r.send aobj.__send__(mid, *args)
          end
        end
      end

      def method_missing id, *args
        @r.send(Ractor.make_shareable([id, *args]))
        @ret_r
      end
    end

    class << self
      alias new_obj new
      def new *args
        Dispatcher.new(self, *args)
      end
    end
  end
end

一つ返す用の Ractor を用意(@ret_r)して、それ経由で受け取っています。Erlang などの receive では、受け取り側を用意して、不要であれば(パターンにマッチしなければ)受信を pending できるのですが、Ruby の Ractor ではそういうことができないので、チャンネルっぽい Ractor を用意しているというわけです。

class Fibonar < Ractor::ActiveObject
  def fib n
    if n < 2
      1
    else
      fib(n-1) + fib(n-2)
    end
  end
end

fs = 10.times.map{Fibonar.new}
rs = fs.map{|fibonar| fibonar.fib(30)}
rs.each{|r| p r.take}

こんなコードがちゃんと動きます。
future オブジェクトが返るので、rs = fs.map{|fibonar| fibonar.fib(30)}では計算が行われているかどうかは問いません。そのあとの r.take で待つわけです。

しかし、take よりも value のほうがいいな、ここでは。

考え出すと、本当にこれでいいのかいろいろある気がしますが、とりあえず、これくらいにしておきます。ちょっと書くだけで、なんだか、おっと、というようなことが書けるのが、スクリプト言語のいいところですよね。

とくにオチはありません。

今回のあまり面白くもないオチ

オチはないと言いましたが、思いついたので一個だけ。ActiveObject を使わないで、ただ Ractor を使うだけだと、Fibonar の例はこんな感じになります。

def fib n
  if n < 2
    1
  else
    fib(n-1) + fib(n-2)
  end
end

rs = 10.times.map do
  Ractor.new do
    fib(30)
  end
end
rs.each{|r| p r.take}

で同じことができます。Ractor自体は、こういう Promise みたいなのがさくっと書けるんですよねぇ(Ractor.new が Promise 返すようなイメージ)。

ActiveObject とどっちがいいですかね。Ractor.new とか一切書かないので、ちょっと簡単そうに見えるような気がします。また、複雑になってくると、ActiveObject のほうがやりやすいとかあるかもしれませんね。例えば、今回は fib(n) しかありませんでしたが、メソッドが複数になると、また違うのかも(あまり考えていません)。

ちなみにちなみに。そもそもRactorをActiveObjectというインターフェースで提供する、という案もありました。が、ちょっと自由度が高すぎるので、Ractor自体はそういうのを実装するための基盤とすることができる、ということを念頭に置いています(不足がないようにしたいと思っています)。

ところで、そもそも ActiveObject の定義ってこれでいいのかな...。