Ruby 3.3リリース! 新機能解説

M:Nスレッドによる軽量な並行処理への挑戦

STORES株式会社でRubyインタプリタ開発をしている笹田です。お正月に新年早々おでんを腐らせてしまったので、今年は作ったらさっさと食べることを目標にしたいと思います。

この記事では、主に私が開発している、Ruby 3.3で導入されたM:Nスレッドについて紹介します。

M:Nスレッドはスレッドの性能向上のために導入されました。M(大きな数)のRubyスレッドをN(十分小さい数)のネイティブスレッドだけで実行するというモデルで、スレッド管理のオーバヘッドを抑えられる方法として知られており、ほかにもGo言語などで利用されています。今後、大量のネットワーク接続を処理するといったことをRubyで記述することを検討したい場面が出てくるしれません。そのようなときにRubyでスイスイとプログラムが書ければいいなと思っており、その一貫です。最終的には、Ractorを用いた軽量な並列・並行アプリケーションを可能にする計画のひとつになります。

ただ、互換性に問題があるかもしれず、また(性能向上のために導入されたにもかかわらず)性能チューニングがまだ十分ではないため、Ruby 3.3ではデフォルトではオフになっています。そのため実験的に導入された側面が強く、今すぐに手元のアプリケーションが速くなるというものではありません。

したがって今後に期待という機能なのですが、その設計や狙いなどを解説していきます。

M:Nスレッドをとりあえず試してみる

Ruby 3.3に導入されたM:Nスレッドを、手元の計算機(Let's note CF-FV4 Windows 11 WSL Ubuntu 22.04)で試してみます。試してみたRubyは現在の開発の最新版を使っているのでバージョン表記は3.4になっています。

スレッドの生成時間

性能向上したということで、とりあえずたくさん作ってみましょう。1万個のRubyスレッドを確実に生成し、それらを終了させる時間を計測してみます。

$ time ruby -e 'q = Queue.new; N=10_000; N.times{Thread.new{q<<1; sleep}}; N.times{q.pop}'

real    0m7.248s
user    0m5.289s
sys     0m2.144s

このプログラムでは、1万個のRubyスレッドが確実に生存している状態を作るためにQueueを利用しています。ここでは7秒程度かかっていることがわかります。

デフォルトではM:Nスレッド(正確にはメインRactorでのM:Nスケジューラ)は無効になっているので、環境変数RUBY_MN_THREADS=1を指定して実行します。

$ time RUBY_MN_THREADS=1 ruby -v -e 'q = Queue.new; N=10_000; N.times{Thread.new{q<<1; sleep}}; N.times{q.pop}'
ruby 3.4.0dev (2024-01-10T02:22:23Z master 597955aae8) +MN [x86_64-linux]

real    0m0.510s
user    0m0.186s
sys     0m0.171s

実行時間が0.5秒程度になりました。速いですね!(14倍速い⁠⁠ ちなみに、バージョン表記(プログラム中ではRUBY_DESCRIPTIONで取得可能)+MNが入っていると、M:Nスレッドが有効になっています。

ただ、あまりにM:N無効時が遅いので調べてみました。その結果、VMのスタックを割り当てるとき、M:Nモデルの時はGC関係ないメモリなのですが、M:Nモデルをオフ(デフォルト)にするとGC関係あるメモリとなって、スレッド生成のためにGCがたくさん発生して遅いようだということがわかりました。

M:Nモデル関係ない性能差に起因していたため、GCをオフGC.disableにして計測しなおしました。結果は以下のようになりました。

$ time ruby -v -e 'GC.disable; q = Queue.new; N=10_000; N.times{Thread.new{q<<1; sleep}}; N.times{q.pop}'
ruby 3.4.0dev (2024-01-10T02:22:23Z master 597955aae8) [x86_64-linux]

real    0m1.346s
user    0m0.256s
sys     0m1.100s

$ time RUBY_MN_THREADS=1 ruby -v -e 'GC.disable; q = Queue.new; N=10_000; N.times{Thread.new{q<<1; sleep}}; N.times{q.pop}'
ruby 3.4.0dev (2024-01-10T02:22:23Z master 597955aae8) +MN [x86_64-linux]

real    0m0.385s
user    0m0.143s
sys     0m0.122s

M:Nスレッドの有無で、差が減りました(3.5倍速い⁠⁠。

途中すこし話がそれましたが、とりあえずたくさんのスレッドを生成すると、GCをオフにした状態で3.5倍くらいスレッド生成は速くなることが確認できました。多くのスレッドがあるときのGCの速度は、今後改善していきたいことのひとつです。

生成するネイティブスレッドの数

スレッドを生成するのに必要になるネイティブスレッドの数の数を数えてみましょう。M:Nモデルの宣伝文句を調べるならN(十分小さい数)以下になるはずです。

まずは、M:Nスレッドを使わない例です。

$ ruby -v -e '10_000.times{Thread.new{sleep}}; puts "#{Thread.list.size} threads are ready"; sleep'
ruby 3.4.0dev (2024-01-10T02:22:23Z master 597955aae8) [x86_64-linux]
10001 threads are ready

このプログラムは、1万個のRubyスレッドを生成後、10001 is readyと表示してからプログラムを停止します。別の端末でスレッドの数を数えてみましょう。

$ ps -eLf| grep ruby | grep -v grep | wc -l
10002

Rubyスレッドに割り当てられた1万スレッドがネイティブスレッドとして生成されているのがわかります。ちなみに残りの2つは、1つはメインスレッド用、1つはタイマスレッド用です。

では、M:Nスレッドを有効にして数えてみましょう。

$ $ RUBY_MN_THREADS=1 ruby -v -e '10_000.times{Thread.new{sleep}}; puts "#{Thread.list.size} threads are ready"; sleep'
ruby 3.4.0dev (2024-01-10T02:22:23Z master 597955aae8) +MN [x86_64-linux]
10001 threads are ready

これを実行中に別端末で数えてみます。

$ ps -eLf| grep ruby | grep -v grep | wc -l
3

Rubyプロセスが3ネイティブスレッドで構成されていることがわかります。内訳は、次のようになります。

  1. (1) メインスレッド用(起動時に作られるRubyスレッドはネイティブスレッドを占有する)
  2. (2) Rubyのスレッド用ネイティブスレッド(1個だけ)
  3. (3) タイマスレッド

(2)のネイティブスレッドで、Thread.new{}で作った1万個のスレッドを実行していきます。2万個、3万個になっても同じです。スレッドを作らないRubyプロセスは(1), (3)の2つのネイティブスレッド(メインスレッド用、タイマスレッド用)が常に作られます。

つまり、スレッドを増やしても(2)は1つだけで、Nに比例した数になりません。実は、RubyのスレッドはRactorごとに、たかだか1つだけしか同時に実行されません。Ractorの数が1だと、N =1になるわけです。

そこでRactorを増やしてみましょう。Ractorを使うとM:Nスレッドを問答無用で使ってしまうため、実験のために、M:Nスレッドを実装していないRuby 3.2でまずは実行してみます。

$ ruby -v -e 'GC.disable; 10000.times{ Ractor.new{ Ractor.receive }}; puts "#{Ractor.count} ractors are ready"; sleep'
ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-linux]
-e:1: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.
10001 ractors are ready

別端末でスレッド数を数えます。

$ ps -eLf | grep ruby | grep -v grep | wc -l
10001

ネイティブスレッドの数が10,001個あることがわかりました。

では、新しいRubyでやってみます。

$ ruby -v -e 'GC.disable; 10000.times{ Ractor.new{ Ractor.receive }}; puts "#{Ractor.count} ractors are ready"; sleep'
ruby 3.4.0dev (2024-01-10T02:22:23Z master 597955aae8) [x86_64-linux]
-e:1: warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.
10001 ractors are ready

別端末でスレッド数を確認してみると、

$ ps -eLf | grep ruby | grep -v grep | wc -l
9

となっていて、無事、ネイティブスレッドの数が9個と十分小さい数で抑えられていることがわかりました。

Rubyのメインスレッドおよびタイマスレッド用に2個使うので、残りは7個です。1万個作ったRactor(上で動くスレッド)が、7個でスケジューリングされるわけです。まさに、十分小さい数Nでネイティブスレッド数が抑えられていることがわかります。

ちなみに、この7という数字はRUBY_MAX_CPUという環境変数で指定できます。無指定だと8になります。本当は搭載しているCPUの数Etc.nprocessorsで知ることができます)で抑えるべきなのですが、現在は8固定でリリースしています(今後変更予定です⁠⁠。これがNになります。

ここで、N8なのに7ネイティブスレッドしかないのが不思議に思うかもしれません。実は、メインRactorがM:Nスレッド有効になっていないと、この(2)の数を減らすことにしているためです。というのも、M:Nスレッドが有効になっていないとネイティブスレッドをNの指定とは関係なく1つ多く使ってしまうからです。

生成するネイティブスレッドの数⁠advanced

無事、N(小さい数)でネイティブスレッド数を抑えられることを確認しましたが、実は次のようなコードではネイティブスレッドがたくさん作られます。

$ RUBY_MN_THREADS=1 ruby -ve 'GC.disable; pid = fork{sleep}; (1..10_000).each{Thread.new{Process.waitpid(pid)}}; sleep'
ruby 3.4.0dev (2024-01-10T02:22:23Z master 597955aae8) +MN [x86_64-linux]

このプログラムは、forkで作ったsleepで終了しない)子プロセスを、1万個のスレッドがwaitpidで待ち受けるというものです。

この状態で、1分程度待ってから別の端末でネイティブスレッド数を数えてみます。

[master]$ ps -eLf| grep ruby | grep -v grep | wc -l
10005

1万個のRubyスレッドのために1万個のネイティブスレッド(と、親プロセスのための2個のネイティブスレッド+1、子プロセスの2ネイティブスレッド)ができていることがわかります。つまり、M:Nスレッドを有効にしても、ネイティブスレッドの数は多くなってしまうケースがあるということです。

この理由はProcess.waitpidが管理外ブロッキング処理を行うから」というものですが、詳しくは実装のところで取り上げます。

ここまでのまとめ

Ruby 3.3で導入されたM:Nスレッドをちょっと試すことで、次のようなことがわかりました。

  • デフォルトではmain RactorにおいてM:Nスレッドは無効にされている
    • RUBY_MN_THREADS=1と環境変数を指定することで、有効化できる
    • main Ractor以外ではデフォルトで有効(無効にする方法がない)
  • M:Nスレッドを有効にすると、スレッド生成などが速い
  • M:Nスレッドを有効にすると、必要なネイティブスレッド数はRubyのスレッド数によらず一定である
    • 1 Ractorの場合は1(+ α)
    • n Ractorの場合はN(+ α)で、NRUBY_MAX_CPUで指定できる(デフォルト値は8
  • M:Nスレッドを有効にしても、Rubyスレッド数に比例したネイティブスレッド数が必要になることもある

Ruby 3.3に導入されたM:Nスレッドがどういうものであるか、という解説はこれくらいにして、以降はそのバックグラウンドとなる設計と実装について紹介していきます。

一般的な言語のスレッド実装

プログラミン言語にスレッド(っぽいもの)を実装するには、OSなどが提供するネイティブスレッド(Pthreadとか)にどう乗っかるかが実装方法の大きな選択になります。大きく次の3つの方法が知られています。

  • M:1モデルM個の言語スレッドを1個のネイティブスレッドで管理
    • Ruby1.8までで利用
    • ユーザーレベルスレッド、ユーザスレッド、グリーンスレッドなどとも呼ばれる
    • 世間一般では1:Nモデルと呼ぶ(M:Nにあわせるために換えています)
  • 1:1モデル:1個の言語スレッドごとに1個の言語スレッドを用意して管理
    • Ruby 3.2までで利用
  • M:NモデルM個の言語スレッドをN個のネイティブスレッドM<=Nで管理
    • Ruby 3.3で導入してみたもの
    • 世間一般ではN:Mモデルということが多いようです

1:1モデルが素直で作りやすいのですが、ネイティブスレッドの生成と制御はソコソコ重い処理になります。たとえば、1万個とか10万個の言語スレッドを作るとつらくなります。そこで、利用するネイティブスレッドをN個に制限するのがM:Nモデルです。

ネイティブスレッドを利用する理由の一つが並列度の向上です。ネイティブスレッドはある程度並列コンピューター上でそれぞれ同時並列に実行されることが期待されますが、その並列度は一般的にCPUのコア数など、ハードウェアによって規定されます。そこで、M:NモデルではNをCPUのコアの数だけ、たとえば8とか16とかに設定しておきます。ネイティブスレッドをそれ以上、たとえば1万個作っても、同時に動くのはたかだかCPUのコア数になるためです。よって、N(小さい数)だけ作っておいて、M(大きい数)個の言語スレッドを管理すると良さそうなことがわかります。

ただし、M:Nスレッドの欠点として、作るのが大変(そう)です。1:1モデルだと、単に言語のスレッド(言語で記述された処理)をネイティブスレッド上で走らせるだけ(だけ、といっても言語特有の割り込み処理などを実装するのはソコソコ大変)ですが、M:Nスレッドでは言語スレッドのコンテキストを適切に切り替える必要があるためです。この「適切な切り替え」をどうするかが腕の見せ所になります。

RubyのM:Nスレッドの設計と実装

ここでRubyにM:Nスレッドをどのように実装したか、その設計意図などをすこし紹介します。

2レベルスケジューリング

N個のネイティブスレッドがあるとき、RubyのスレッドはRactorごとにたかだか1つしか動かないようになっているため、まずは動かすRactorを決めます(M個のRactorのうち、N個を決める⁠⁠。そして、そのRactorで動くスレッドを1つ選び、実行していきます。このようなかたちで、2つのスケジューリングが行われます。性能のためにさらに複雑なことをしていますが、基本的にはこのように考えてもらって構いません。

このスケジューリングをさらに高性能化する方法はいろいろ知られていますが、現状では本当に単純なラウンドロビンスケジューリングしかしていません。そこで今後このあたりを検討していこうと思っています(しかし、単純なモデルになかなか勝てない、という話もあります⁠⁠。

ブロッキング処理のハンドリング

さきほど「適切な切り替え」が技術的ポイントだと言及しましたが、Rubyでは次のような場合にスレッドを切り替えます。

  1. (1) 一定時間がたったら(時分割スケジューリング)
  2. (2) sleepやI/O待ちなどで待ちになったら(ブロッキング処理のハンドリング)

(1)はタイマスレッドによって実現します(これまでも大体そんな感じでした)が、(2)が難しいところです。たとえばIO#readで待っていたら、別のスレッドに切り替わってほしいわけです。この手の「待ってしまうので別のRubyスレッドにバトンタッチしてほしい」処理をここではブロッキング処理といいます。

いくつか例を考えてみます。sleep(n)は、タイマーがあれば、

  1. (a) 別のスレッドに処理を移す
  2. (b) n秒後、sleepしていたスレッドを実行可能Rubyスレッドとして登録し、再度スケジューリングされるのを待つ

という手順でできそうです。readの場合も、

  1. (a) 別のスレッドに処理を移す
  2. (b) readしてもブロックしないことを確認したら、実行可能Rubyスレッドとして登録し、再度スケジューリングされるのを待つ

という手順になります。

これを実現するのがタイマースレッド(特別なネイティブスレッド)で、時間やI/Oの処理の完了を待ったりします。I/Oの処理をまとめて待つ機能として、Linuxのepoll、BSD系のkqueue、Windows系のIOCPが知られており、現在はepollkqueueにのみ対応しています(それらがない環境ではM:Nスレッドは有効になりません⁠⁠。

ここで問題となるのは、ブロッキング処理はsleepやread/writeといった、わかりやすい処理だけではないということです。極端な話をすると、特定の環境でclose()を呼び出すとブロックしてしまったりしますが、epollなどでそれを適切にハンドリングする方法はありません(聞いてみると、⁠read/writeできるよ! ブロックしないよ!」みたいな結果が返ってきます⁠⁠。……これは滅多にない例ですが、この手の「なんかブロッキングしてしまうし、それをタイマスレッドで待つ方法はない」という処理は世の中にあふれています(言いすぎかもしれませんが⁠⁠。

そこで、それらを管理外ブロッキング処理と呼ぶことにし、管理外ブロッキング処理を呼び出すときにはネイティブスレッドを専用に割り当ててしまうことにしました。ネイティブスレッドを占有させるのはコスト増になる可能性がありますが、動かないより全然マシという判断です。

さきほどProcess.waitpid()で待ってしまうとネイティブスレッドがたくさん増えてしまう例を示しましたが、それはProcess.waitpid()が管理外ブロッキング処理であるためです。厳密にいえば、Process.waitpid()を管理ブロッキング処理とすることは可能です(Linuxでのpidfdの利用など⁠⁠。しかし、とりあえず今動いているものは何もしなくてもきちんと動くことを、きちんと実装するために、このようにしています。

M:Nスレッドの無効化

細かい話になりますが、ネイティブスレッドのThread Local Storage(TLS)に強く依存したコードがあるとM:Nスレッドが破綻することが知られています。主にC拡張ライブラリが気にする(C拡張ライブラリが依存する外部ライブラリが使っているかもしれない)ことなので、特定のRubyスレッドについてM:Nスレッドを無効化するC APIrb_thread_lock_native_thread()を追加する予定です(Ruby 3.3.0で入れることをすっかり忘れてました⁠⁠。

ちなみに、RUBY_MN_THREADS=1を指定していないとmain RactorではM:Nスレッドは有効でない、という機能はまさにこの仕組みを使って実現しています。

M:Nスレッドのねらい

最後に、M:Nスレッドを導入した狙いについて紹介します。

たとえば数万、数十万のコネクションを処理するネットワークアプリケーションを作りたい場合に、現状ではGo言語やElixir(Erlang)など、並行並列処理が得意と定評があるプログラミング言語が選ばれることが多いと思います。

Rubyで並行処理を行うには、まずはスレッドが出てくると思いますが、残念ながらRubyの現在のスレッドは数万個作って処理できるようなものではありません。また、並行に処理しますが、並列計算機(マルチコアCPUなど)上で同時並列に実行することもできません[1]。そのために導入されたのがRactorですが、Ractorも少なくとも1つずつスレッドを作るため、スレッドに関する制約が出てきます。

そこで、スレッド自体をM:Nスレッドに変更することで軽量に扱えるようにすることがこの導入の経緯です。最終的にはRactorを軽量にポンポン作ったり、数万Ractorがグリグリ動くような世界を目指しています。

Rubyで軽量な並行処理を目指す別の仕組みとして、Ruby 3.0で導入されたFiber schedulerがあります。これは、並行処理をFiberをベースに行い、I/Oなどのブロッキング処理をRubyプログラムによってスケジューリング可能にするという仕組みです。この仕組みの上で、async gemなどを用いてRubyで並行処理を実現しようというもので、詳しくないのですが実際に性能を得ているとのことです。

実はM:Nスケジューラは、Fiber schedulerと技術的に似たようなことをしているのですが、M:NスケジューラはFiberを意識したり拡張する必要がないこと、Rubyインタプリタ内で完結すること、Ractorによる並列処理にも寄与すること、といった点が異なります。

M:Nスケジューラの利点はスレッドの生成と管理が軽量になることです。具体的には生成や実行の停止・再開がQueueやMutexなどで頻繁に起こるようなケースでとくに高速に実行できます。ただし、これまでのRubyの使い方である、限られた個数のスレッドプールを用いてリクエストを処理する、基本的にはI/Oでスレッドを切り替える、といった使い方ではあまり性能上の違いは出てこないでしょう。そのため、今までのRubyのスレッドアプリケーションでは、大きな性能向上を得ることは難しいはずです。Rubyの応用範囲を広げるための改善ととらえていただけると良いかと思います。

おわりに

この記事ではRuby 3.3.0で導入されたM:Nスレッドがどういうものかを紹介し、その設計や狙いを説明しました。記事でも述べたとおり、まだGCで遅いとかいろいろ問題があるので道半ばではあるのですが、並列並行プログラムをかるく書くにはRubyだね、と言えるような世界をめざして開発を続けようと思います。

RubyのM:Nスレッド化について、より詳細については次をご参照ください。

連載のおわりに

本連載では、Ruby 3.3の新機能をご紹介しました。ほかにも紹介しきれなかったいろいろな変更があります。それらについては次の記事をご参考にしてください。

毎年のことですが、Rubyはどんどん良くなっていっています。ぜひ、お手元にセットアップして新しいRubyを楽しんでください。

Enjoy Ruby programming!

おすすめ記事

記事・ニュース一覧