技術部の笹田です。今日で退職するので、バタバタと返却などの準備をしています。
本記事では、Rubyの並行並列処理の改善についての私の取り組みについて、おもに RubyKaigi 2022 と 2023 で発表した内容をもとにご紹介します。
並行と並列はよく似た言葉ですが、本記事では次のような意味で使います。
並行処理(concurrent processing)は、「複数の独立した実行単位が、待っていればいつか終わる(もしくは、処理が進む)」という論理的な概念で、古典的にはタイムシェアリングシステムなどが挙げられます。
並列処理(parallel processing)は、「複数の独立した実行単位のうちのいくつかが、あるタイミングで同時に動いている」という物理的な概念で、古典的には複数のCPU上で同時に実行させる、というものです。最近では、1つのCPU上で複数コアが同時に動いている、というのが普通になってきましたね。
Ruby(CRuby/MRI)は古くからThreadによる並行処理のための仕組みを提供しており、並列処理はUnixなどのプロセスなどを用いる、つまりRubyの外側(?)の機能と組み合わせて使う必要がありました。そして、Ruby 3.0から導入された Ractor で Ruby プロセス内で利用できる並列処理の仕組みが導入されました。まだまだ不十分なので、これからもっと頑張っていこう、っていう内容の記事になります。
簡単な歴史
Ruby 1.8 まで
Ruby 1.8 までは、Rubyはユーザーレベルスレッドと呼ばれる仕組みで、OSなどが提供するネイティブスレッド(下記NTとも表記、PthreadやWindows APIのスレッド)を1つつかって、複数のRubyスレッド(下記、RTとも表記、Thread.new{}
で作るやつ)を管理していました。1つのネイティブスレッドで複数(M個)の Rubyスレッドを管理するので、M:1 モデルということもあります(世間的には 1:N スレッドモデルということが多いのですが記事の都合上、M:1 と書いておきます)。
複数のRubyスレッドは、(1) I/Oや sleep、Mutex などで待ちが入るタイミング、(2) 一定時間(タイムスライス)経過したときのタイミングで切り替わります。I/O に関しては、select(に類する)システムコールで準備できたかを定期的に確認しながら管理します。
この手法の利点と欠点は次の通りです。
- 利点:
- ユーザレベルスレッドなので、生成は速い
- 欠点:
- 当時はRubyスレッドの切り替えをスタックを丸々コピーするという手法を使っていたので、結構遅いものでした。
- select で待ちを制御できない処理、たとえば
waitpid()
やflock()
、ドメイン名解決、待ちが入るようなライブラリ関数などで待っていると他のRubyスレッドに切り替わらない - ポーリング出来る処理は都度ポーリングで対処していたが、スレッド数が増えるとスケールしない可能性がある
- ネイティブスレッドを占有するほうが都合がよいライブラリ(GUI系など)を素直に使えない
- 作るのが(メンテし続けるのが)結構大変
Ruby 1.9 Thread
Ruby 1.9 では、Rubyスレッド一つにつきネイティブスレッド1つ用意する 1:1 モデルに変更されました。というのも、ユーザレベルスレッドはいろいろ tricky で、実際に実装していた私には手がおえなかったからです。また、1:1 モデルにすることで、並行処理だけでなく、並列処理への拡張も視野に入っていたからです。
Ruby 1.9 で 1:1 モデルを導入する際には、GVL(Global VM Lock)というのを導入しました。ただ一つのGVLをロックしているRubyスレッドしか動かない、というモデルとすることで、同時にRubyスレッドはたかだか1つしか動かない、というのものです。そのため、Rubyスレッドは相変わらず並行処理をサポートしますが、並列処理はサポートしません。いろいろな理由はあるのですが、要は「実装が簡単だから」ということに尽きます。
(余談:どちらも簡単に拘っているのは、手抜きしたいというのが本音なのですが、建前でいうと、マンパワーのない状況で安定した処理系を提供するためには適切なトレードオフであるのではないかな、と思っており、この判断は今振り返っても妥当だったんじゃないかなと思います。2006年ごろですかね)
- 利点:
- 1:1 スレッドモデルは結構簡単
- GVL があるので並列に実行されないためにさらに簡単
- GVL を解放することで(C-extension)、ブロックする処理をしている間、他のRubyスレッドに処理を移すことができる
- GVL を解放することで(C-extension)、複数の処理(Rubyで記述してある処理ではない)を並列に実行することができる。例えば、Bignumの時間がかかる計算や、I/O の処理(Ruby インタプリタとは独立した処理)など。
- Rubyスレッド切り替えはネイティブスレッドによって実装されるので速い
- スケジューリングをネイティブスレッドに任せてしまうので楽
- 欠点:
- 1:1 モデルなので Ruby スレッドを1つ作るたびにネイティブスレッド 1 つ必要になり、Rubyスレッド数がスケールしない(ネイティブスレッドの実装による)
- GVL があるので並列実行できない
- Rubyスレッド間で処理を受け渡すような処理が遅い(CPU core 間で処理を頻繁に切り替えるときに遅い)
- スケジューリングをネイティブスレッドに任せてしまうので、Ruby側からの細かい制御が難しい
Ruby 1.8 であった問題がだいぶ解決されているのがわかると思います。そのほかはトレードオフですね。
Ruby 1.9 Fiber
Ruby 1.9 では、ユーザレベルスレッドの利点をちょっとのこしておこうということで Fiber が導入されました。Fiber は Ruby 1.8 のユーザレベルスレッドとほぼ同様ですが、タイムスライスがなく、I/O などで自動的にスイッチすることのない、という点が異なります。
Fiber は当初は Ruby 1.8 のスレッド切り替え処理をそのまま踏襲していたのですが、のちのバージョンで改善され、今では CPU ごとにアセンブラを用いて記述する、というものになりました。
Ruby 3.0 の Fiber scheduler
Ruby 3.0 で導入された Fiber scheduler は、I/O など、ブロックするような処理を契機にフックを呼び出す仕組みで、自分で Fiber をスケジュールする処理(これを総称して Fiber scheduler)を記述するための仕組みです。実際には、自分で記述するのではなく、すでにある gem を利用するといいでしょう。
- 利点:
- ユーザレベルスレッドの利点(低コストな生成)を得られる
- 自分でスケジューラーを記述できる
- タイムスライスがないため予測可能性が上がる
- 欠点:
- Ruby 1.8 スレッドと同じ(管理できないブロックする処理では他のFiberに切り替えられない)
- Fiber を意識する必要がある
- タイムスライスがない
アプリケーションに特化したスケジューラを記述できるというのは、最高性能を目指すという観点からは良いものですが、多くの場合 too much じゃないかなぁ、というのが私の感想です。
Ruby 3.0 の Ractor
そもそも Ruby スレッド、というかいわゆるスレッド一般って、変更可能(Mutable)データの共有によるデータレース、レースコンディションの問題があり、Mutexなどで正しく排他制御が必要です。なので、私は、スレッド難しいなぁ、あんまり便利にしたくないなぁ、という意見を持っていました。この問題に対処するために、例えば他の言語では次のような工夫をしていました。
- 領域を完全に分けてしまう: Unix などのプロセス、Racket(のPlace)
- すべてのデータを Immutable にして、Immutable なデータしか共有できないようにする: Erlang, Elixir(のProcess)
- 型でいい感じになんとかする: Rust
- データを共有する方法を標準化する: Java, Golang(Goroutine)
- 実行時にやばそうなところを検知して修正する: Valgrind, Thread sanitiser, ...
Rubyで取れそうな戦略は何かな、ということを考えて、「"Immutable なデータしか共有できないようにする"なら、なんとかなりそうかな?」という発想で Ractor を設計しました。イメージとしては、Unixなどのプロセスに近いですが、共有可能な部分は共有するし、Mutable でもロックを必須とするなら共有可能にする、というところが近いです。
- 利点:
- 並列実行が可能
- データレースなどの問題が原理上起こらない
- 欠点:
- 共有可能オブジェクトを制限するので、ふつうのRubyプログラムが multi-Ractor の上では動かない
- 実装が悪いので色々遅い
「実装が悪い」という部分は、改善すればいいのですが、いくつか大きな問題がありました。
- 1:1 スレッドを踏襲している(Ractor 内に 1 個 Ruby スレッドを作るが、それが 1 ネイティブスレッドを要求する)
- すべてをとめて GC しなければいけないので Ractor 数にスケールしない
欠点の1つ目の問題から、なかなか利用されず、ではスクラッチで Ractor ベースに書き直せばよくなるか、というと2つ目の欠点である実装が悪いという点から利用も進まない、となれば順当に実装をよくしていくぞ、というのが今年の RubyKaigi 2023 での私の発表でした。
"Ractor" reconsidered - RubyKaigi 2023
要点としては、
- Ractor はせっかく入ったんだけど
- 既存のコードがすぐには動かないから使われていない
- 性能が悪いことが多くてなかなか使われていない
- とりあえず性能よくすることで、小規模なコードから使われるんじゃないだろうか
- そのためにはこういうことをやったし、これからこういうことをするよ
というものでした。
M:N スレッドの導入
というわけで「こういうことをしたよ/するよ」という話です。
まず、RactorやRubyスレッドは、1:1モデルであることで、生成が遅かったり生成できる数が少なかったりします(1:1モデルの欠点)。そこで、M:N スレッドモデルを導入できないか、今(というか去年から)開発中です。
という内容が RubyKaigi 2022 での私の発表でした。
Making MaNy threads on Ruby - RubyKaigi 2022
ちなみに、M:N スレッドモデルの実装なので MaNy プロジェクトというコードネームを中田さんにつけてもらいました。
- M:1スレッドは、RubyスレッドM個にたいしてネイティブスレッドが1個(Ruby 1.8 まで)
- 1:1スレッドは、Rubyスレッド1個にたいしてネイティブスレッドが1個(Ruby 1.9~)
- M:Nスレッドは、RubyスレッドM個にたいしてネイティブスレッドがN個
というモデルです。N をコア数にすることで、十分小さい数のN個のネイティブスレッドで、M個のRubyスレッド(例えば1000個)を賄おうというものです。これで、「1:1スレッドモデルがスケールしない」という問題が解決します。
欠点としては、実装が複雑であることですが、そこは頑張りました/頑張っています。
同じRactorに属するRubyスレッドは同時並列には動かないので、1つしかRactorを動かさない場合はNをいくら多くしても2個以上のネイティブスレッドを使うことはありません。なので、その点はユーザレベルスレッドと同じです。 あまりRubyスレッドをよくしたいという動機はないのですが(Ractor 使ってほしい)、副次的に現在のマルチ Ruby スレッドプログラムがユーザレベルスレッドモデルを用いることで改善されることもあるかもしれません。
ユーザレベルスレッドで問題となっていた、どーしょーもなくブロックしてしまう処理は、そのブロックしてしまう(可能性のある)処理を実行中、1:1スレッド、つまり1つのRubyスレッドが1つのネイティブスレッドを占有する、という状態にしておきます。ちなみにこの状態から戻らないと、他のRubyスレッドに処理がうつらないような気がしますが、ちゃんと移すために、準備のできたRubyスレッドを実行するためのネイティブスレッドを1個余分に追加します。つまり、N はそのようなスケジュール可能なネイティブスレッドの数であり、占有されている状態のネイティブスレッドは数に入れません(上限なしにネイティブスレッドが作られる可能性がありますが、動かないよりまし、というスタンスです)。
この工夫により、ユーザーレベルスレッドモデルで問題であった「何か処理が止まってしまう」ときに別のRubyスレッドに切り替わらなくなる、という問題が解決します。 M:Nスケジューラは、切り替わらなくなるかもしれない、といった危険のなくなった、そしてタイムスライスで切り替わる(プリエンプションがある)組み込みの Fiber scheduler みたいなもの、というとらえ方もできると思います。
このM:Nスレッドの実装は、Go language の構成によく似ています。ちょっとした違いとしては、goroutine はどれも順不同で実行できるのですが、Rubyのスレッドは同じRactorに所属している場合、同時に動くことはできない(GVLをもつRubyスレッドしか動かせない)、という制約です。この制約を満たすため、Ractor内のRubyスレッドについてのスケジューラと、どのRactorを動かすか選ぶRactorについてのスケジューラの2つのスケジューラによる構成となっています(もちろん細かい違いはほかにもいろいろあります)。
M:Nスレッドは、多くの場合で(ちゃんと作って有れば)問題ないと思われるのですが、どうしても仕組み的にネイティブスレッドの Thread local storage に依存した作りになっているコードを利用すると破綻する、という問題があります(あるRubyスレッドが異なるネイティブスレッドで実行するようになるため)。そこで、今のところ M:N スレッドモデルはデフォルトではオンにならないようにしようということにしています。より正しくは、メインRactorの中でRubyスレッドを作る場合(つまり、ふつうのRubyによるスレッドプログラムの場合)、1:1 スレッドという従来のスレッドモデルで実行されることになります。複数Ractorを利用する場合は、メインRactor以外はM:Nスレッドモデルで実行されます。
今のところ、RUBY_MN_THREADS=1
という環境変数でメインRactorでのM:Nスレッド対応を指定出来るようにする予定です。もし M:N スレッドの実装がマージされたら試してみてください。ちなみに、ネイティブスレッド数の上限Nを指定するには、今のところRUBY_MAX_CPU=n
という環境変数で指定できるようにする予定です。
詳細は当該チケットをご覧ください: Feature #19842: Introduce M:N threads - Ruby master - Ruby Issue Tracking System
性能改善などはあまりきちんとできていないのですが、去年の RubyKaigi 2022 の発表で、少し述べています。場合によってはだいぶ速くなっています。
Ractor local GC の導入
現在 Ractor が遅いもっとも大きな理由が GC です。並列に実行される Ractor ですが、GC をするためにはすべてを止める必要があり、とめた状態で唯一のネイティブスレッド上でGCが実行されるようになっています。これは色々遅いので、RactorごとにGCをそれぞれ並列に実行する、というRactor local GCができないか試行錯誤中です(他の方が試験実装中)。
これを実現するためには、Ractorがなまじっか Immutable オブジェクトは Ractor 間で共有できるといった仕組みから、きちんと動かすためには分散GCが必要になります。現在、実装しながら問題を見つけ解決していくような手探りな感じで開発を進めています。来年くらいに何かご紹介できるといいですね。
おわりに
本稿では、Rubyの並行並列処理について外観し、利点と欠点をまとめました。そして、それらの欠点を解消するために M:N スレッドモデルの実装を行っており、現状をご紹介しました。また、さらにまともな性能にするためにはRactor local GCが必要であるということをご紹介しました。
いまのところ、M:Nスケジューラは、1:1モデルにしておけば(デフォルトです)テスト通っているのですが、M:Nスケジューラを有効にすると「あれー?」というところでバグを出すので、Ruby 3.3 にマージできるのか予断を許さない感じです。がんばろ。
クックパッドでは、実はずっとこの辺をやっていました。
- 2016: Guild (のちの Ractor)構想の発表
- 2017: Ractor につなげるための、Fiber 周りの整理(この年にクックパッド入社)
- 2018: Ractor の実装の検討
- 2019: Ractor の実装(RubyKaigi 2019 では Ruby で MRI を書くための話をしていたけど)
- 2020: Ractor の入ったRubyのリリース(Guild -> Ractor に名前が変わったのもこの年)
- 2021: Ractor もデバッグできるようにするために debug.gem の開発(まだ Ractor では動かないんだけど)
- 2022: M:N スケジューラ構想の発表とプロトタイプ
- 2023: M:N スケジューラで Ractor を動かせるように
(やっていたのはこれだけじゃないけど)同じテーマで何年やってるんだ、という気もしますが、長い目で開発を支えてくれたクックパッドに深く感謝します。
そんな感じでまだまだやることがイッパイありますが、「並列並行処理を書くならRubyもいいね」といってもらえるように、これからも頑張っていきたいと思います。
読まなくてもよい余談です。私の卒論(2002)はスレッドライブラリ(pthread)の実装でして、20年たっても似たようなことしかやってねーな、という感想があります(でも、20年ずっと楽しいので幸せ。ちなみにYARV開発は2004年からなのでもうすぐ20年)。M:N スケジューラはその頃から考えてはいたんだけど、当時は逆に遅くなるかもなぁ、などと思っていたところでした。Go がだいたい同じようなことをしている、ということを確認できたので、結構自信をもって進めているという次第です。まぁ、Go はほかのランタイムを全部自分でかいているので、TLSみたいな互換性問題があまり起きないというところはあるとは思うんですが。2018年のRubyConf で、まつもとさんの部屋でこんな構想を話して、「でも2020年には間に合わないよなー」と言っていた内容が、やっと形になってきました。うまくまとめたいな。回り道しすぎ?
というわけで、またどこかで成果をおみせできることを楽しみにしています。
Happy hacking!