将来を見据えたウェブ・アプリケーションの構築:The Codestのエキスパート・チームによる洞察
The Codestが、最先端技術を駆使してスケーラブルでインタラクティブなウェブアプリケーションを作成し、あらゆるプラットフォームでシームレスなユーザー体験を提供することにどのように秀でているかをご覧ください。The Codestの専門知識がどのようにデジタルトランスフォーメーションとビジネス...
ご存知のように、Ruby には MRI、JRuby、Rubinius、Opal、RubyMotion などの実装があり、それぞれ異なるコード実行パターンを使っています。この記事では、そのうちの最初の 3 つに焦点を当て、MRI
ご存知のように、RubyにはMRI、JRuby、Rubinius、Opal、RubyMotionなどいくつかの実装があり、それぞれ異なるパターンの コード を実行する。この記事では、そのうちの最初の3つに焦点を当て、MRI(現在最も人気のある実装)とJRuby、Rubiniusを、CPU負荷の高いアルゴリズムの処理やファイルのコピーなど、様々な状況におけるフォークとスレッドの適合性を評価するためのいくつかのサンプルスクリプトを実行することで比較する。
フォーク
スレッド
例えば、アプリケーション・サーバー・レベルではUnicorn(フォーク)とPuma(スレッド)、バックグラウンド・ジョブ・レベルではResque(フォーク)とSidekiq(スレッド)などだ。
以下の表は、主要なRuby実装におけるフォークとスレッドのサポートを示している。
Rubyの実装 | フォーク | スレッディング |
MRI | はい | あり(GIL**による制限あり) |
JRuby | – | はい |
ルビニウス | はい | はい |
このトピックでは、さらに2つのマジックワードがブーメランのように戻ってくる。まず第一に、これらの言葉を同じ意味で使うことはできない。一言でいえば、2つ以上のタスクがまったく同時に処理されることを並列性という。同時並行性は、2つ以上のタスクが(必ずしも同時とは限らないが)重なった時間帯に処理される場合に起こる。大雑把な説明になってしまったが、この違いに気づき、この記事の続きを理解するのに十分だろう。
以下の表は、並列性と並行性のサポートを示している。
Rubyの実装 | 並列性(フォーク経由) | 並列処理(スレッド経由) | コンカレンシー |
MRI | はい | いいえ | はい |
JRuby | – | はい | はい |
ルビニウス | はい | はい(バージョン2.X以降) | はい |
理論的な話はここまでにして、実際にやってみよう!
Rubyの実装でフォークとスレッドがどのように機能するかを説明するために、次のような単純なクラスを作成した。 テスト
と、それを継承するいくつかのクラスがある。それぞれのクラスが処理するタスクは異なる。デフォルトでは、すべてのタスクはループで4回実行される。また、すべてのタスクは、シーケンシャル、フォーク、スレッドの3種類のコード実行に対して実行される。さらに ベンチマーク.bmbm
一度目は実行環境を立ち上げて実行し、二度目は計測するためである。この記事で紹介する結果はすべて2回目の実行で得られたものである。もちろん bmbm
メソッドは完全な分離を保証するものではないが、複数のコードを実行した場合の違いは些細なものだ。
require "benchmark"
クラス テスト
AMOUNT = 4
def run
ベンチマーク.bmbm do |b|
b.report("sequential") { sequential }.
b.report("forking") { フォーキング } b.report("threading") { スレッディング
b.report("threading") { スレッディング }.
終了
終了
プライベート
def sequential
AMOUNT.times { 実行 }.
終了
def forking
AMOUNT.times do
フォークする
実行
終了
終了
プロセス.waitall
rescue NotImplementedError => e
# forkメソッドはJRubyでは利用できない
e を置く
終了
def threading
スレッド = []
AMOUNT.times do
スレッド << Thread.new do
実行
終了
終了
スレッド.map(&:join)
終了
def perform
raise "実装されていません"
end
終了
ループで計算を実行し、大きなCPU負荷を発生させる。
class LoadTest < テスト
def 実行
1000.times { 1000.times { 2**3**4 } }.}
終了
終了
走らせてみよう...
LoadTest.new.run
...そして結果をチェックする
MRI | JRuby | ルビニウス | |
シーケンシャル | 1.862928 | 2.089000 | 1.918873 |
分岐 | 0.945018 | – | 1.178322 |
スレッディング | 1.913982 | 1.107000 | 1.213315 |
ご覧の通り、逐次実行の結果は似ている。もちろん、解答の間にはわずかな違いがあるが、それはさまざまなインタープリターで選択されたメソッドの根本的な実装に起因するものだ。
この例では、フォークすることでパフォーマンスが大幅に向上する(コードの実行が約2倍速くなる)。
スレッドを使用すると、forkと同様の結果が得られるが、JRubyとRubiniusの場合のみである。MRI上でスレッドを使ってサンプルを実行すると、シーケンシャルな方法よりも少し時間がかかる。少なくとも2つの理由がある。第一に、GILは逐次的なスレッド実行を強制するので、完璧な世界では実行時間は逐次的な実行と同じになるはずだが、GIL操作(スレッド間の切り替えなど)のための時間のロスも発生する。第二に、スレッドを作成するためのオーバーヘッド時間も必要である。
この例では、MRIにおけるスレッドの使用感についての質問に対する答えは得られない。別の例を見てみよう。
スリープ・メソッドを実行する。
class SnoozeTest < テスト
def perform
スリープ 1
終了
終了
以下はその結果である。
MRI | JRuby | ルビニウス | |
シーケンシャル | 4.004620 | 4.006000 | 4.003186 |
分岐 | 1.022066 | – | 1.028381 |
スレッディング | 1.001548 | 1.004000 | 1.003642 |
ご覧のように、各実装は逐次実行とフォーク実行だけでなく、スレッド実行でも同じような結果を出している。では、なぜMRIはJRubyやRubiniusと同じような性能向上を実現できたのだろうか?その答えは 睡眠
.
MRI 睡眠
メソッドは rb_thread_wait_for
という別の関数を使用する。 ネイティブスリープ
.その実装を見てみよう(コードは簡略化されている。 これ):
static void
native_sleep(rb_thread_t *th, struct timeval *timeout_tv)
{
...
gvl_unlock_begin();
{
// ここでいくつかのことを行う
}
gvl_unlock_end();
thread_debug("native_sleep donen");
この関数がなぜ重要かというと、厳密なRubyのコンテキストを使うだけでなく、システムのコンテキストに切り替えてそこで何らかの処理を行うからだ。このような状況では、Rubyプロセスは何もすることがない。そうとも言えない:「このスレッドでやることがない?このスレッドでやることがないのなら、別のスレッドに切り替えて、しばらくしてからここに戻ってきましょう」というGILがあるからだ。これは、GILを gvl_unlock_begin()
そして gvl_unlock_end()
の機能がある。
状況は明らかになったが 睡眠
メソッドはほとんど役に立たない。もっと実例が必要だ。
ファイルをダウンロードして保存するプロセスを実行する。
require "net/http"
class DownloadFileTest < テスト
def perform
Net::HTTP.get("upload.wikimedia.org", "/wikipedia/commons/thumb/7/73/Ruby_logo.svg/2000px-Ruby_logo.svg.png")
終了
終了
以下の結果についてコメントする必要はない。上の例とよく似ている。
1.003642 | JRuby | ルビニウス | |
シーケンシャル | 0.327980 | 0.334000 | 0.329353 |
分岐 | 0.104766 | – | 0.121054 |
スレッディング | 0.085789 | 0.094000 | 0.088490 |
他の良い例としては、ファイルのコピー処理やその他のI/O操作がある。