Sidekiqのスケールアップをきっかけに RubyのGVLを検証してみた

はじめに

こんにちは、プロダクト開発部の勝間田です。 先日、サービスのパフォーマンス向上のため、ECSで稼働しているSidekiqコンテナのvCPUをスケールアップしました。増やしたCPUリソースを最大限に活用すべく、Sidekiqのconcurrencyも引き上げたのですが、CPU使用率が期待通りに上がらず、オートスケールが機能しないという事態に陥りました。

この出来事をきっかけに、CPU、プロセス、スレッド(concurrency)、そしてRubyのGVL(Global VM Lock)について自分の理解が不十分だと気づきましたので、これらについて調査・検証を行いました。

きっかけ:予期せぬCPU使用率の低下

私たちのサービスでは、バックグラウンドジョブの実行にSidekiqを利用しています。CPU使用率に基づいたオートスケールを設定しており、1vCPU 8GBの環境で処理していました。 ここ最近メトリクスを確認するとメモリ使用率が頻繁に100%近くに達することがあったため、安定性向上・パフォーマンス向上を目指し、コンテナのスペックを1vCPU→2vCPU、8GB → 16GBにスケールアップしました。

【当初の期待】 vCPUが2倍になったので、Sidekiqのconcurrency(同時に処理するスレッド数)も約2倍に引き上げる。 これにより、CPU使用率は以前と同水準を維持しつつ、処理スループットが2倍になるはずだった。

【実際の結果】 concurrencyを増やしたにもかかわらず、CPU使用率がスケールアップ前の半分程度に低下してしまった。 その結果、オートスケーリングの閾値にも達しない状態になってしまった。

1vCPUの時点ではCPUリソースを使い切るほどジョブを捌けていたのですが、2vCPUにしたところ増やした分のCPUが遊んでしまいました。

原因は単純で実行者であるRubyプロセスが1つのままだった点にありました。 既にご存知の方も多いかもしれませんが、RubyにはGVLという「1つのRubyプロセス内で、同時に複数のスレッドがRubyのコードを並列で実行することを防ぐ」仕組みがあります。 つまり、複数のスレッドが存在していても、実際にCPUを使ってRubyのコードを実行できるのは、常にたった1つのスレッドだけです。

参考: class Thread (Ruby 3.4 リファレンスマニュアル)

そのため良かれと思ってスケールアップしたものの、結果的には手付かずの「空きCPU」を1つ作ってしまっただけでした。

concurrencyで指定した分、同時に処理されていると勘違いをしており、実際にはごく短い時間でスレッドを切り替えながら処理を進めている「見かけ上の並列処理(並行処理)」であることを理解しました。

スレッドの切り替えは以下の条件で行われます。

  1. スレッドがCPUバウンド(処理速度がCPUの計算性能によって制限されている状態)の処理を完了させる
  2. スレッドがI/O操作を実行する(この場合自動的にGVLを解放する)
  3. GVL保持期間を超える(デフォルト100ms)

参考:スレッドの切り替え

I/O待ちが発生する処理(例:データベースへのクエリ、外部API呼び出し、ファイルの読み書きなど)を行っている間は、GVLを解放する仕組みになっています。 あるスレッドがI/O待ちでブロックされると、そのスレッドはGVLを解放し、待機していた別のスレッドが実行権を得て処理を開始できます。これにより、I/Oバウンド(処理速度がInput/Outputの速度によって制限されている状態)な処理が中心のアプリケーションでは、マルチスレッドによるスループットの向上が期待できます。

コンカレンシーとパラレリズム、またGVLが行っていることをスーパーマーケットのレジに例えた解説がわかりやすかったです↓

techracho.bpsinc.jp

検証:GVLの動き

理論はわかったのですが、実際にGVLがどのように動作しているのかを確かめるため、CPUに負荷をかけるCPUバウンドなワーカーを用意して検証を行いました。

実行環境:Ruby 3.4.3

検証用ワーカー(Geminiにお願いしました)

class BenchmarkWorker
  include Sidekiq::Job

  def perform(worker_id, iterations = 50_000_000_0)
    puts "[開始] Worker#{worker_id}"
    start_time = Time.now

    result = 1
    counter = 0
    while counter < iterations
      counter += 1
      result = (result + counter + worker_id) % 1000000

      # 1000万回ごとに進捗を出力
      if counter % 10_000_000 == 0
        progress = (counter.to_f / iterations * 100).round(1)
        puts "[#{Time.now.strftime('%H:%M:%S.%3N')}] Worker#{worker_id}: #{progress}%"
      end
    end

    duration = Time.now - start_time
    puts "[完了] Worker#{worker_id}: #{duration.round(3)}"
    return duration
  end
end

単独でワーカーを動かしたログは以下です。 処理完了まで約25秒かかりました。 またputsの間隔は約0.5秒ほどでした。

INFO  2025-06-17T00:30:58.839Z pid=1 tid=cqx jid=bde01311593723bcb73d5bbc class=BenchmarkWorker: start
[開始] Worker1
[09:31:01.059] Worker1: 2.0% (10000000/500000000) result=1
[09:31:01.552] Worker1: 4.0% (20000000/500000000) result=1
[09:31:02.089] Worker1: 6.0% (30000000/500000000) result=1
[09:31:02.606] Worker1: 8.0% (40000000/500000000) result=1
...
[09:31:24.922] Worker1: 98.0% (490000000/500000000) result=1
[09:31:25.393] Worker1: 100.0% (500000000/500000000) result=1
[完了] Worker1: 24.825秒
INFO  2025-06-17T00:31:25.401Z pid=1 tid=cqx jid=bde01311593723bcb73d5bbc class=BenchmarkWorker elapsed=26.562: done

仮説

  • 真の並列処理の場合: 1つのワーカーを実行する時間と、2つのワーカーを同時に実行する時間は、ほぼ同じになるはず。
  • GVLが効いている場合: 2つのワーカーを同時に実行すると、1つのワーカーを実行する時間の約2倍の時間がかかるはず。

concurrencyが2以上のキューに対して2つ同時にエンキューしたログが以下です。

INFO  2025-06-17T00:31:32.045Z pid=1 tid=crd jid=89ef0d0e04252a9dd9dd6e12 class=BenchmarkWorker: start
INFO  2025-06-17T00:31:32.045Z pid=1 tid=cpl jid=97b2acb34b6ff2c8dcf5a4fd class=BenchmarkWorker: start
[開始] Worker1
[09:31:32.540] Worker1: 2.0% (10000000/500000000) result=1
[開始] Worker2
[09:31:33.200] Worker1: 4.0% (20000000/500000000) result=1
[09:31:33.932] Worker2: 2.0% (10000000/500000000) result=1
[09:31:34.103] Worker1: 6.0% (30000000/500000000) result=1
[09:31:34.980] Worker2: 4.0% (20000000/500000000) result=1
[09:31:35.104] Worker1: 8.0% (40000000/500000000) result=1
[09:31:35.929] Worker2: 6.0% (30000000/500000000) result=1
...
[09:32:19.217] Worker1: 96.0% (480000000/500000000) result=1
[09:32:19.251] Worker2: 92.0% (460000000/500000000) result=1
[09:32:20.123] Worker1: 98.0% (490000000/500000000) result=1
[09:32:20.258] Worker2: 94.0% (470000000/500000000) result=1
[09:32:21.086] Worker1: 100.0% (500000000/500000000) result=1
[完了] Worker1: 49.083秒
INFO  2025-06-17T00:32:21.159Z pid=1 tid=crd jid=89ef0d0e04252a9dd9dd6e12 class=BenchmarkWorker elapsed=49.114: done
(Worker2の完了ログは割愛しますが、ほぼ同時に完了しています)

ログを見ると、Worker1とWorker2の進捗が交互に出力されていることがわかります。 そして、完了までにかかった時間は約49秒単独実行のほぼ2倍になりました。Worker1とWorker2それぞれのputsの間隔も2倍近くの値になっています。 2つのスレッドが同時にそれぞれ実行されているのではなく、GVLによって交互に実行されている(コンテキストスイッチを繰り返している)ことがわかりました。

続いてconcurrency: 1のSidekiqキューに対して、2つ同時にエンキューした場合の挙動についても検証しました。

INFO  2025-06-17T00:51:47.438Z pid=1 tid=cs9 jid=6f918c7413e8b90a82687d50 class=BenchmarkWorker: start
[開始] Worker1
[09:51:49.372] Worker1: 2.0% (10000000/500000000) result=1
[09:51:49.867] Worker1: 4.0% (20000000/500000000) result=1
...
[09:52:14.165] Worker1: 100.0% (500000000/500000000) result=1
[完了] Worker1: 25.375秒
INFO  2025-06-17T00:52:14.174Z pid=1 tid=cs9 jid=6f918c7413e8b90a82687d50 class=BenchmarkWorker elapsed=26.737: done
INFO  2025-06-17T00:52:14.176Z pid=1 tid=cs9 jid=f29879723f0eb233ea8f5c10 class=BenchmarkWorker: start
[開始] Worker2
[09:52:14.663] Worker2: 2.0% (10000000/500000000) result=1
[09:52:15.146] Worker2: 4.0% (20000000/500000000) result=1
...
[09:52:38.078] Worker2: 98.0% (490000000/500000000) result=1
[09:52:38.556] Worker2: 100.0% (500000000/500000000) result=1
[完了] Worker2: 24.376秒
INFO  2025-06-17T00:52:38.556Z pid=1 tid=cs9 jid=f29879723f0eb233ea8f5c10 class=BenchmarkWorker elapsed=24.381: done

ログから、Worker1が完全に終了した後にWorker2が開始されていることがわかります。 concurrencyの設定が正しく機能し、ジョブが一つずつ処理されていることが確認できました。

まとめ

今回の経験からRubyによる開発において、以下のような学びを得ることができました。

  • GVL(Global VM Lock)は単一のRubyプロセス内では、同時に1つのスレッドだけがCPUバウンドのコードを実行できるようにする
  • Rubyのマルチスレッドは見かけの並列化であり、本当に並列で処理が進んでいるわけではない
  • I/Oバウンドな処理が多い場合、I/O待ちの間にGVLが解放されるため、スレッド(concurrency)を増やすことでスループットの向上が期待できる
  • CPUバウンドな処理をスケールさせたい場合、スレッド(concurrency)ではなくプロセスを増やす必要がある
  • Sidekiqのconcurrencyの設定値をただ大きくするだけでは改善に繋がらない可能性がある

Sidekiqのスケールアップでconcurrencyを増やした際、プロセス数自体は変わっていないため結果CPUを有効活用できないことに気づきました。この経験が、プロセス、concurrency、そしてRubyのGVLについて理解を深めるきっかけとなりました。 得た学びを生かし、効率的でパフォーマンスの高い設定を追求していきたいと思っています。

最後に

スタメンでは、Rubyエンジニアに限らず全技術領域で、プロダクトを成長させていくエンジニア、デザイナー、プロダクトマネージャーの方を募集しています。

herp.careers