技術部でRubyインタプリタの開発をしている笹田です。コロナの影響で、リモート勤務などに移行し、新しい生活スタイルを満喫されている方々がたくさんいらっしゃるんじゃないかと思います。ただ、私は以前から自主的に自宅勤務することが多かったので、正直生活がぜんぜん変わっていません。
さて、家で私が何をしているかというと、Ruby 3の準備です。その中でも、数年取り組んできた Ruby で並列処理をするための仕組みである Ractor の開発をしています(以前は Guild というコードネームで呼んでいました)。Ractor という名前は、Ruby の Actor みたいな意味で付けました。Erlang とか Elixir で有名な Actor model というアレです。厳密には、Actor model でよく言われる特性をすべて備えているわけではないのですが、並列で動く Ractor を複数作ることで並列計算機上で気楽に並列処理を行うことができます(少なくとも、それができることを目標にしています)。
Ractor は、意図的に Ractor 間でメモリの共有を排除するように設計されています。しかし、どうしても共有したいなぁ、というときのために、Software Transactional Memory (STM) という仕組みを入れようと思いました。STM を使うと、DB のトランザクションのように、何か競合したらなかったことにしてやりなおすメモリを作ることができます。
本稿では、その背景と、実際にどう作ったか、そしてどう試すのか、などについてご紹介します。
Ractor のちょっとしたご紹介
本題に入る前に、本稿を読むために必要になる、Ractor についての基礎知識を少しご紹介します。 しっかりしたリファレンスは ruby/ractor.md at master · ruby/rubyにありますので、よかったら参照してみてください。
Ractor を作って並列計算する
Ractor は、複数作ってそれらが並列に動く、ということで、並列計算機上で並列に動かすことができます。
# Ractor を生成する r = Ractor.new do expr # expr は、他の Ractor とは並列に動くend r.take #=> expr の実行、つまり Ractor 自体の処理が終了を待ち、# expr の結果を得る
この例では、Ractor.new
で新しい Ractor を作り、そこで expr
を実行し、その結果を Ractor#take
で受け取る(r.take
)、という意味になります。ここでは1つしか作っていませんが、n 個作れば、n 個の Ractor が並行に処理され(Thread と一緒)、それらがシステムで許された並列度で並列に実行されます(Thread と異なる)。
ちなみに生成時に引数を渡すと、それをブロック引数で受け取ることができます。
r = Ractor.new 10do |n| p n #=> 10end r.take
Ractor 間ではオブジェクトは(あんまり)共有されない
Ractor 上ではたいていの Ruby のプログラムを動かすことができます。つまり、上記 expr
に、いろんな Ruby の式が書けます。が、Ractor 間でオブジェクトを共有することは、基本的にはできません。
# s から参照される文字列を、新しく作った Ractor と main Ractor で共有する例# エラーになります s = "hello" r = Ractor.new do s << "world"end s << "ko1" p r.take
この例では、s
で参照される "Hello"
という文字列を、2つのRactor(起動時からあるmain Ractorと、Ractor.new
で作る子Ractorの2つ)で共有してしまう例です。それぞれの Ractor で、String#<<
で文字を結合、つまり破壊的操作をしようとしています。一般的には、並列処理において、ロックなどで排他制御をしなければならない場面です。
例えば、スレッドが並列に実行されるようなJRubyなどでは、RactorではなくてThreadでこのようなコードを動かすと、Javaレベルのエラーが起きることがあります(手元で連結を何度も繰り返すようにして試してみたら、java.lang.ArrayIndexOutOfBoundsException
が出ました)。余談ですが、MRIは、GIL/GVLによって並列に動くことはなく、String#<<
の処理中にスレッドの切り替えが起こらないことを保証しているため、問題なく動かすことができます。が、Ruby レベルの処理ではどこで切り替えがおこるかわからないため、やっぱり排他制御ちゃんと考えないと、となります。
というわけで、もしこのようなコードによって、どんなオブジェクトも複数の Ractor から同時にアクセスできるようになってしまうと、Ractor 間での同期が必須になってしまいます。
Ractorでは、Ractor間で文字列などの共有による、排他制御が必要な状況になるのを防ぐために、いろいろな工夫をしてあります。例えば、ブロックの外側のローカル変数を参照するようなブロックを Ractor.new
に渡そうとすると、Ractor.new
のタイミングでエラーになります。
in `new': can not isolate a Proc because it accesses outer variables (s). (ArgumentError)
こんな感じでオブジェクトを共有できないので、「ロックをちゃんとしなきゃ」といった、難しいスレッドプログラミングに関する問題から解放されます。やったね。
Ractor 間のコミュニケーション
そうは言っても、何か状態を共有したいことはあります。また、複数の Ractor が協調して動くように作る必要もあるでしょう(何かイベントをまったり、イベントが起こるまで別の Ractor を待たせたり)。そこで、Ractor では、メモリを共有するのではなく、オブジェクトをメッセージとしてコピーして送ったり受け取ったりすることで、データを共有します。
Go で言われているらしい "Do not communicate by sharing memory; instead, share memory by communicating."ということですね。Go と異なるのは、Go はいうてもメモリをいじってコミュニケーションできてしまう(メモリを共有しているので)のですが、Ractor ではコピーしちゃうので、そもそも共有ができません。Go は「気をつけようね」というニュアンスですが、Ractor では「絶対にさせないでござる」という感じです。
Ractor 間のコミュニケーションは Ractor#send
、Ractor.receive
および、Ractor.yield
、Ractor#take
のペアで行います。
r1 = Ractor.new dowhile msg = Ractor.receive Ractor.yield msg end:finend r1.send 1 r1.send 2 r1.send 3 r1.send nil p r.take #=> 1 p r.take #=> 2 p r.take #=> 3 p r.take #=> :fin
この例では、main Ractor が、作成したRactor r1に対して、1, 2, 3, nil という値を Ractor#send
でメッセージとして送っています。
r1 では、Ractor.receive
で send されたメッセージを受け取って(送られるまで待つ)、それをそのまま Ractor.yield
に渡しています。Ractor.yield
は、他の Ractor がそのオブジェクトを Ractor#take
で持っていくまで待ちます。つまり、1, 2, 3 について、Ractor.yield しているわけです。最後、Ractor.yield
は nilを返すので、while 文が止まり、ブロックは :fin
を返して Ractor は終了します。
main Ractor では、Ractor#take
によって、Ractor.yield
された 1, 2, 3 を受け取り、表示します。
また、4 回目の Ractor#take
によって、ブロックの返値 :fin
を取ります。
というのが、コミュニケーションの方法になります。
さて、メッセージとして渡すオブジェクトは毎回コピーするとご紹介しましたが、いくつかの場合、コピーなしで受け渡されます。コピー無しで受け渡されるオブジェクトのことを「共有可能オブジェクト」と呼びます。
共有可能オブジェクトの定義はこちら:
- 不変オブジェクトは共有される
- 不変オブジェクトとは、そのオブジェクトが
freeze
されており、参照するオブジェクトがすべて共有可能であること - 例えば、整数オブジェクトや nil とかは、frozen で参照するオブジェクトは無いので共有可能です
- 不変オブジェクトとは、そのオブジェクトが
- クラス・モジュールは共有される
- その他、特別に共有可能に作られたオブジェクト
- たとえば、Ractor オブジェクトは共有可能
- 今回ご紹介する
Ractor::TVar
も共有可能オブジェクト
共有可能オブジェクトを他の Ractor に送るときは、コピーせずにリファレンスだけ送ります(共有されても、おかしなことは起こらないだろうから。もしくは、共有されても、おかしなことが起こらないように特別に設計されているから)。
それから、渡すときにコピー以外にも move が選べますが、ちょっと長くなってきたのでこの辺で。Ractor に関しては、いろんな話があります。再掲になりますが、詳細は ruby/ractor.md at master · ruby/rubyをご参照ください。
Software Transactional Memory (STM)
Ractor ではメッセージのやりとりで共有できるんですが、やっぱり一部はメモリを直接共有したいこともあるかもしれません(ないかもしれません、ちょっとわからない)。そこで、Software Transactional Memory (STM) という仕組みを入れるのはどうかと考え、実装してみました。最新の Ruby で gem でインストールすれば使えるようになっているので、よかったら試してください。
以降は、その STM の話をご紹介します。
STM が必要な背景
ちょっとしたデータを Ractor 間で共有したい例として、例えば、何かプログラム全体で数を数えたい、ってのがあります。さばいたリクエスト数かもしれません。処理したデータの総サイズを数えたいかもしれません。こういう、Ractor 間でちょっとしたデータを共有する方法が、今はありません。強いて言えば、そのデータを管理する専用のRactor
を作ることで行うことができます(専用じゃなくてもいいけど、何か管理するやつ)。
counter = Ractor.new do cnt = 0while msg = Ractor.receive case msg in [:increment, n] cnt += n in [:decrement, n] cnt -= n in [:value, receiver] receiver.send cnt endendend counter << [:increment, 1] # Ractor#send は Ractor#<< という alias を持っています counter << [:increment, 2] counter << [:increment, 3] counter << [:value, Ractor.current] p Ractor.receive #=> 6
この例では、カウンターを管理するためだけの Ractor を用意してみました。実際、Actor モデルの言語では、こんな感じで作ることが多いんじゃないかと思います。そして、こういうのを簡単につくるためのライブラリが用意されています。例えば Elixir なんかだと Agent(Agent — Elixir v1.11.2。日本語での詳細は、12月に出版される プログラミングElixir(第2版) | Ohmshaとかがお勧めですよ!)とかですかね。複数の Ractor 間で安全に共有できる、変更可能な状態を作るときは、こんな感じにします。
が、もうちょっと楽に書きたいなぁ、という気分があります。「カウンターごとに Ractor 作るんですか?」って感じです(まだ、Ractor の生成は遅いのです。Thread と同程度に)(べつに、カウンターごとに作らないで、すべてのカウンターを管理する Ractor を作る、みたいな方法でできんこともないです。単純なカウンターの集合だけなら)。
そこで、メモリを共有する仕組みを用意するのはどうでしょうか。cnt = Counter.new(0)
としたら、cnt
は複数の Ractor で共有できる、みたいな感じです。ただ、値の increment でも、ロックが必要です(Thread-safe の説明の例でよくあるアレです)。
じゃあ、ロックしないとアクセスできないようなインターフェースにすると、どうでしょうか。ロックを持たないでアクセスするのを禁止すれば、うっかりロックを忘れてしまうこともなさそうです(エラーになって気づく)。ちゃんとロックをするようにすれば、Ractor 間で排他制御されるので、まずい問題が起こらない気がします。
やってみましょう。
cnt = Counter.new(0) r = Ractor.new cnt do cnt.lock{ cnt.value += 1 } end cnt.lock{ cnt.value += 2 } r.take p cnt.lock{ cnt.value } #=> 3
良さそうです!
さて、ここでカウンタを 2 個にしてみましょう。そして、2つのカウンタは同時に動く必要があるとしましょう。そうですね、c2 = c1 * 2 となるような関係になるという特殊なカウンタです。ロックをうまく使えば大丈夫ですかね?
c1 = Counter.new(0) c2 = Counter.new(0) r1 = Ractor.new do c2.lock do c1.lock do c1.value += 2 c2.value = c1.value * 2endendend c1.lock do c2.lock do c1.value += 1 c2.value = c1.value * 2endend#...?
こんな感じでしょうか。
実は、このプログラムはデッドロックしてしまいます。というのも、main Ractor は c1 -> c2 の順でロックをしていきます。r1 は、c2 -> c1 の順です。このとき、運悪く次のような順にロックしていくと、デッドロックしてしまいます。
- main: c1.lock
- r2: c2.lock
- main: c2.lock ->できないので待つ
- r2: c1.lock ->できないので待つ
こうならないためには、ロックの順番を、複数の Ractor でそろえる(c1->c2とか)必要があります。
とか考えていくと、ロックのアプローチはいまいちです。うっかり順番間違えるとか、普通にありそうじゃないですか。
STM のよさ
そこで使えそうなのが STM です。DB なんかで Transaction の話はよくご存じの方は多いと思いますが、これをメモリに適用したのが STM で、2010 年くらいに言語処理系界隈で研究が盛んでした。でも、今ではあんまり聞かないですねえ。言語についている STM としては、Clojure とか Haskell (Concurrent Haskell) が有名だと思います。Erlang/Elixir における mnesia も STM と Wikipedia には書いてありました、があれは DB だよなぁ。
STM は、DB のトランザクション(楽観的ロック)と同じように、とりあえずなんか読み書きして、あとで、「あ、別の Ractor とアクセスが被った!」となったらロールバックしてしまいます。簡単ですね。
ロック(悲観的ロック)と何が違うかというと、さっきの順序の問題が現れないんですよね。そもそも「ここからトランザクションです」のように指定するので、ロックの順序がない。この性質を、composable であるといいます。複数の排他制御が必要とする操作を、まとめても問題ないという良い性質です。
STM のデメリットは、操作が衝突してロールバックが多発するとむっちゃ遅くなっちゃうんですよね。この辺はフロー制御をなんとかする、みたいな研究がいろいろあります。たとえば、衝突しまくってそうなら、実行可能なスレッド(今回は Ractor)を絞っちゃうとか。
まぁあと、楽観ロックなので、みんなが read しかしないような場合は、どの処理も並列に実行可能なので速そうです。それから、進行性保証的な話もあったりして、いろいろメリットがあります。
どんな STM を作るのか
STM にもいろいろな流派があります。
- そもそも、Software じゃない Hardware でやる HTM って分野があります。CPU がサポートしたりしています。が、あんまり最近聞かないですねえ。
- メモリ操作を全部 transaction の対象にしてしまうという STM があります。C++ とかで多いですね。X10 という昔ちょっとかかわってた言語では、言語組み込みにこういう STM がありました。
- 特定のメモリを transaction 対象にするという STM があります。特定のメモリしか扱わないので、それ以外のメモリはロールバックしてももとに戻りません。
- 操作の衝突の定義もいろいろあります。
Ruby の場合は、全部ロールバックできないので(作るのスゴイ大変)、一部のメモリだけを対象にする、というようにします。具体的には、Ractor::TVar.new
(TVar
は Transactional Variable の略)が保持する値のみ、transaction で何か問題があったらロールバックします。そして、Transaction の範囲は Ractor.atomically
に渡すブロック中ということにします。
というインターフェースが、実は Class: Concurrent::TVar — Concurrent Rubyにあったんですよね。Concurrent Ruby は、Thread を対象にしています。このインターフェース踏襲し、Ractor でも使えるようにしたのが Ractor::TVar
です。
先ほどのカウンターの例だと、こんな感じで書けるようにするといいでしょう。
c1 = Ractor::TVar.new(0) c2 = Ractor::TVar.new(0) r1 = Ractor.new c1, c2 do |c1, c2| # 外側のローカル変数は見えないから引数で渡すRactor.atomically do c1.value += 2 c2.value = c1.value * 2endendRactor.atomically do c1.value += 1 c2.value = c1.value * 2end
main Ractor と子 Ractor で、変更が競合してしまった場合は、どちらかのブロックが再実行されます。先に紹介した通り、ロールバックされるのは Ractor::TVar#value
の値だけなので、例えばインスタンス変数への代入などは残ってしまいます。IO 処理なんかも取り返しがつきません。そのため、Ractor.atomically
に渡すブロックは、できるだけシンプルにする必要があります。
Ractor.atomically
は自由にネストすることができます。この性質が、composable である、という話です(ロックですと、ロックの順番に気を付けないといけませんでした)。
TVar は共有可能オブジェクトなので、他の Ractor に渡すことができます。TVar に設定できる値は、他の Ractor から見えることになるので、共有可能オブジェクトに制限されます。たとえば、mutable な文字列などは渡せません。
トランザクションは、次のようなプロセスで管理されます。
- (1) トランザクションの生成・開始
- (2) TVar の読み書き
- (3) トランザクションのコミット
このとき、(2) および (3) のタイミングで競合を検知し、必要ならロールバックを行って (1) に戻ります。
- (a) (2) において、read した値がすでに他の Ractor に書き換えられていた→ロールバック
- (b) (3) において、read した値が、すでに他の Ractor で書き換えられていた
- (c) (3) において、write しようと思ったら、すでに他の Ractor に書き換えられていた
(c) は直観的だと思いますが(git で push しようとしたら、先に他の人が変更していて書き換えられなかった、みたいな話です)、(a), (b) はちょっと意外ではないでしょうか。つまり、書き換えの行わないトランザクションでも、ロールバックは発生し得る、という話です。
この読み込みだけでロールバックしてしまう、という挙動は、2つ以上の値を読み込むときに重要になります。tv1.value
, tv2.value
2値を取り出すとき、tv1
を読み込んだ後で、他の Ractor が tv2
を書き込み、それを main Ractor で読み込んだ時、tv1
と tv2
が一貫性を持たない状態である可能性が出てきます。そのため、(b), (c) のタイミングで、適切な tv1
, tv2
を読み込めているかチェックする、という話になります。まだちょっとわかりづらいですね。
例えば tv1
に配列のインデックス、tv2
に配列が格納されているとき、tv1
のインデックスを読み込んだ後、なんやかんやがあって他の Ractor で tv2
の配列が切り詰められたとします。このとき、すでに読み込んだインデックスは tv2
の配列の長さを超えているかもしれません。問題です。
これはつまり、tv1
と tv2
の一貫性が取れていない、という状況です。TVar では、このようなことが起こらないように、上記 (a)~(c) をトランザクションのロールバックタイミングとしています。
さて、1つの値だけを読みだすとき、Ractor.atomically
が必要かどうかは議論が必要なところです(例えば、p Ractor.atomically{ c1.value }
と書かなければならないのか、p c1.value
と書くだけでよいのか)。というのも、この処理は複数読み込みもせず、write もないので、一貫性制御が要らないような気がするからです。実際、Clojure の STM や、Concurrent-ruby の TVar は、トランザクション内でなくても値を読みだすことだけはできるようになっています。
我々は、このときも Ractor.atomically
を必須としました。というのも、c1.value + c2.value
のように、2つ以上の値を読み込むために、うっかり Ractor.atomically
を書き忘れそうな気がしたからです。
あと、カウンタとして使おうとすると、increment 処理をよくやると思うので、Ractor.atomically{ tv.value += 1 }
のショートカットである Ractor::TVar#increment(n=1)
を用意しています。
STM の限界
composable に記述できる STM ですが、たとえば同一トランザクション内で処理しなければならないのに、複数のトランザクションに分けてしまう、という問題はいかんともしがたいです(意図的かもしれないのでエラーにできません)。
c1 = Ractor::TVar.new(0) c2 = Ractor::TVar.new(0) r1 = Ractor.new c1, c2 do |c1, c2| Ractor.atomically do c1.value += 2end# 本当はここで transaction を切ってはいけない!!Ractor.atomically do c2.value = c1.value * 2endend
c1 を変更後、c2 を変更する前に他の Ractor が c1, c2 を観測すると、c2 = c1 * 2 という関係が崩れている(一貫性がない)瞬間を目撃できるのです。
ちなみに、何かのカウンタなら多少の誤差は許されることもあるかもしれませんが、例えば STM でよく例に出てくる銀行口座の残高の移動というタスクにおいては大問題になってしまうかもしれません。例えば、A さんから B さんに n 円送金するとき、A さんから残高を減らして、B さんに残高を追加する、という処理になります。このとき、Aさんから残高を減らしたタイミングで他の Ractor から A, B 各氏の口座が観測され、世界から n 円消える、という瞬間を目撃していまいます。それはまずい、あってはならないことです。
(STM 自体はこのように、口座残高のような、同時に複数のデータをいっきに変える(一貫性のない状態を、ほかから見えないようにする)ときに使うことが多いと思います)
さて、この例では恣意的で、こんなミスは起こさないような気がするのですが、例えば、
defadd_2 tv Ractor.atomically{ tv.value += 2 } enddefset_twice tv1, tv2 Ractor.atomically{ tv2.value = tv1.value * 2} end
のように定義していれば、add_2(c1); set_twice(c1, c2)
のように記述してしまう可能性は十分あります。
どーにかなんないか考えてみたのですが、トランザクションでの read/write のログを取れるようにしておいて、問題が発覚したら、そのログを見つめるなり自動解析ツールなりを作って、トランザクションが分かれていないかチェックする、みたいなことくらいしかできないかなぁ、と思っています。良いアイディアをご存じでしたら教えてください。
そういえば、TVar
という名前は concurrent-ruby からとりましたが、T って色々ありますよね。なので、TxVar とかもう少し冗長でもいいかなぁ、などという気分があります。どうしよっかな。
STM の実装
あんまり中身の話をしてもしょうがないような気がしますが、こんなアルゴリズムで実現しています。
- (1) トランザクション開始時に、現在の時刻 T を取得する
- (2) TVar の読み込み時 / 書き込み
- 書き込み時には、TVar には書かず、Ractor local なトランザクションログに書き込む
- 読み込み時には、トランザクションログにその TVar の書き込み履歴があれば、Ractor local なその最新の値を返し、なければ読み込む。このとき、TVar に記録された最終書き込み時間と、開始時に記録した T を比較し、T が古ければればロールバック。新しければ読み込み完了だが、ついでにトランザクションログに載せておく(次の read 時は、TVar を読む必要がなくなる)
- (3) コミット
- コミット時、トランザクションログに記録された TVar たちについて、最終書き込み時間が T より新しくないことを確認
- 時刻を1進める。この時の時刻を T'とする。
- 書き込みが必要な TVar には、変更を反映。このとき、その TVar の最終書き込み時間が T'となる。
あんまり難しそうじゃないんですが、なんかこれで動くみたいです。論文的には、TL2 という方式(をちょっと弄っている)なんだそうです。
ちなみに STM を作った本当の経緯。以前から STM が欲しいと思っていました。そこで、9月にとった夏休みに STM の実現方法についてのアイディアが思いついたので、実装したら動いたヤッター、俺スゴイ、となったのです。で、調べてみたら、すでに誰かが提案していて、しかも自分が考慮していなかった箇所とかあったり、名前までついていたという。新しいことを考えるのは大変ですね(いや、別に今回は新しいことは目指してはいなかったんですが)。
ロールバックは、(2) の時は単純に例外を投げるようにしています。(3) のときは、コミットする関数で成功失敗を返し、失敗していたら最初からやりなおす、という実装にしています。
なお、Ractor と言ってますが、Thread 間でも同じように TVar が使えます。なので、Ractor ごとにトランザクションログをもつのではなく、Thread ごとに持つようにしています。この辺で Thread::TVar にするのか Ractor::TVar にするのか悩んだんですが、結局 Ractor::TVar がいいかなぁ、と思い至りました。
Ruby 3.0 における STM
この提案を、Ruby 3.0 の機能として提案してみたのですが、良さがわからん、ということで reject されました(Feature #17261: Software transactional memory (STM) for Threads and Ractors - Ruby master - Ruby Issue Tracking System)。残念。まぁ、確かに Actor っぽい仕組みでメモリを分けたのに、また別の仕組みを入れるのか、という気はしないでもないです。
ただ、実際これないとプログラム書きづらいと思うんだよなー。どうかなー。
ということで、gem を用意しました。
ractor-tvar に Ractor::TVar
だけ入っていて、ractor gem は、ractor-tvar への依存があるため、gem install ractor
すれば入ります。ractor gem のほうは、今は空ですが、Ractor に関するいろいろなユーティリティーを入れられるようにしようと思っています。
require 'ractor/tvar'
で使えるようになります。なお、当然ですが、開発中の Ruby 3.0 上でしか動かせません(そもそも拡張ライブラリがビルドできません)。もう入っていますか?
最初は本体組み込みを前提に STM を実装していたのですが、gem に切り出すために変更が入り、性能が若干落ちています。また、コンテンションマネージメントをまじめにやっていないため、ロールバックが多発するようなシチュエーション(つまり、ある TVar への書き込みが激しいとき)では性能が凄く下がります。逐次実行時より下がります。
性能評価はまじめにやる時間がないのでスキップしますが、いくらかの評価が先のチケット(Feature #17261: Software transactional memory (STM) for Threads and Ractors - Ruby master - Ruby Issue Tracking System)にありますのでご参照ください。
おわりに
本稿では、Ruby に STM を入れたいと思った話と、それからその仕様と実装を軽くご紹介しました。Ruby 3.0 には入らないのですが、gem で使えるので、お試しいただけると良いかもしれません。
STM については、いろいろ偉そうに書きましたが、だいたいこの書籍の受け売りです: Amazon | Transactional Memory, 2nd Edition (Synthesis Lectures on Computer Architecture) | Harris, Tim, Larus, James, Rajwar, Ravi, Hill, Mark | Network Administration
Ruby に STM 入ると、あまり注目されない STM もまた盛り上がる気がします。性能チューニングや記事中に書いたデバッグ支援など、いろいろやることがあるので、興味ある言語処理系の研究者の方とか、共同研究とかどうでしょうか。ちゃんとやれば、学生さんの卒論修論くらいにはなるんじゃないかと思います。
さて、Ruby 3.0 は、そんなわけで Ractor も入るし他にもいろいろ入るし、夢いっぱい楽しさイッパイのリリースです。多分。そんなすてきな Ruby 3.0 をいち早くご紹介するイベントを開催するので、年の瀬ですが、もしよかったらご参加ください。
Ruby 3.0 release event - connpass
では、12月の Ruby 3.0 リリースをお楽しみに!