Ruby 3.0 の Ractor を自慢したい

Ruby の開発をしている技術部の笹田です。娘が自転車に乗り始め、まだ不安なためずっとついていなければならず、少し追っかけまわしただけで息切れがヤバい感じになっています。運動しないと。

ここ数年、Ruby で並列処理を気軽に書くための仕組みである Ractor を Ruby 3.0 で導入するという仕事を、クックパッドでの主務として行ってきました(クックパッドから、これ、と言われていたわけではなく、Ruby を前進させるというミッションの上で行ってきました)。

Ractor は、もともと Guild という名前で開発をはじめ、2020年の春頃、Ractor という名前に変更することにしました。いくつかの機会で発表しています。下記は、RubyKaigi での発表の記録です。

そして、昨日リリースされた Ruby 3.0 で導入されました。やった! ただ、まだ仕様が変わりそうなことと、色々実装がこなれていないので、実験的機能として導入されており、使うと警告が出る状態です。

本稿では、Ractorの簡単なご紹介と、Ractor の(私の考える)位置づけ、そして将来の Ruby (主語が大きい)についてご紹介します。あまり how to な内容ではありません。

Ractor 自体の詳細は、ruby/ractor.md at master · ruby/ruby にあるのでご参考になさってください。また、先日の本ブログ記事 Ruby に Software Transactional Memory (STM) を入れようと思った話 - クックパッド開発者ブログ にも、いくつか基本的な使い方が載っています。

簡単な Ractor の紹介

例を用いて、Ractor の機能と現状について簡単にご紹介します。

Ractor での並列処理で、実際に速くなる例

Ruby 3.0 のリリース文(Ruby 3.0.0 リリース)にある、Ractor プログラムの例を見てみましょう。ここ、私が書きました。引用します。

def tarai(x, y, z) =
  x <= y ? y : tarai(tarai(x-1, y, z),
                     tarai(y-1, z, x),
                     tarai(z-1, x, y))
require 'benchmark'
Benchmark.bm do |x|
  # sequential version
  x.report('seq'){ 4.times{ tarai(14, 7, 0) } }

  # parallel version
  x.report('par'){
    4.times.map do
      Ractor.new { tarai(14, 7, 0) }
    end.each(&:take)
  }
end

(1行 def と呼ばれる新機能を使っているのがオシャレポイントです。定義自体は4行だけど)

このプログラムでは、ベンチマークでよく用いられる竹内関数(竹内関数 - Wikipedia )tarai(14, 7, 0) を、4回実行するか(seq)、Ractor を用いて4並列実行するか(par)で、実行時間を測っています。

Ractor.new { tarai(14, 7, 0) } が、新しい Ractor で tarai() 関数を実行する部分です。Thread.new{} のように、ブロックの部分を新しい Ractor(の中で作った Thread)で実行します。Ractor をまたいだスレッドは並列に実行されるので、この tarai() も並列に実行されるというわけです。

Ractor#take によって、その Ractor が値を返すのを待つことができます。

さらに、結果をリリース文から引用します。

Benchmark result:
          user     system      total        real
seq  64.560736   0.001101  64.561837 ( 64.562194)
par  66.422010   0.015999  66.438009 ( 16.685797)

結果は Ubuntu 20.04, Intel(R) Core(TM) i7-6700 (4 cores, 8 hardware threads) で実行したものになります。逐次実行したときよりも、並列化によって3.87倍の高速化していることがわかります。

このマシン、笹田の自宅にあるマシンなんですが、ちゃんと4並列で4倍近い性能が出ていてよかったね、という結果になっています。こんな感じで、Ractor を用いることで、並列計算機上で並列処理を行うことができ、うまくいけば並列実行による速度向上が狙えます。

現状の Ractor

先ほどの例では、4倍近い高速化を達成することができました。ただ、これベストケースというか、チャンピオンデータというか、うまくいく例でして、多くの場合、Ractor 自体は、まだまだうまいこと性能が出せていません。

例えば、リリース直前に発見した、性能上の大きな問題。デモのために、あまり意味がありませんが、tarai 関数の先頭で、Object を参照してみましょう。

def tarai(x, y, z) = Object &&
  x <= y ? y : tarai(tarai(x-1, y, z),
                     tarai(y-1, z, x),
                     tarai(z-1, x, y))

必ず真になるので、不要な参照です。では、同じようにベンチマークをとってみましょう。

          user     system      total        real
seq  79.807530   0.000000  79.807530 ( 79.807818)
par 902.635763 432.107713 1334.743476 (343.626728)

なんと桁違い。4倍速いならぬ、4倍遅い、という残念な結果になってしまいました。なぜこんなことになってしまうかというと、定数(Object)の参照が遅いためです。

理由を少し解説すると、次のようになります。

  • (1) 定数参照時に利用するインラインキャッシュがスレッドセーフでなかったため、main Ractor 以外ではキャッシュを無効にしていた
  • (2) 定数参照時、定数テーブルは Ractor 間で共有するため、ロックを行うが、ロックが競合するとむっちゃ遅い

(1) と (2) の相乗効果でだいぶ遅くなってしまっています。残念無念。リリース直前に発覚したので、これから直そうと思っています(修正自体は、そんなに難しくない)。Ractor 自体は、こういうのがチョイチョイありそう、というクオリティーになっています。

これに限らず、これからいろんなフィードバック(主に苦情)を受けると思います。それらに対処していくことで、完成度をあげていこうと思っています。というわけで、「これおかしいんじゃないの?」とか、「ここが遅いんだけど」といったフィードバックを歓迎します。伸びしろしかないRactorを、一緒に育てていってください。

というわけで、まだそういうクオリティなので、Ractor.new{} すると警告が出ます。

warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.

書いてある通り、仕様も fixed というわけではないので、変わるかもしれません。こちらもフィードバックをお待ちしております。

Ractor の基礎

Ractor の仕様は、下記のポイントを基礎としています。かいつまんでご紹介します。

  • Ractor.new{} で複数の Ractor を作ることができ、それらは並列の実行される
  • Ractor 間のオブジェクトの共有はだいたい禁止されている
    • 共有不可 (unshareable) オブジェクト
    • 特殊な共有可能 (shareable) オブジェクトだけ共有可能
      • Immutable オブジェクト
      • Class/Module オブジェクト
      • その他
  • 2種類のメッセージ交換方式
    • pushåž‹: r.send(obj) -> Ractor.receive
    • pullåž‹: Ractor.yield(obj) -> r.take
r = Ractor.new do
  Ractor.receive # :ok を受診し、それをブロックの返値とする
end  

r.send(:ok) # r へ :ok を送る(push)
p r.take #=> r のブロックの返値 :ok を取得する(pull)
  • Ractor.select による同時待ち
r1 = Ractor.new{ :r1 }
r2 = Ractor.new{ :r2 }
r, msg = Ractor.select(r1, r2)
# どっちか早く終わったほうのメッセージが得られる
  • メッセージの送信方法
    • 複製: ディープコピーして送信
      • r.send(obj)
    • 移動: 浅いコピーを行うが、送信元ではそのオブジェクトを利用不可(使うと、どんなメソッドも method_missingになる) ruby r.send(obj, move: true) obj.inspect #=> `method_missing': can not send any methods # to a moved object (Ractor::MovedError)
      • 情報の世界では、自動的にコピーになることが多いので、「移動」という概念は面白いと思う(これが、Guild という言葉の由来だった)
  • 複数 Ractor を動かす場合、いくつかの機能に制限(後述)

詳細はドキュメント(ruby/ractor.md at master · ruby/ruby)、もしくはRuby に Software Transactional Memory (STM) を入れようと思った話 - クックパッド開発者ブログ の冒頭の例などをご覧ください。

以降は、最近入って、まだあまり紹介されていない機能についてご紹介します。

Ractor#receive_if による選択的受信

Ractor.receive は Ractor に送られたメッセージを、FIFO で取り出すという機能でした。ただし、これだと、複数の Ractor から順不同で送られてくるメッセージを、区別して扱うことができません。

Erlang/Elixir などの言語では、ここでパターンマッチを用います。

(Processes - The Elixir programming languageから引用)

# elixir の例
iex> receive do
...>   {:hello, msg} -> msg
...>   {:world, _msg} -> "won't match"
...> end
"world"

この例では、receiveで、pat -> expr のように、pat にマッチしたメッセージが見つかれば、expr を実行する、のように記述することができます。

Ractor.receive で似たようなことをすると、マッチしなかったとき、incoming queueにメッセージを戻すことができないため、似たような機能を作ることができません(receive済みのメッセージをためておく仕組みと、そこから取り出す仕組みを作って、receive は直接用いない、とすればできんこともないです)。

そこで、Ractor.receive_if が(結構リリース直前に)導入されました。

Ractor.receive_if{|msg| /foo/ =~ msg}

この例では、受信したメッセージのうち、/foo/にマッチする場合、ブロックが true を返し、そのときはじめて incoming queue からメッセージを削除します。

この機能を用いることで、あるパターンに合致したメッセージのみ受信することができます。

ただ、Erlang/Elixir にあったような、パターンA なら処理A、パターンBなら処理B、というようなことは書けません。というのも、このブロックは述語として true/false を返すべきものであるからです。

無りやり書くとすると、こんな感じで Proc (labmda) を返し、それをブロックの外側で実行する、として記述することが可能です(break などでブロックを抜けると、true を返したときのように incoming queue からメッセージを抜きます)。そして、その後に実行したい処理を Proc で返しているので、それを呼べば対応する処理(taskA か taskB)を実行できる、というものです。

Ractor.receive_if do |msg|
  case msg
  when patA
    break -> { taskA(msg) }
  when patB
    break -> { taskB(msg) }
  end
end.call

が、これも正直書きたくないので、Ruby 3.1 以降にマクロが入れば、なんかいい感じにできそうだなぁ、と考えています。

複数 Ractor を動かす場合、いくつかの機能に制限

これまで、Ractor がなければ問題なく使えてきた機能が、Ractor 間でのオブジェクトの共有を排除するため、複数 Ractor 環境において制限されました。Ractor を使わなければ(main Ractor だけで利用するなら)、これまで通り制限はありません。

具体的には、次の操作が main Ractor だけで利用可能になります。

  • (1) グローバル変数、クラス変数の設定・参照
  • (2) 定数に共有不可オブジェクトの設定・参照
  • (3) 共有可能オブジェクトのインスタンス変数の設定・参照(とくに、クラス・モジュールで問題になる)

どの機能も、使われていると一発で main Ractor 以外で利用できなくなります。この中で、一番はまりそうなのは、(2) と (3) でしょうか。

C = ["foo", "bar"]  # NG: (2) 定数に共有不可オブジェクトを設定

class C
  @bar = 42     # NG: (3) 共有可能オブジェクトのインスタンス変数を設定
  def self.bar
    @bar        # NG: (3) 共有可能オブジェクトのインスタンス変数を参照
  end
  def self.bar=(obj)
    @bar = obj  # NG: (3) 共有可能オブジェクトのインスタンス変数を設定
  end
end

よく使われていそうなプログラムです。この制限により、多くのライブラリを、複数 Ractor 上で利用することが、現在できません。今後、うまいこと書き換えが進むと、Ractor は利用しやすいものになっていくと思います。

さて、ではどのようにすればいいでしょうか。

(2) については、# shareable_constant_value: ... というプラグマが新設されました。

# shareable_constant_value: literal

C = ["foo", "bar"]

このオプションで none(デフォルトのモード)以外を選ぶと、定数が共有可能オブジェクトを参照している、ということを保証できます。この例では、literal を選んでいます。これは、定数の右辺値、つまり代入するオブジェクトがリテラルのように記述されたオブジェクトなら、再帰的にfreezeしていくことで、immutable な共有可能オブジェクトを生成し、定数に代入します。リテラルでなければ、共有可能であるか実行時にチェックすることで、共有不可オブジェクトが定数に代入されることを防ぎます。

指定できるオプションは、none と literal 以外に、この2つが指定できます。

  • experimental_everything
    • 右辺値の値を共有可能オブジェクトに変換する
  • experimental_copy
    • 右辺値の値をまずコピーし、コピーに対して共有可能オブジェクトに変換処理を行う

everything は副作用が気になりますが、copy は元のオブジェクトに影響を与えないため、副作用がほぼ起こりません。ただし、コピーによって若干時間がかかるかもしれません。

(3) の、共有したい mutable な値については、gem になりますが、Ractor::TVar(ractor-tvar | RubyGems.org)を用いると良いと思っています。

class C
  BAR = Ractor::TVar.new 42
  def self.bar
    Ractor::atomcally{ BAR.value }
  end
  def self.bar=obj
    Ractor::atomcally{ BAR.value = obj }
  end
end

Ractor::atomcally を毎回書かないといけないのは冗長な気もしますが、ここで Ractor 間にまたがる共有状態を操作している、というのが明示できて、長い目で見ると利点になるのではないかと思っています。

拡張ライブラリの Ractor 対応

C などで記述された拡張ライブラリは、デフォルトでは main-Ractor 以外では動きません(提供されているメソッドを呼ぼうとすると、Ractor::UnsafeError になります)。

対応させるためには、複数 Ractor で実行していいことを確認して、rb_ext_ractor_safe(true) で、この拡張ライブラリが Ractor のサポートをしていることをインタプリタに教えてあげることが必要です。

対応させるためのチェックポイントについて、詳細は、ruby/extension.rdoc at master · ruby/ruby にまとめてあります。ただ、あんまり変なことしてなければ、たいてい Ractor 対応は簡単じゃないかなと思っています。

Ractor の背景

ここからは、具体的なコードの話ではなく、Ractor に関する検討について、その一端をご紹介します。

複数コアのCPUが普通になってきた昨今、並列計算を記述する、というニーズはどんどん高まっています。というフレーズは、私が大学で研究していた10年以上前から定番の前振りでした。実際、高性能なソフトウェアを書くのに、並列計算は必須であることにどなたも異論はないでしょう。

並列計算を行うためには、プログラムが並列計算に対応していなければなりません。そのためには、並列プログラミングが必要になります。すでに、多くのプログラミング言語が並列計算のための仕組みを備えています。

スレッドプログラミングは難しい

ただ、並列計算を行うプログラムは、だいぶ面倒くさいことが知られています。とくに、スレッドプログラミングは、いろいろな理由から、正しいプログラムを書くことが困難です。Ruby でも、スレッドは Thread.new{ ... } と書くことで、簡単に作ることができます。

たとえば、同じメモリ領域に、複数のスレッドが同時に読み書きすると、おかしなことになります。「この順番で読み書きしているから大丈夫」とすごく考えてプログラムをかいても、コンパイラの最適化によって読み書きの順序が変わったりして、逐次処理しているときには気づかなかった問題が生じることも多いです。この問題を、よくスレッド安全の問題といいます。

デバッグ時には、非決定的(non-deterministic)な挙動が問題になります。逐次処理は、2度実行すれば、だいたい同じ結果になります(そういう筋の良いバグが多いです)。しかし、複数のスレッドがどのように動くかは、スレッドマネージャの仕事になり、一般的には制御することは難しく、2度目の実行では異なる結果になることが多いです。そのため、問題が発覚しても、その問題を再現することが難しく、つまりデバッグがすごくしんどいわけです。

この非決定性は、ほんとうにタイミングよく何かしないと起きないバグなんかだと、めったに再現しないので、がんばって修正をしても、本当にその問題が解決したのかわからない、といった話もあります。

メモリを共有した同時の読み書きは難しい

スレッドプログラミングの1つの問題点は、複数スレッドでメモリを同時に読み書きが可能である、という点が挙げられます。同時に読み書きが起こる可能性があるメモリ領域においては、ロックをかけて排他制御するなど、他のスレッドと同期しながら処理を進める必要があります。

が、往々にして、こういう「ちゃんとアクセスする前にはロックをとる」みたいなものは、忘れがちです。人間は、ウッカリをするものです。私はしょっちゅう忘れて痛い目にあっています。

「私は大丈夫、ちゃんと同期とか仕込める」という人も、うっかりやっちゃう可能性はいくらでもあります。いくつか、うっかりしそうな例を並べてみます。

  • プログラムの規模が大きくなり、想定と別の用途でデータを用いて、うっかりロックが必要であることを忘れる
  • データ構造が複雑化し、共有されていることに気づかず、うっかりロックを忘れる
  • 別の人(将来の自分かも)がうっかりロックを忘れてアクセスする

他にもいろいろあると思います。

ちなみに、「ちゃんと動くプログラムを書く」というのも難しいですが、さらに「速いプログラムを書く」というのも難しい問題です。例えば、異なるメモリには、異なるロックを本当に必要な時にだけ用いたほうが(つまり、細粒度ロックを用いるほうが)並列度はあがり、並列処理の性能向上をますが、ロックの処理(獲得と開放)を頻繁に行う必要が出てきて、下手に作ると遅くなってしまいます。

難しさに対する対応策

もちろん人類は賢いので、様々な対策を考えてきました。

  • ロックなどをきちんと使っているか、チェックするツールの利用(valgrin/helgrind、thread-sanitizer、...)
  • ロックなどを自然に使うことができるデータ構造の導入(同期キュー、Transactional memory、...)
  • 型によるデータの所有系の明示(Rustなど)
  • 書き込みを禁止して、同時に読み書きを起こさない(Erlang/Elixir, Concurent-haskell など)
  • そもそもプロセスなどで分離して、共有しない(shell, make)

が、どれも完全に解決するのが難しいか、Ruby に導入するのは困難です(個人の見解です。別の見方もあると思います)。

  • ツールは漏れが生じます。また、MRI の構成上、(現実的なコストで)実現がなかなか困難です
  • データ構造を正しく扱えば問題なくても、ロックを忘れるのと同様にうっかり正しくない使い方をしてしまいます
  • Ruby にはこの手の型を記述する方法がないため困難です(文法を入れるのはきっと難しい)
  • 書き込み禁止(例えば、インスタンス変数への代入禁止)は、互換性を大いに壊します

最後の「そもそもプロセスなどで分けて、原則状態を共有しない」という shell などで利用されているアプローチは、Ruby でもマルチプロセスプログラミングとしてすでに行われています。dRubyやUnicorn、paralle.gem のプロセスモードなどがこれですね。通信する場合にひと手間かける、というアプローチになっています。

このモデルでは、それぞれのコンポーネントを単純に作ることができ、まさに UNIX 流の開発の利点が効いてきます。パイプなどでうまくつなげることで、それぞれが独立に並列実行させることができたり、make で依存関係のルールを記述することで、それぞれのタスクを良い感じに並列実行させることができます。また、別の計算機に処理を分散させることも、比較的容易です。

ただ、プロセスを複数いい感じに並べるだけだと、パイプだけだとちょっと表現力が弱く(パイプライン並列処理に特化している)、make も、あまり複雑なことは書けません。先述した Unicorn なども、あるパターンに特化していますね。

それから、コミュニケーションを主にパイプで行うため、通信のための手間が、複雑なコミュニケーションを行う場合は結構大変になります。また、実行単位がプロセスになることが多いので、タスクが多い場合、リソース消費が問題になる可能性があります。

Ractor の狙い

Rubyのモットーは「たのしいプログラミング」というところだと思います。できるだけ、難しいこと、面倒なことはしなくても良いようにするといいと思っています。

(現在、Ruby でも行うことができる)スレッドプログラミングは、その点で考えることがたくさんで、いざバグが入ると直すのが難しいという、「たのしさ」からは離れた機能ではないかと思うようになりました(難しいスレッドプログラミングをきちんとやる「たのしさ」もあると思うので、まぁ一概には言えないのですが)。この話は、手動メモリ管理と自動メモリ管理の話に似ていると思っています。つまり、ちゃんと作れば手動メモリ管理は効率的だったりしますが、うっかり間違えてしまったり、バグの発見は難しいし、というような。

そのため、多少性能を犠牲にしても(最高性能は出ないにしても)、なんとなく書けばちゃんと並列に動く、というのを目指すと良いのではないかと思い、Ractor を設計しています。

前節で述べた並列並行プログラミングの問題点を解決するために、Ractor はどのようなアプローチをとっているかご紹介します。

共有しない、がちょっと共有する

並列プログラミング言語において、あるメモリに対する read/write を混ぜない、というのは大事な観点であることをご紹介しました。並行並列に実行する処理が、共有状態を持たないと、問題が簡単にいなるわけです。

そこで、Ruby でこれを実現するのが Ractor です。Ractor という単位でオブジェクト空間を「だいたい」分けて、お互いに干渉させないようにさせます。これで、いわゆる同期漏れによるスレッド安全の問題が、だいぶ解決されます。

ただし、全部分けるとプロセスと同じでそれはそれで不便となるので、いくらか共有してもだいたい大丈夫だろうと思われるものを共有します。これが、プロセスで完全に分離してしまうことに対する利点になります。

この、ちょっとだけ共有することで、下記の利点が生じます。

  • Ractor 間の通信が、少し書きやすくなる
  • Ractor 間の通信が、少し速くなる
  • Ractor 間でメモリを共有することで、メモリ消費が減る

ウェブアプリケーションサーバにおいて、スレッドモデルが好まれるのが、「メモリ消費が減る」ではないでしょうか。Ractorでは、そのへんをそこそこ狙っています。

ただし、ちょっと共有することで、スレッド安全に関する問題が残ります。これは、本当に難しい問題で、利点を取るか欠点を取るか、ずいぶん悩んだのですが、今回は利点を優先することにしました。「まぁ、だいたい大丈夫だろう」というやつです。

ほかの言語では、Racket という言語で place という、Ractor と似た isolation を行う仕組みがあります(Places: adding message-passing parallelism to racket | Proceedings of the 7th symposium on Dynamic languages )。Ractor とよく似ていますが(だいぶ参考にしました)、通信の方法が、Go 言語のように、チャンネルを用いるというのが Ractor と異なります。

Actor model と CSP (Communicating Sequential Processes)

よく Erlang と Go の比較で、前者が Actor、後者が CSP を採用している、みたいな話があります。大雑把に言うと、前者が通信対象を並行実行単位(アクター)に、後者を並行実行単位をつなぐチャンネルに対して行うのが特長になるかと思います(厳密には多分違うと思うんですが、ここではそうとらえてみます)。

Ractor は、名前の通り Actor model を強く意識して設計されています。実は、2016年の開発当初では、とくに何も考えずに CSP 的なモデルを考えていました。ただ、数年色々考えた結果、Actor model のほうがいいかな、と思って、現在の設計になっています。

Actor model の利点はスケールがしやすいことと言われています。これ、作ってみるとわかるんですが、待ちが生じるのが、いわゆるアクターへ送られたメッセージを受信する操作に限定されるんですよね。複数のチャンネルを待ったりするより、自分自身に送られてきたメッセージを監視するだけのほうが楽なのです。他にも、コンポーネントを疎にしやすい(例えば、アクターが別の計算機にいてもよい)といった良い性質を持ちます。

が、あまりそのへんが Actor model 型のインターフェースにした理由ではなく、例外の伝搬を適切に行うことができるか、という観点から、現在のデザインにしました。

相手を指定する操作において(具体的には、Ractor#send による送信時に、もしくは Ractor#take による受信時)、相手の Ractor がすでに例外などで終了していた場合、エラーで気づくことができます。つまり、エラーが出ていることを、Ractor 間で適切に伝搬させることができるわけです。

CSPでは、処理の対象がチャンネルなので、その先につながっている並行実行単位の状況はわかりません(そもそもつながっていないかもしれない)。適切にチャンネルをクローズする、という手もありますが、ひと手間かかります(つまり、一手間を忘れる可能性があり、そして可能性があれば人は忘れる)。ソケットなんかは似たようなモデルですが、プロセスに紐づいているので相手側に状況が伝わります。こういうモデルでもよかったかなと思うのですが、うまいこと簡単に扱うAPIに落とし込めませんでした(チャンネルをさらにほかのRactorに渡すような用途で、うまいことモデリングできませんでした)。

いくつかのパターンでは、CSP のほうが書きやすい、というのがわかっていたのですが、Ractor 自体をチャンネルのように使えば、性能を気にしなければ、実は CSP とほぼ同じようなことができることがわかったので、とりあえず Actor model 風のインターフェースをベースにしました。性能はあとでなんとかしよう、と思っています。Actor っぽい push 型のコミュニケーション手段と、Actor っぽくない pull 型のコミュニケーション手段が混ざっているのは、この辺を作りやすくするためです。

余談ですが、pull 型のコミュニケーションは、Promise を簡単に作れる、というような感じにしています。

r = Ractor.new{ expr }
do_some_task()
r.take # ちょうどいいタイミングで値を得る

Promise には並列に実行する、という意味はあんまりないんですが、Ractor ではそれを実現しています。さらに余談ですが、先ほど紹介した Racket の Future (Promise みたいなやつ) はもっとかっこよくて、スレッドで並列に動かすんだけど、thread-safety を危うくする処理(つまり、共有状態への操作)を検出すると、そこで止まるというかっこいい奴になっています。かっこいいなぁ、真似したいなぁ、と思うのですが、Ruby だといろいろ難しくて断念しました。

コピーと移動

互いに分離された環境で通信するとき、コピーによってメッセージを渡すのは、よくある方法です。ただ、それだけだと他と同じで面白くないな、と思って考え付いたのが移動です。

情報の分野において、送ったメッセージが、その後参照できなくなるとういうのは、あまり聞いたことがないので面白いなぁと思って、Guild といってた時の目玉機能と思っていました。そもそも、Guild という名前は、Guild のメンバーが移籍する(moveする)という意図で見つけた名前でありました。

が、まぁ普段は使わない(コピーで十分)ということで、あまり前面に出さないようにして、そうすると Guild という名前もなんだね、ということで、Ractor に改名されました。

Ractor が使えるようになるまでの、まだまだ長い道のり

このように、とりあえず入った Ractor ですが、便利に利用するにはいくつかのハードルがあります。

利用者としての課題

まずは、ライブラリの対応がたくさん必要になります。とくに、先に述べた2点

  • (2) 定数に共有不可オブジェクトの設定・参照
  • (3) 共有可能オブジェクトのインスタンス変数の設定・参照(とくに、クラス・モジュールで問題になる)

については、だいぶ書き換えが必要になると思います。本当に大丈夫か、ってくらい。あと、説明してませんでしたが、define_methodによるメソッド定義で使うブロックが、ふつうのブロックだと他の Ractor では使えないというのがあります。まずそうな点です。

ライブラリがないと Ruby の魅力はものすごく下がってしまいます。そのため、これらの変更に追従していただけるかどうかが、Ractor が成功するかどうかの分水嶺になるかと思います。

使いづらいところがあれば、Ractor 側で改良したり、便利なライブラリを提供していったりしていきたいと思います。フィードバックをお待ちしております。

書き換えはいろいろ面倒なのですが、これは、スレッド安全を解決するための、見直すための良い指針の一つになる可能性があります。いままで、スレッド安全について、テストで問題ないし、なんとなく平気かな、と思っていたところが、ぜったい大丈夫、という安心感に代わるんではないかと思います。

並行並列処理時代の Ruby に書き換えるという、個人的には Ruby の性質を変える話じゃないかと思います。

実装上の課題

最初にご紹介した通り、性能上の問題、そしてバグが残っています。随時直していこうと思いますので、こちらもフィードバック頂ければと思います。

以下、実装の課題について、箇条書きで並べておきます。

  • 性能改善
      * ObjectSpace の物理的な分離+分散GC
      * Fiber context による Thread の実装)
      * 単一ロックではなく、細粒度ロックによる並列度の改善
      * その他もろもろ(定数アクセスのキャッシュとか)
    
  • パターンの収集とライブラリ化(OTP, TBB的な)
  • デバッグ

おわりに

本稿では Ractor についてご紹介しました。

自慢したいことは、まだ仕様・実装ともに不十分ではありますが、Ractor を導入までもっていったこと、それから娘が自転車に乗れることです。

新しい Ruby の一つの形ということで、楽しんでいただければ幸いです。

では、よいお年をお迎えください。