基本知識
プロセス
- プログラムの実行単位
- 固有のメモリ空間を持つ(リソースを共有しない)
- マルチプロセスの場合、物理/仮想メモリ領域間のアドレス解決のオーバーヘッドが高い。
スレッド
- プロセスの実行単位
- 共通のメモリ空間を持つ(リソースを共有する)
- マルチスレッドの場合、物理/仮想メモリ領域間のアドレス解決は発生しない。
ユーザースレッド
-
ユーザー空間(アプリケーションが利用するメモリ空間)を利用
-
1つのプロセスに複数のスレッドがあっても、1つのスレッドしか実行されない。
-
OSカーネルを介さないスレッド切り替えのため、スレッド切り替えに伴うオーバーヘッドが少ない。
-
仮想VM上で実行されるスレッドをグリーンスレッドと呼ぶ。
カーネルスレッド
- カーネル空間(カーネルが利用するメモリ空間)を利用
- 1つのプロセスに複数のスレッドがある場合、同時に複数(CPUコア数分)のスレッドを実行できる。
- OSカーネルを介するスレッド切り替えのため、スレッド切り替えに伴うオーバーヘッドが高い。
軽量プロセス(Light Weight Process : LWP)
- カーネル空間内に生成されるスレッドオブジェクト。
- LWPオブジェクトはユーザー空間内でスケジューリングされたユーザースレッドと連携する。
- このためカーネル空間でのスケジューリングのオーバーヘッドが軽減される。
- カーネルスレッドと軽量プロセスをまとめてネイティブスレッドと呼ぶ。
グローバルインタプリタロック(GIL)
- LL言語のインタプリタのスレッドが保持するスレッドセーフでないコードにより、スレッド間でリソースを共有してしまわないよう排他ロックを掛ける仕組み
- この機能により、実行スレッドが1つに限定されるためマルチプロセッサの恩恵を受けることができない。
- グローバルバーチャルマシンロック(GVL)とも呼ぶ。(Rubyではこちらが一般的か)
並行処理
- 実行状態を複数維持できる
- 概念的に異なる処理を同時に実行する(OSのマルチタスクなど)
- 複数のスレッドが状態を維持しながら時系列に切り替わって処理されるRubyのネイティブスレッドは並行処理
並列処理
- 複数の処理を同時に実行できる
- 同じ処理を分割して実行する
- 並列処理は並行処理に含まれる
- マルチプロセッサを利用して複数プロセスで同じ処理を同時に実行するマルチプロセスは並列処理
Rubyにおけるマルチスレッド
- GILが導入されたネイティブスレッド(バージョン1.9以降)
- 1つのプロセスに複数のスレッドを生成できるが、GILにより実行できるスレッドは常に1つ。
- マルチプロセッサの場合、時間単位に複数のプロセッサのスレッド間で処理が切り替わるため、あたかも並列処理しているように見える。
- そのため全体の処理速度はシングルプロセッサの場合とほとんど変わらない。
- ただし、IO待ちの際GILが解放されるため、一時的な並列処理が可能になる。
検証
- Vagrant
- CentOS 6.5
- Ruby 2.2
- gem parallel
- プロセッサ4つ
- メモリ1G
- ランダムに生成されたuuidをmd5で暗号化する処理を100000回行い、それを10回繰り返す処理
-
top -d 0.1 -u vagrant
にてCPU利用率やプロセスを確認
シングルスレッド
- 単純なループ処理
require 'benchmark'
require "securerandom"
exec_time = Benchmark.realtime do
[*1..10].each do |i|
100000.times do |ii|
Digest::MD5.digest(SecureRandom.uuid)
end
end
end
puts exec_time
- 実行時間 8.720838014996843秒
- 利用されるプロセッサは1つ
- プロセス/スレッドは1つ
マルチスレッド
-
parallel
モジュールを利用 - 100000回の暗号化処理10回分を4つのスレッドに振り分ける
- スレッド間の切り替え回数を確認するために、グローバルに宣言した
id
変数が暗号化処理中に切り替わる回数をカウント
require "parallel"
require 'benchmark'
require "secure random"
id = 0
count = 0
exec_time = Benchmark.realtime do
Parallel.each([*1..10], in_threads: 4) {|i|
id = i
100000.times do |ii|
if id != i
count += 1
end
id = i
Digest::MD5.digest(SecureRandom.uuid)
end
}
end
puts exec_time
puts count
- 実行時間 8.964116005001415秒
- すべてのプロセッサが利用されるが、同時に実行されるプロセッサ・スレッドは常に1つ
- そのためスレッドの切り替えが87回発生し、処理速度もシングルスレッドの場合とほぼ同じ。
マルチプロセス
-
parallel
モジュールを利用 - 100000回の暗号化処理10回分を4つのプロセスに振り分ける
- プロセス間でメモリ共有が発生しないことを確認するため、グローバルに宣言した
id
変数が暗号化処理中に切り替わる回数をカウント
require "parallel"
require 'benchmark'
require "secure random"
id = 0
count = 0
exec_time = Benchmark.realtime do
Parallel.each([*1..10], in_processes: 4) {|i|
id = i
100000.times do |ii|
if id != i
count += 1
end
id = i
Digest::MD5.digest(SecureRandom.uuid)
end
}
end
puts exec_time
puts count
- 実行時間 3.1994748680008342秒
- すべてのプロセッサを利用して並列処理される。
- プロセスはプロセッサ分(4つ)立ち上がっており、メモリ空間は各プロセスで独立しているためスレッド切り替えは発生しない。
まとめ
- Rubyのマルチスレッド/プロセスに関して、CPU利用率やプロセスの動作状況を確認することができた。
- Rubyで並行処理プログラミングすることはできるが、スレッドを時系列に切り替えているだけなので処理速度の大幅な向上は期待できない。ただし、IO待ちが発生する際に一時的にGILが解放されるため、大容量のデータにアクセスするような場合であれば並行処理による高速化が期待出来る(未検証)。
- マルチプロセッサの環境を利用できるならマルチプロセスで処理速度を向上させることは可能。
- ただしメモリのオーバーヘッドが高いため、使う場面の見極めが必要。