ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

Rubyのスレッドで並列化するのに向いている処理を調べてみる

こんにちは、夏に向けて腹筋強化中のWebエンジニア、本間です。

はじめに、この開発者ブログを開設してから、半年が経過しました🎉

気づけばエントリー数も20を超え、開発者ブログっぽくなってきなーと感じております。 この勢いを続けていけるよう、会社としても個人としてもこれからも頑張っていく所存です。

話は変わりまして、今回のブログでは「Rubyのスレッドで並列化するのに向いている処理」を簡単に調べたので、メモがわりに残しておこうと思います。

調査を実施した理由として、バックグラウンドジョブを動かすGemの1つに sidekiq という有名なGemがあります。 このsidekiqの特徴の1つに「マルチスレッドで動作する」というものがあります。 一方、MRI(CRuby)にはGVL(Global VM Lock)、またはGIL(Global Interpreter Lock)と呼ばれる排他制御の機構があり、「同時に実行できるスレッドは1スレッドのみ」という制限がかかっています。 このロックにより、sidekiqでは複数スレッド立ち上げても処理性能が向上しないのでは、と疑問に思ったことがきっかけです。

ただし、GVLはIO関連の処理が実行されている間は解放されて他スレッドの処理を実行できる、と聞いたこともあります。 どの処理だとマルチスレッドにしてもGVLの影響を受けずに並列化できるのか、調査してみました。

調査

シングルスレッドとマルチスレッドで実行した場合の処理時間を比較して、ロックされているかどうかを確認します。

調査対象の操作

弊社のサービス内容を鑑みつつ、バックグラウンドで実行することが多そうな処理をメインに洗い出して見ました。

  • sleep
    • コマンド呼び出し
    • rubyで実行
  • HTTPでGET
  • SMTPでメール送信
  • データベースにSELECT
  • ローカルディスクからのREAD
  • ローカルディスクへのWRITE
  • 素数の計算
  • 画像のリサイズ
    • コマンド呼び出し
    • RMagickで実行
  • zip生成
    • コマンド呼び出し
    • rubyzipで実行

調査方法

各調査対象の操作に対して以下の3種類で計測を行い、かかった時間を比較します。

  • 1スレッドで 4 * N回の処理を実行
  • 4スレッドでN回の処理を実行
  • (参考)4プロセスでN回の処理を実行

コードにすると以下のようになっています。(N = 5の場合)

require 'benchmark'
require 'parallel'

THREAD_NUM = 4
n = 5

Benchmark.bm(15) do |r|
  r.report "Single thread" do
    Parallel.in_threads(count: 1) { |num| (n * THREAD_NUM).times { # 操作実行 } }
  end
  r.report "Multi threads" do
    Parallel.in_threads(count: THREAD_NUM) { |num| n.times { # 操作実行 } }
  end
  r.report "Multi processes" do
    Parallel.in_processes(count: THREAD_NUM) { |num| n.times { # 操作実行 } }
  end
end

複数スレッド、複数プロセスの実装は parallel を大いに利用させていただきました🙇

Nは操作に応じてちょうどよさげな時間(10-20秒前後)になるように調整しています。 1スレッドから4スレッドに増やしているため、GVLの影響がなければ4分の1程度の時間で処理が終わる想定です。

調査に使用したプログラムは下記のリポジトリで公開しています。

https://bitbucket.org/unifa-public/ruby_thread_lock_test/overview

結果

下記環境で実行して見ました。

  • MacBook Pro (Retina, 13-inch, Early 2015) (2コア/4スレッド)
  • macOS Sierra 10.12.5
  • ruby 2.4.1
  • parallel 1.11.2

結果は以下になります。(単位は全て「秒」です)

操作内容 1スレッド 4スレッド 4プロセス
sleep コマンド呼び出し 12.04 3.02 3.03
sleep rubyで実行 12.10 3.03 3.06
HTTPでGET 17.29 4.28 6.56
SMTPでメール送信 16.04 4.97 5.51
データベースにSELECT 12.14 3.05 3.13
ローカルディスクからのREAD 8.75 6.43 5.00
ローカルディスクへのWRITE 12.76 12.13 11.57
素数の計算 14.56 14.00 8.11
画像のリサイズ コマンド呼び出し 17.70 10.44 7.93
画像のリサイズ RMagickで実行 14.37 13.80 7.81
zip生成 コマンド呼び出し 25.15 8.19 8.08
zip生成 rubyzipで実行 25.62 12.74 10.75

考察

コマンド実行、外部サービス呼び出し

sleepコマンドの実行や、HTTPのGET、SMTPのメール送信、DBへのSELECTは、実行時間がほぼ4分の1となっているため、GVLの影響はないと思って良さそうです。

ただし、今回は非常に簡略化した処理でテストをしています。 業務で実行する処理は、これらの操作でもRubyでの処理がある程度入ると思いますので、GVLの影響が少し出てくると想定されます。

ローカルディスクへのREAD/WRITE

これらもGVLの影響がないかなと考えていたのですが、1スレッドの時と実行時間はほとんど変わらない結果になりました。

しかし、参考で走らせていたマルチプロセスの計測でもパフォーマンスが出ていません。 GVLではなく、OSレベルでの排他制御で並列処理がブロックされてしまった可能性があります。 こちらに関しては、本番相当の環境で実行してみて同じ結果になるかを確認してみます。

素数の計算

これは明らかにGVLによって並列化が阻害されていると考えられます。 Rubyレベルでたくさんの計算をする処理は、スレッドによる並列化は向いていないことがわかります。

マルチプロセスではもっと早くなっても良さそうですが、素数の計算は1回目と2回目以降で計算速度が異なるようで、1回目のオーバーヘッドが余分に出てしまっているようです。 計算回数をもっと増やせば、4分の1に近づいていくと思われます。

画像のリサイズ

今回の調査で一番興味深かった結果です。

コマンド呼び出しで実行した場合、4分の1とまではいきませんが、マルチスレッドの方がパフォーマンスが出ていることがわかります。(リサイズ後の画像を保存している時にOSレベルでの排他がかかっており、速度が落ちたと推測)

一方、RMagick経由でリサイズ&保存した場合、シングルスレッドもマルチスレッドも処理時間がほぼ同じであることがわかります。 これは、画像処理をrubyと同じプロセスで実行していて、GVLの影響を受けたためと推測します。

上記からマルチスレッドで画像処理の並列化を行う場合、RMagickを使うのではなく、ImageMagickのコマンドを直接実行した方がよさそうです。

zip生成

コマンド実行、rubyzipでの生成ともマルチスレッドで高速化することができました。 ただし、rubyzip側も高速化できているのは、内部で使用しているzlibがGVLを外して実行しているからだと推測されます。

まとめ

  • コマンド実行、外部サービス呼び出し、DBに重いクエリ投げる、等はスレッドでも問題なし
  • Rubyでがっつり計算している処理は、スレッドで並列化しても意味がない
  • 画像処理は、ImageMagickのコマンドを直接実行した方が良さそう
  • ファイルの読み書きに関しては、追加で調査してみる