ActiveJobをシングルスレッドにしたい

この記事について

本記事は、2023年10月に社内で実施した勉強会の内容を基に、社外向けに再編集したものです。
記載の内容は執筆当時の情報であり、現在の仕様やベストプラクティスと異なる可能性があります。
実装にあたっては、必ず最新の公式ドキュメントをご確認いただくようお願いいたします。

経緯

  • とある外部APIを呼び出す際に、1秒あたり1リクエストという制限が設けられていた。
  • APIの呼び出し自体はバックグラウンドでの呼び出しで多少遅延しても問題なく、失敗しても定期的にリトライが可能。

1秒あたり1回のリクエスト制限を実現するため、まずはActiveJob内で1秒のsleepを挟む方法を試しました。
しかし、この方法では複数のAPIリクエストがあった場合、1秒あたりに複数の実行が発生してしまう可能性がありました。

ここで疑問に思ったのが、「ActiveJobはシングルスレッド実行が可能なのか?」という点でした。


ActiveJobとは(Notion AI)

最近何かと話題の生成AI(Notion AI)にActiveJobについて聞いてみました。

ActiveJobは、Ruby on RailsのフレームワークであるActiveRecordをベースにしたジョブキューイングシステムです。ActiveJobを使用することで、非同期処理やバックグラウンドジョブの管理を簡単に行うことができます。

Notion AIより

💡 ActiveJobはActiveRecordをベースとしていません。

ActiveJobは、バックエンドキューシステムと連携するためにアダプターを使用します。
デフォルトでは、ActiveJobはRailsのキューシステムであるDelayedJobを使用しますが、他のキューシステム(Resque、Sidekiqなど)にも切り替えることができます。

Notion AIより

💡 デフォルトはDelayedJobではありません。(後述)

ActiveJobを使用すると、非同期処理が必要なタスクをジョブとしてキューに追加することができます。
これにより、ユーザーがリクエストを送信した後でも、処理がバックグラウンドで実行されるため、レスポンスの速度やパフォーマンスを向上させることができます。

Notion AIより

💡 上記の記載は正しいです!

また、ActiveJobはシングルスレッドの動作モードをサポートしています。
シングルスレッドモードでは、ジョブが順番に実行されるため、競合状態やデッドロックのリスクを低減することができます。
シングルスレッドモードを有効にするには、ActiveJobの設定ファイルであるconfig/application.rbconfig/environments/*.rbで設定を行います。

Notion AIより

💡 スレッド数の指定のことでしょうか。(「1」に指定すれば実質シングルスレッド)
「シングルスレッドモード」というものが存在するかは不明でした。

ActiveJobは、Railsアプリケーションで非同期処理を行いたい場合に非常に便利です。シングルスレッドモードを使用することで、安定性とパフォーマンスを向上させることができます。

Notion AIより

💡 シングルスレッドモードだとパフォーマンスはあまり向上しないような・・・


ActiveJobのバックエンド

ActiveJobは特別な設定を行わなくても利用可能ですが、ジョブの永続化や信頼性を高めるために、複数のアダプターが用意されており、それぞれに対応するバックエンド上で動作させることができます。

参考URL:
Ruby on Rails公式ドキュメント,ActiveJob::QueueAdapters(2023年10月閲覧)
https://api.rubyonrails.org/v7.0/classes/ActiveJob/QueueAdapters.html

特に設定を行わない場合、デフォルトではAsyncアダプターが使用されます。
ただし、Production環境での利用は推奨されていません。

本記事では、以下の5種類のアダプターを取り上げます。
上2つはRails標準で利用できるもので、下3つは利用実績の多いバックエンドです。

  • Inline
  • Async
  • Sidekiq
  • Resque
  • DelayedJob

それぞれのバックエンドの違いについては、以下の記事が分かりやすくまとめられています。

参考URL:
Zenn , shima-zu, 【Active Job】Sidekiq vs Resque vs Delayed Job(2023年10月閲覧)
https://zenn.dev/shima_zu/articles/rails_active_job#3.-%E3%83%90%E3%83%83%E3%82%AF%E3%82%A8%E3%83%B3%E3%83%89%E3%81%AF%E3%81%A9%E3%82%8C%E3%81%8C%E3%81%84%E3%81%84%E3%81%AE%E3%81%8B%EF%BC%9F

各バックエンドの利用状況や人気度については、以下のページも参考になります。

参考URL:
The Ruby Toolbox, Background Jobs(2023年10月閲覧)
https://www.ruby-toolbox.com/categories/Background_Jobs

目次

Inline

参考URL:
Ruby on Rails公式ドキュメント,ActiveJob::QueueAdapters(2023年10月閲覧)https://api.rubyonrails.org/classes/ActiveJob/QueueAdapters/InlineAdapter.html

Inlineアダプターは非同期処理ではないため、利用シーンは限られます。
ただしマルチスレッドで動作するため、複数のリクエストが同時に発生した場合は並行して処理されます。

class InlineJob < ApplicationJob
  queue_as :default
  self.queue_adapter = :inline

  def perform(*args)
    10.times do |i|
      log "perform inline job #{i}"
      sleep 1
    end
  end
end

上記のコードをperform_laterで実行しても、処理が完了するまで待機されるため、perform_nowと同様の動作となります。

Async

参考URL:
Ruby on Rails公式ドキュメント,ActiveJob::QueueAdapters(2023年10月閲覧)https://api.rubyonrails.org/v7.0/classes/ActiveJob/QueueAdapters/AsyncAdapter.html

Asyncアダプターは、ActiveJobのデフォルトアダプターです。
インプロセスで動作するため、Railsプロセスが停止するとジョブも同時に終了します。
再実行しても問題のないジョブであれば、このアダプターでも十分対応可能です。

デフォルトではマルチスレッドで動作しますが、設定によってスレッド数を制限できます。
スレッド数を1に指定することで、実質的にシングルスレッドでの動作が可能になります。

class AsyncJob < ApplicationJob
  queue_as :default
  self.queue_adapter = ActiveJob::QueueAdapters::AsyncAdapter.new(
		min_threads: 1,
		max_threads: 1
	)

  def perform(*args)
    10.times do |i|
      log "perform async job #{i}"
      sleep 1
    end
  end
end

Sidekiq

参考URL:
Sidekiq公式(2023年10月閲覧)
https://github.com/sidekiq/sidekiq

Sidekiqは利用実績が多く、知名度の高いバックエンドの一つです。
Redis(NoSQL)をストレージとして利用するため、別途Redisの環境構築が必要です。

Sidekiqでは起動時にconcurrencyの指定ができます。

bundle exec sidekiq -c 1

sinatraというgemを導入すると、ジョブの状況を可視化できる便利なダッシュボードを利用可能です。

その他諸々の設定は下記参考

参考URL:
Sidekiq公式ドキュメント, Using-Redis(2023年10月閲覧)
https://github.com/sidekiq/sidekiq/wiki/Using-Redis

Sidekiq公式ドキュメント,Advanced-Options(2023年10月閲覧)
https://github.com/sidekiq/sidekiq/wiki/Advanced-Options#concurrency

Resque

参考URL:
Resque公式(2023年10月閲覧)
https://github.com/resque/resque

ジョブごとにプロセスがフォークされる仕組みのため、メモリリークやメモリ肥大化のリスクを軽減できます。

Resqueもコマンド実行時にworker数の指定ができますが、シングルスレッドなのでプロセスも増えます。

・シングルスレッドで起動
docker-compose exec -it -e "QUEUE=*" web rails resque:work
・シングルスレッドのworker数2で起動
docker-compose exec -it -e "QUEUE=*" -e "COUNT=2" web rails resque:workers

追加のgemを導入しなくてもダッシュボードを利用可能です(Route設定は別途必要)。

Delayed Job

参考URL:
Delayed Job公式(2023年10月閲覧)
https://github.com/collectiveidea/delayed_job

ジョブをデータベース上にキューイングできるため、Redisと比較して導入が容易で、他のミドルウェアをストレージとして利用することも可能です。

複数プロセスで起動すれば並行処理が可能になりますが、その分データベースへのアクセスが増加します。

公式ドキュメントではコマンド一つで実行可能と記載されていますが、検証環境では動作を確認できませんでした。
本番環境でのみ動作する仕様の可能性があります。

まとめ

調査の結果デフォルトの状態でもスレッド数の制限はできることがわかったので、Asyncバックエンドを利用することにしました。ただし、例えば複数台のサーバーを運用している状況の場合、Asyncではサーバー間の同期はできず今回のような要件は満たせませんのでご注意ください。

役に立ったらシェアしていただけると嬉しいです
  • URLをコピーしました!
  • URLをコピーしました!
目次