mmag

ハマったことメモなど

Poolboyメモ

社内勉強会でpoolboyで遊んでみて、いろいろ調べたのでこっちにも貼ります。


poolboyとは

プロセスプールを管理してくれるErlangのライブラリ。poolboyはワーカープロセスをいくつか予め起動しておく。それらを利用したいクライアントは、poolboyに「どのプロセスが暇ですか」と聞くと、「このプロセスは暇してます」とPIDを返してくれる。PIDを得たクライアントはワーカープロセスからサービスを受けられる。

何が嬉しいか

1. 並列度を制限できる

例えば、「画像URLを指定すると、インターネット上から画像を取得してディスク上に保存する」タスクを考える。並列度が制限されない場合、このタスクを必要な分だけ並列に実行できてしまうため、ネットワークやディスクに大きな負荷が掛かる可能性がある。あらかじめこのタスクを行うサーバプロセスの数を制限しておけばこの問題は回避できる。

2. 状態を使いまわせる

TCPのコネクションなどを必要になる度に作るのは非効率的である場合がある。そういったものはワーカープロセスの内部状態として保持することで、無駄なリソース消費を抑えられる。poolboyはワーカープロセスを可能な限り立ち上げたままにしておくため、起動時の初期化処理にかかる時間も節約できる。

使い方

起動

poolboyはワーカープロセスの再起動もやってくれるが、poolboy自体がクラッシュしてしまうとお終いなので、Supervision treeに入れるのが一般的。下の例では、Pool.Workerモジュールにstart_link/1が定義されていることを前提に、Pool.Workerプロセスを4つ起動している。

defmodule Pool do
  use Application

  @pool_name :my_first_pool

  def start(_type, _args) do
    import Supervisor.Spec, warn: false

    poolboy_config = [
      name: {:local, @pool_name},
      worker_module: Pool.Worker,
      size: 4,
      max_overflow: 1
    ]

    children = [
      :poolboy.child_spec(@pool_name, poolboy_config, :an_argument)
    ]

    opts = [strategy: :one_for_one, name: Pool.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

:poolboy.child_spec/3はプール名、オプション、ワーカーへの引数の3つをchild_spec形式で返してくれる便利な関数。第1引数は:poolboy(GenServerで実装されている)が起動するときに:nameオプションに渡される名前。{:local, atom}, {:global, term}, {:via, term}の形式のいずれかで指定する。第2引数については後述する。第3引数は、ワーカーを起動する際にワーカーに渡す値。

PIDの取得

起動したpoolboyが管理しているワーカーにリクエストを送るためには、いずれかのワーカープロセスのPIDを取得する必要がある。PIDの取得方法は、:poolboy.checkout/1, 2, 3:poolboy.transaction/2, 3がある。

:poolboy.checkout/1を呼ぶと、プールされたプロセスから1つが選ばれ、そのPIDが返ってくる。選ばれたプロセスは暇リストから一旦外される(ソース)。クライアントは得られたPIDを用いて処理を行い、:poolboy.checkin/2で暇リストへ戻す。

pid = :poolboy.checkout(:my_first_pool)

do_something_with(pid)

:poolboy.checkin(:my_first_pool, pid)

:poolboy.checkout/2の第2引数にはブロックするか否かをbooleanで指定できる。デフォルトはtrueである。第3引数には、ブロックする際のタイムアウト(デフォルト5秒)をミリ秒単位で指定する。暇リストが空のとき、第2引数がfalseならすぐに:fullが返され、第2引数がtrueならtimeoutだけ待ち、それでも暇リストにワーカーが戻ってこなければ、エラーを返す。

:poolboy.transaction/2はプール名と無名関数を受け取り、checkoutして得られたPIDを無名関数に渡して実行し、その後checkinする(ソース)。第3引数にはtimeoutを指定できる。

:poolboy.transaction :my_first_pool, fn (pid) ->
  do_something_with(pid)
end

基本的にはcheckoutではなくtransactionの方が、コードが簡潔になりcheckin忘れも無いため推奨される。暇リストが空のときにすぐ:fullを返してほしい場合はcheckoutを使うしかない。

オプション

:poolboy.child_spec/3の第2引数に与えられる設定群。以下のキーを持ったキーワードリスト。一部は省略可能。

  • worker_module: ワーカープロセスとして起動するサーバが実装されたモジュール
  • size: プールするワーカーの数
  • max_overflow: ワーカープロセスが足りなくなったときに起動される追加ワーカープロセスの最大数
  • strategy: プールからワーカーを選択するときの選び方。:lifo:fifo。デフォルトは:lifo

ソース

sizemax_overflow

sizeが5, max_overflowが3に設定されているケースを例とすると、最初に起動されるのはsizeで指定された5プロセス。リクエストが一斉に5つ来ると、暇なワーカーがいなくなる。ここでさらにPIDを求められると、poolboyは新たなワーカープロセスを起動する。この追加プロセスの最大数を指定するのがmax_overflowである。なのでこのケースでは、最大8つのワーカーが同時に存在することになる。なお、checkin時にsizeを超えてワーカーが存在する場合、checkinされたワーカーは廃棄される(ソース)。

strategy

暇なワーカーが複数存在するときに、:poolboy.checkoutがどのワーカーのPIDを返すかを指定するオプション。デフォルトの:lifoなら、プールはスタックのような挙動となり、最も最近まで仕事をしていたワーカーが選ばれる。:fifoなら、プールはキューのように動作し、最も長い間暇なワーカーが選ばれる。なぜデフォルトが:lifoなのかはこのPRに書いてある。一番ホットなワーカーを使うのが良い、という考え方。

やってはいけないこと

http://blog.elixirsips.com/2014/07/16/errata-dont-use-cast-in-a-poolboy-transaction/

以下の様なコードを考える。

def work
  :poolboy.transaction :my_pool, fn pid ->
    GenServer.cast(pid, :work)
  end
end

def handle_cast(:work, state) do
  time_consuming_task()
  {:noreply, state}
end

関数work/0複数のプロセスが並行して実行すると、:poolboy.transaction/2が並列に呼び出される。:poolboy.transactionは以下の実装になっているので、Fun(Worker)を実行している間は暇リストから外される。

transaction(Pool, Fun, Timeout) ->
    Worker = poolboy:checkout(Pool, true, Timeout),
    try
        Fun(Worker)
    after
        ok = poolboy:checkin(Pool, Worker)
    end.

work/0の場合、castは非同期呼び出しなので、すぐに:okが返される。ワーカーは一瞬で暇リストへ戻されるので、デフォルトの:lifo設定では、そのワーカーが本当はtime_consuming_task()を行っていて暇ではないのに、同ワーカーばかりがcheckoutされる。

参考

テーマはFB matteをベースにしてます。作者さんに感謝を込めて。