Rubyのスレッド処理について眺めてみる
はじめに
Rubyのスレッド処理のドキュメントから、Rubyの処理についての深追いをしてみます。
⚠️個人の技術記事なので誤り等があればご容赦ください。
Ruby 3.4 リファレンスマニュアル の引用
ネイティブスレッドを用いて実装されていますが、現在の実装では Ruby VM は Giant VM lock (GVL) を有しており、同時に実行されるネイティブスレッドは常にひとつです。ただし、IO 関連のブロックする可能性があるシステムコールを行う場合には GVL を解放します。その場合にはスレッドは同時に実行され得ます。また拡張ライブラリから GVL を操作できるので、複数のスレッドを同時に実行するような拡張ライブラリは作成可能です。
https://docs.ruby-lang.org/ja/latest/doc/spec=2fthread.html
GVLにより、CRubyが同時に実行できるスレッドは1つのみになりますが、I/Oのブロッキングする可能性がある場合にのみ、GVLを開放してスレッドの同時実行が可能な旨が記載されています。
I/O中は並行に処理を実行できるが、CPUを存分に使って計算を実行することが難しいように伺えます。
これの影響でRubyの処理は遅いのか?と思われる方もいらっしゃるかもしれませんが、ケースバイケースになります。CPUを使用した計算ロジックが多い場合は、GVLの制約を受けますが、I/Oが多い場合はGVLが解放されるのでその限りではありません。
実験
CPUバウンド
threads = Integer(ENV.fetch("THREADS", "8"))
total = Integer(ENV.fetch("N", "5_000_000"))
def measure(title)
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
c0 = Process.times
yield
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
c1 = Process.times
wall = t1 - t0
cpu = (c1.utime + c1.stime) - (c0.utime + c0.stime)
puts "#{title.ljust(14)} wall: #{format('%.3f', wall)}s cpu: #{format('%.3f', cpu)}s cpu%: #{format('%.1f', cpu / wall * 100)}%"
end
def cpu_work(n)
x = 1
n.times { x = (x * 1_000_003 + 97) % 1_000_000_007 }
x
end
per_thr = (total.to_f / threads).ceil
puts "[CPU] total=#{total} threads=#{threads} per_thread≈#{per_thr}"
GC.start; GC.disable
measure("single") { cpu_work(total) }
measure("#{threads} thr") do
Array.new(threads) { Thread.new { cpu_work(per_thr) } }.each(&:join)
end
GC.enable
I/Oバウンド
require "net/http"
require "uri"
url = ENV.fetch("URL", "http://127.0.0.1:3000/")
reqs = Integer(ENV.fetch("REQ", "64"))
conc = Integer(ENV.fetch("CONC", "16"))
def measure(title)
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC); c0 = Process.times
yield
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC); c1 = Process.times
wall = t1 - t0
cpu = (c1.utime + c1.stime) - (c0.utime + c0.stime)
puts "#{title.ljust(14)} wall: #{format('%.3f', wall)}s cpu: #{format('%.3f', cpu)}s cpu%: #{format('%.1f', cpu / wall * 100)}%"
end
def http_get(u)
Net::HTTP.start(u.host, u.port, open_timeout: 3, read_timeout: 10) do |h|
h.request(Net::HTTP::Get.new(u)).body
end
rescue => e
warn "[error] #{u} #{e.class}: #{e.message}"
nil
end
uris = Array.new(reqs) { URI(url) }
puts "[IO] url=#{url} reqs=#{reqs} conc=#{conc}"
measure("sequential") { uris.each { |u| http_get(u) } }
n = [conc, uris.size].min
measure("threads=#{n}") do
q = Queue.new; uris.each { |u| q << u }
workers = Array.new(n) do
Thread.new do
while (u = (q.pop(true) rescue nil))
http_get(u)
end
end
end
workers.each(&:join)
end
結果
CPUバウンド
[CPU] total=5000000 threads=8 per_thread≈625000
single wall: 0.242s cpu: 0.242s cpu%: 100.0%
8 thr wall: 0.239s cpu: 0.239s cpu%: 100.1%
I/Oバウンド
[IO] url=http://127.0.0.1:3000/ reqs=64 conc=16
sequential wall: 3.681s cpu: 0.092s cpu%: 2.5%
threads=16 wall: 0.257s cpu: 0.054s cpu%: 21.2%
まとめ
Rubyはネイティブスレッド上で動くものの、Rubyコードの実行は常に1スレッドずつとなります。
一方で、I/Oでブロックする可能性がある区間ではGVLを解放するため、I/O待ちの間は並行処理が実現できます。
CPUバウンド:スレッド数を増やしても wall がほぼ変わらず、cpu% ≈ 100%。GVLの影響でマルチスレッドでの実行が上手にいかないことを確認。
I/Oバウンド:I/O待ち中のGVL解放が効いており、マルチスレッドでの実行ができていることを確認。
Discussion