Resque、SidekiqからSucker PunchまでActiveJobのバックエンドについてひと通り調べてみた

ActiveJobのバックエンドと、永続化先としてRedisが好まれる理由

Rails 4.2で、ActiveJobというクラスが導入されました。

これ以前より、Railsで非同期処理を行う際にはResqueやSidekiq、Delayed Jobなどが広く使われていましたが、 ActiveJobはジョブを記述するためのインタフェースを抽象化して、 ジョブの実装を変えること無くジョブランナーを切り替えることを可能にするものです。 なおActiveJobにおいては、バックエンドを指定しなければジョブは非同期実行しようとしても即座に実行されます。

Rails 4.2.0時点では、ActiveJobのバックエンドとして以下のページにある9つのいずれかを使用できます。

ActiveJob::QueueAdapters

  • Backburner
  • Delayed Job
  • Qu
  • Que
  • queue_classic
  • Resque 1.x
  • Sidekiq
  • Sneakers
  • Sucker Punch

ちなみにRuby ToolboxのBackground Jobsというカテゴリの上位3つは、Resque、Sidekiq、Delayed Jobでした。この3つがよく使われている印象です。

The Ruby Toolbox - Background Jobs: Save jobs in a queue and process them later wihout blocking your current thread

特にResque、Sidekiqは、ジョブの情報を永続化する先としてRedisを必須としています。

情報の保存先ならActiveRecord(RDBMS)でも良さそうなものですが、あえてRedisを必須としている理由は何だろうか考えてみました。

ぱっと思いつくのは、「キューにジョブが積まれた」というイベントをワーカーへ伝えるための手段として、 RedisのPub/SubBLPOP/BRPOPのようなメッセージング機構を使うのが簡単で都合が良かったのではないか?ということです。

バックエンドにMySQLのようなRDBMSを使う場合、普通に考えるとワーカー側はジョブを保存したテーブルをロックしつつポーリングしないといけないのでスケールしなさそうだし、 RabbitMQのようなメッセージングサービスを用いるのと比べて、RedisであればRailsエンジニアは元々better memcachedとして使用しているケースが多いので導入のハードルが低いです。

というわけで、このエントリではResque、Sidekiqの2つについてはRedisのメッセージング機構を使っているかどうかを確認しつつ、 ActiveJobのページに挙げられている9つのプロダクトについてジョブ情報の保存先を調べてまとめてみようと思います。

9つの中で特にSucker Punchはジョブの保存先もワーカープロセスの起動も必要とせずに非同期処理を行えるので、簡単に作りたいならおすすめです。詳細は後述。

Resque

Rails 4.2.0時点においてActiveJobが対応しているのはRedisのバージョン1系列です。

resque/resque at 1-x-stable

ワーカーを起動する場合はrakeタスクを実行するので、そのあたりからコードを読んでいきます。

lib/tasks/resque.task これはresque/tasksを読み込んでいるだけ。

resque/tasks.rb Resque::Worker#workがメインループのようです。

resque/worker.rb ジョブが無い場合はインターバル分だけスリープする実装になっていました。 ジョブの取得はさらに読んでいくとRedisのLPOPを使っていて、キューが空でもブロックせずにnilが帰ってきます。

Resque::Worker#work
  Resque#reserve
    Resque::Job.reserve
      Resque.pop
        Redis#lpop(redis-3.2.1)

結論

Resqueのバージョン1系はRedisのメッセージング機構は使わずにポーリングしてました。

ただ、2015-02-26時点のmasterブランチのHEADでは、インターバルに設定した値をタイムアウト値としてBLPOPを使う実装になっていました。 (インターバルが1より小さい場合はnon-blockで実質ポーリング)

Sidekiq

現時点で最新の3.3.2を読んでいきます。

mperham/sidekiq at v3.3.2

Sidekiqのエントリポイントはbin/sidekiqです。 中身はSidekiq::CLI#runなのでそこを見てみます。

sidekiq/cli.rb

Sidekiq::Launcher#runがメインループのようです。

sidekiq/launcher.rb

Celluloidを使ったアクターモデルでFetcherとManagerとPollerという3つのインスタンスが強調しつつ並行処理を行っているように見えます。 Pollerがスケジュールされたジョブをワークキューに載せ替える、ということをやっていました。 Managerの方では同時実行数の数だけProcessorのインスタンスを作って、 ProcessorごとにFetcher#fetchしています。 Fetcherの中身をざっくり追いかけると、 sidekiq/fetch.rb

Fetcher#fetch
  Fetcher.strategy
    Fetcher::BasicFetch#retrive_work
      Redis#brpop

という流れで最終的にRedisのBRPOPでジョブ情報を読みだしていました。

結論

Sidekiqはポーリングせずに、RedisのBRPOPを使ってジョブが積まれたイベントをワーカーに通知していました。

その他のバックエンド

Backburner

nesquena/backburner

beanstalkd というジョブキューサーバをバックエンドに使うようです。

Delayed Job

collectiveidea/delayed_job

バックエンドとして複数のプロダクトを指定できます。(DJはDelayed Jobのこと)

Qu

bkeepers/qu

RedisかMongo。

Que

chanks/que

PostgreSQLのAdvisory Lockを使います。

Advisory Lockを用いてMessage Queueを実現する方法については下記のサイトが詳しいです。

PostgreSQL で簡易に MQ - Mi manca qualche giovedi`?

ちなみにMySQLでMessage Queueやる場合はQ4MというストレージエンジンがありますがActiveJobで使う方法は見つかりませんでした。

queue_classic

QueueClassic/queue_classic

PostgreSQL。listen / notify & row lockingで実装。PostgreSQLはPub/Sub機能があるんですね。

Sneakers

jondot/sneakers

RabbitMQ。サンプルコードでRedisを使っていますが、デモのためのカウントアップをRedisでやっているだけです。

Sucker Punch

ジョブ情報の保存先やワーカープロセスの立ち上げが必要ありません。 Celluloidで即座に非同期処理を開始するモデルのようですね。 RailsやSinatraのようなアプリケーションプロセス一つでジョブの実行が(非同期に)完結するというのがウリみたいです。

そのため、RedisやRabbitMQを用意する必要はないし、ワーカープロセスを起動する必要すらありません。

Herokuで安価にサービス運用したいのでRedisやRabbitMQは使いたくないけどビューのレスポンスは高速化したいような場合に使うと良さそうです。 個人的にはこれがActiveJobのデフォルトのバックエンドでも良いくらいだと思いました。

development環境のWebrickで試してみましたが、コントローラのアクションメソッドでTestJob.perform_laterしても、アクションそのものはすぐにレスポンスが返ってきました。 また、ジョブが完了していなくても次のリクエストを処理することができていました。 Celluloid使っているらしいのでジョブはThreadで実行されていると思われます。

以下動作を確認したサンプルコードです。

# config/routes.rb
Rails.application.routes.draw do
  get 'top/welcome'
  get 'top/job'
  root 'top#welcome'
end

# config/initializers/sucker_punch.rb
Rails.application.configure do
  config.active_job.queue_adapter = :sucker_punch
end

# app/jobs/test_job.rb
class TestJob < ActiveJob::Base
  queue_as :default

  def perform(*args)
    logger.info "job started"
    sleep 5
    logger.info "job finished"
  end
end

# app/controllers/top_controller.rb
class TopController < ApplicationController
  def welcome
  end

  def job
    TestJob.perform_later
    flash[:notice] = "done!"
    redirect_to top_welcome_path
  end
end

# app/views/welcome.view.erb
<% if flash[:notice] %>
  <%= flash[:notice] %>
<% end %>
<%= link_to "Do job", top_job_path %>

感想

  • resque 1系がRedisを使いつつ実はポーリングしていたのが意外でした。
  • PostgreSQLはほとんど使ったこと無いんだけど、RDBMSでPub/Subできたら楽できそう。
  • Sucker Punchはワーカープロセスの起動無しで非同期処理できる点が便利。これでOKという場面は結構ありそうなので良い発見でした。