ユニファ開発者ブログ

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

Rubyの並列プログラミングについて考える

こんにちは、並列してますか?プロダクトエンジニアリング部の伊東です。

並列処理と言えば、過去に弊社メンバーがこんな記事を投稿しています。

tech.unifa-e.com

私は今までWebアプリケーションを作っていて、並列処理を書く機会はほとんどなかったです。 ただ最近、仕事の中で、同時に複数の処理を走らせたほうが早いケースに遭遇したので、改めてRubyの並列プログラミングについて考えたいと思いました。

並列処理が活かせそうなユースケース

並列処理が活かせそうなケースとして、例えば、いち早くawsのs3にファイルを大量にアップロードをしたい場合などが良さそうです。

並列ではなく直列的にアップロードする場合を考えると、1つのファイルがアップロードされたら次のファイルと順々に行うことになります。その場合、ある1つのファイル容量が多くてアップロードに時間がかかると、そこがボトルネックとなって、以降の処理がなかなか進みません。ゆえに直列では、すべてのファイルのアップロードが完了するまでに多くの時間がかかりそうです。

一方で、並列で同時にファイルをアップロードすることになると、1つのファイルに時間がかかっていても他のファイルのアップロードが進みます。そのため、すべてのファイルのアップロード完了時間は短くなると考えられそうです。

(後述しますが、これはネットワーク通信がボトルネックになりえそうな処理が多いと思われるのでI/Oバウンドな処理と言え、CPUの計算処理に依存するCPUバウンドなものとは分けて考えるといいです。)

フィボナッチ数列計算で並列処理

それでは、並列処理を試してみます。Rubyには並列処理を実現する方法がいくつかあります。Thread、Process、Fiber、Ractorなどです。 今回は、Thread、Process、Ractorで試してみます。

並列処理の有効性を確認するにあたって、フィボナッチ数列の計算を対象にしてみます。 下記のようなメモ化を使わないフィボナッチ数列の計算は、実行時間がかかりそうなので丁度並列処理の効果が期待できそうです。 (今回はn=40までの計算を5回繰り返します。)

※実行環境は、MacBook Pro 2020(CPU:2 GHz クアッドコアIntel Core i5、メモリ:16 GB 3733 MHz LPDDR4X) rubyのバージョンは3.2.2

まずは直列に計算してみる。

require 'benchmark'

def fib(n)
  return n if n <= 1
  fib(n - 1) + fib(n - 2)
end

Benchmark.bm do |bm|
  bm.report do
    5.times do
      fib(40)
    end
  end
end

       user     system      total        real
46.166422   0.285866  46.452288 ( 47.782437)

約48秒かかった。

Threadを使った方法(parallelというgemを利用します)

require 'benchmark'
require 'parallel'

def fib(n)
  return n if n <= 1
  fib(n - 1) + fib(n - 2)
end

results = []

Benchmark.bm do |bm|
  bm.report do
    Parallel.map([40, 40, 40, 40, 40], in_threads: 5) { |num| fib(num) }
  end
end
       user     system      total        real
  44.240589   0.225749  44.466338 ( 45.210952)

約45秒かかりました。さきほどの直列にやったときとあんまり変わりません。

Processを使った方法

require 'benchmark'
require 'parallel'

def fib(n)
  return n if n <= 1
  fib(n - 1) + fib(n - 2)
end

results = []

Benchmark.bm do |bm|
  bm.report do
    Parallel.map([40, 40, 40, 40, 40], in_processes: 5) { |num| fib(num) }
  end
end
       user     system      total        real
   0.001680   0.004683  57.178401 ( 11.944204)

約12秒です。かなり早くなりました。直列にやったときの4分の1くらいで完了です。

Ractorを使った方法

require 'benchmark'
require 'parallel'

class FibonacciCalculator
  def self.fib(n)
    return n if n <= 1
    fib(n - 1) + fib(n - 2)
  end
end

results = []

Benchmark.bm do |bm|
  bm.report("Parallel fib(40) 5 times with 'parallel' gem") do
    Parallel.map([40, 40, 40, 40, 40], in_ractors: 5, ractor: [FibonacciCalculator, :fib])
  end
end
       user     system      total        real
 58.496704   0.224902  58.721606 ( 12.328212)

約12秒です。同じく早いです。

フィボナッチ数列の計算結果について

まず、直列にやった場合約48秒かかりました。 そして、Threadは、約45秒かかりました。 ProcessとRactorはどちらも約12秒で完了しました。

なぜTreadはあまり早くならないのでしょうか。 それは、フィボナッチ数列の計算はCPUバウンドな処理だからです。 RubyのスレッドはI/Oバウンドな処理(ファイルの読み書き、ネットワーク通信など)には有用ですが、CPUバウンドな処理には必ずしも有用ではないからのようです。

RubyはGiant VM lock(GVL)があり、同時に実行できるネイティブスレッドはいつもひとつになるようです。 I/O待ちの場合はGVLは解放されるので複数のスレッドが同時に動くことが可能になるということみたいです。 詳しくはこちらを参照ください。 docs.ruby-lang.org

では、Threadが有効に働くI/Oバウンドな事例を見てみましょう。 次に事例は、複数のウェブサイトからファイルをダウンロードする例です。

複数のウェブサイトからダウンロードで並列処理

まずは直列に計算してみる。

require 'net/http'
require 'benchmark'

urls = [
  'http://www.google.com',
  'http://www.yahoo.com',
  'http://www.bing.com',
  'http://www.example.com',
  'http://www.wikipedia.org'
.... 約50件ほどのURLの配列
]

Benchmark.bm do |bm|
  bm.report do
    urls.each do |url|
      Net::HTTP.get(URI(url))
    end
  end
end
       user     system      total        real
   0.130959   0.097158   0.228117 (  9.458646)

約9.4秒かかりました。

Threadを使った方法

require 'net/http'
require 'benchmark'
require 'parallel'

urls = [
  'http://www.google.com',
  'http://www.yahoo.com',
  'http://www.bing.com',
  'http://www.example.com',
  'http://www.wikipedia.org'
.... 約50件ほどのURLの配列
]

Benchmark.bm do |bm|
  bm.report do
    Parallel.map(urls, in_threads: 5) do |url|
      Net::HTTP.get(URI(url))
    end
  end
end
       user     system      total        real
   0.094957   0.069750   0.164707 (  1.676318)

1.6秒になりました。早いです。

複数のウェブサイトからダウンロードで並列処理の計算結果について

ThreadもこのようなI/Oバウンドな事例には有効であるということがわかりました。 ちなみに、Processでもやってみましたが、約1.6秒で、Threadとあまり変わらない結果でした。

まとめ

今回、rubyの並列処理について改めて勉強してみました。 並列処理と言っても、適材適所で使い分ける必要性を実感できました。 今後の実装ではここらへんを意識して実装しようと思いました。


ユニファでは仲間を随時募集しています!詳細は下記の募集要項をご覧ください。

unifa-e.com