🎢

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