STORES株式会社でRubyインタプリタ開発をしている笹田です。お正月に新年早々おでんを腐らせてしまったので、今年は作ったらさっさと食べることを目標にしたいと思います。
この記事では、主に私が開発している、Ruby 3.
M:Nスレッドはスレッドの性能向上のために導入されました。M個
ただ、互換性に問題があるかもしれず、また
したがって今後に期待という機能なのですが、その設計や狙いなどを解説していきます。
M:Nスレッドをとりあえず試してみる
Ruby 3.
スレッドの生成時間
性能向上したということで、とりあえずたくさん作ってみましょう。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スレッドRUBY_
を指定して実行します。
$ 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.RUBY_
で取得可能)+MN
が入っていると、M:Nスレッドが有効になっています。
ただ、あまりにM:N無効時が遅いので調べてみました。その結果、VMのスタックを割り当てるとき、M:Nモデルの時はGC関係ないメモリなのですが、M:Nモデルをオフ
M:Nモデル関係ない性能差に起因していたため、GCをオフGC.
)
$ 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スレッドの有無で、差が減りました
途中すこし話がそれましたが、とりあえずたくさんのスレッドを生成すると、GCをオフにした状態で3.
生成するネイティブスレッドの数
スレッドを生成するのに必要になるネイティブスレッドの数の数を数えてみましょう。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) メインスレッド用
(起動時に作られるRubyスレッドはネイティブスレッドを占有する) - (2) Rubyのスレッド用ネイティブスレッド
(1個だけ) - (3) タイマスレッド
(2)のネイティブスレッドで、Thread.
で作った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.
$ 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という数字はRUBY_
という環境変数で指定できます。無指定だと8になります。本当は搭載しているCPUの数Etc.
で知ることができます)
ここで、Nは8なのに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
で終了しない)waitpid
で待ち受けるというものです。
この状態で、1分程度待ってから別の端末でネイティブスレッド数を数えてみます。
[master]$ ps -eLf| grep ruby | grep -v grep | wc -l 10005
1万個のRubyスレッドのために1万個のネイティブスレッド
この理由はProcess.
が管理外ブロッキング処理を行うから」
ここまでのまとめ
Ruby 3.
- デフォルトではmain RactorにおいてM:Nスレッドは無効にされている
RUBY_
と環境変数を指定することで、有効化できるMN_ THREADS=1 - main Ractor以外ではデフォルトで有効
(無効にする方法がない)
- M:Nスレッドを有効にすると、スレッド生成などが速い
- M:Nスレッドを有効にすると、必要なネイティブスレッド数はRubyのスレッド数によらず一定である
- 1 Ractorの場合は1
(+ α) - n Ractorの場合はN
(+ α) で、NはRUBY_
で指定できるMAX_ CPU (デフォルト値は8)
- 1 Ractorの場合は1
- M:Nスレッドを有効にしても、Rubyスレッド数に比例したネイティブスレッド数が必要になることもある
Ruby 3.
一般的な言語のスレッド実装
プログラミン言語にスレッド
- M:1モデル:M個の言語スレッドを1個のネイティブスレッドで管理
- Ruby1.
8までで利用 - ユーザーレベルスレッド、ユーザスレッド、グリーンスレッドなどとも呼ばれる
- 世間一般では1:Nモデルと呼ぶ
(M:Nにあわせるために換えています)
- Ruby1.
- 1:1モデル:1個の言語スレッドごとに1個の言語スレッドを用意して管理
- Ruby 3.
2までで利用
- Ruby 3.
- M:Nモデル:M個の言語スレッドをN個のネイティブスレッド
(M<=N) で管理 - Ruby 3.
3で導入してみたもの - 世間一般ではN:Mモデルということが多いようです
- Ruby 3.
1:1モデルが素直で作りやすいのですが、ネイティブスレッドの生成と制御はソコソコ重い処理になります。たとえば、1万個とか10万個の言語スレッドを作るとつらくなります。そこで、利用するネイティブスレッドをN個に制限するのがM:Nモデルです。
ネイティブスレッドを利用する理由の一つが並列度の向上です。ネイティブスレッドはある程度並列コンピューター上でそれぞれ同時並列に実行されることが期待されますが、その並列度は一般的にCPUのコア数など、ハードウェアによって規定されます。そこで、M:NモデルではNをCPUのコアの数だけ、たとえば8とか16とかに設定しておきます。ネイティブスレッドをそれ以上、たとえば1万個作っても、同時に動くのはたかだかCPUのコア数になるためです。よって、N
ただし、M:Nスレッドの欠点として、作るのが大変
RubyのM:Nスレッドの設計と実装
ここでRubyにM:Nスレッドをどのように実装したか、その設計意図などをすこし紹介します。
2レベルスケジューリング
N個のネイティブスレッドがあるとき、RubyのスレッドはRactorごとにたかだか1つしか動かないようになっているため、まずは動かすRactorを決めます
このスケジューリングをさらに高性能化する方法はいろいろ知られていますが、現状では本当に単純なラウンドロビンスケジューリングしかしていません。そこで今後このあたりを検討していこうと思っています
ブロッキング処理のハンドリング
さきほど
- (1) 一定時間がたったら
(時分割スケジューリング) - (2) sleepやI/
O待ちなどで待ちになったら (ブロッキング処理のハンドリング)
(1)はタイマスレッドによって実現しますIO#read
で待っていたら、別のスレッドに切り替わってほしいわけです。この手の
いくつか例を考えてみます。sleep(n)
は、タイマーがあれば、
- (a) 別のスレッドに処理を移す
- (b) n秒後、sleepしていたスレッドを実行可能Rubyスレッドとして登録し、再度スケジューリングされるのを待つ
という手順でできそうです。read
の場合も、
- (a) 別のスレッドに処理を移す
- (b) readしてもブロックしないことを確認したら、実行可能Rubyスレッドとして登録し、再度スケジューリングされるのを待つ
という手順になります。
これを実現するのがタイマースレッドepoll
、BSD系のkqueue
、Windows系のIOCPが知られており、現在はepoll
とkqueue
にのみ対応しています
ここで問題となるのは、ブロッキング処理はsleepやread/close()
を呼び出すとブロックしてしまったりしますが、epoll
などでそれを適切にハンドリングする方法はありません
そこで、それらを管理外ブロッキング処理と呼ぶことにし、管理外ブロッキング処理を呼び出すときにはネイティブスレッドを専用に割り当ててしまうことにしました。ネイティブスレッドを占有させるのはコスト増になる可能性がありますが、動かないより全然マシという判断です。
さきほどProcess.
で待ってしまうとネイティブスレッドがたくさん増えてしまう例を示しましたが、それはProcess.
が管理外ブロッキング処理であるためです。厳密にいえば、Process.
を管理ブロッキング処理とすることは可能ですpidfd
の利用など)。しかし、とりあえず今動いているものは何もしなくてもきちんと動くことを、きちんと実装するために、このようにしています。
M:Nスレッドの無効化
細かい話になりますが、ネイティブスレッドのThread Local Storagerb_
)
ちなみに、RUBY_
を指定していないとmain RactorではM:Nスレッドは有効でない、という機能はまさにこの仕組みを使って実現しています。
M:Nスレッドのねらい
最後に、M:Nスレッドを導入した狙いについて紹介します。
たとえば数万、数十万のコネクションを処理するネットワークアプリケーションを作りたい場合に、現状ではGo言語やElixir
Rubyで並行処理を行うには、まずはスレッドが出てくると思いますが、残念ながらRubyの現在のスレッドは数万個作って処理できるようなものではありません。また、並行に処理しますが、並列計算機
そこで、スレッド自体をM:Nスレッドに変更することで軽量に扱えるようにすることがこの導入の経緯です。最終的にはRactorを軽量にポンポン作ったり、数万Ractorがグリグリ動くような世界を目指しています。
Rubyで軽量な並行処理を目指す別の仕組みとして、Ruby 3.
実はM:Nスケジューラは、Fiber schedulerと技術的に似たようなことをしているのですが、M:NスケジューラはFiberを意識したり拡張する必要がないこと、Rubyインタプリタ内で完結すること、Ractorによる並列処理にも寄与すること、といった点が異なります。
M:Nスケジューラの利点はスレッドの生成と管理が軽量になることです。具体的には生成や実行の停止・
おわりに
この記事ではRuby 3.
RubyのM:Nスレッド化について、より詳細については次をご参照ください。
- Rubyの並列並行処理のこれまでとこれから - クックパッド開発者ブログ
- Feature #19842: Introduce M:N threads - Ruby master - Ruby Issue Tracking System
- 私のRubyKaigiでの発表
連載のおわりに
本連載では、Ruby 3.
毎年のことですが、Rubyはどんどん良くなっていっています。ぜひ、お手元にセットアップして新しいRubyを楽しんでください。
Enjoy Ruby programming!