Poolboy

Вы можете незаметно использовать все системные ресурсы, если вы не ограничите максимальное число конкурентных процессов, создаваемых вашей программой. Poolboy — известная, легковесная, библиотека для Erlang решающая эту проблему.

Зачем использовать Poolboy?

Возьмём конкретный пример. Вам надо создать приложение для сохранения профилей пользователей в базу данных. Если бы вы создавали по процессу на регистрацию каждого пользователя, вы бы генерировали неограниченное количество соединений. В определённый момент эти соединения начали бы соперничать за ограниченные ресурсы сервера базы данных. В итоге в приложении стали бы возникать таймауты и различные ошибки из-за перегрузки, появившейся вследствие такой конкуренции.

Решение такой проблемы — использовать пул процессов-обработчиков для ограничения количества одновременных соединений вместо создания процессов для регистрации каждого пользователя. Так можно с лёгкостью избежать истощения системных ресурсов.

Для этого и нужен Poolboy. Он позволяет легко и просто настроить пул процессов-обработчиков, управляемых процессом-супервизором, без каких-то значительных усилий с вашей стороны. Существует множество библиотек, использующих Poolboy “под капотом”. Например, redis_poolex (Redis connection pool) - довольно популярная библиотека, использующая Poolboy.

Установка

Благодаря mix установка очень проста. Нужно всего лишь добавить Poolboy в список зависимостей в mix.exs.

Для начала создадим приложение:

mix new poolboy_app --sup

Добавим Poolboy в список зависимостей mix.exs.

defp deps do
  [{:poolboy, "~> 1.5.1"}]
end

Затем загрузим зависимости, включая Poolboy.

mix deps.get

Настройка

Перед тем как начать пользоваться Poolboy, надо ознакомиться с возможностями его настройки:

Настройка Poolboy

Для примера создадим пул процессов-обработчиков, ответственных за обработку запросов на расчёт квадратного корня числа. Пример намеренно выбран попроще, чтобы мы могли сфокусироваться на Poolboy.

Опишем модуль настройки Poolboy и добавим его как дочерний рабочий процесс при запуске нашего приложения. Отредактируем lib/poolboy_app/application.ex:

defmodule PoolboyApp.Application do
  @moduledoc false

  use Application

  defp poolboy_config do
    [
      name: {:local, :worker},
      worker_module: PoolboyApp.Worker,
      size: 5,
      max_overflow: 2
    ]
  end

  def start(_type, _args) do
    children = [
      :poolboy.child_spec(:worker, poolboy_config())
    ]

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

В первую очередь мы задали опции настройки пула процессов-обработчиков. Наш пул мы назвали :worker и установили параметр :scope (область видимости) в :local. Затем мы установили, что в качестве :worker_module этот пул процессов-обработчиков должен использовать PoolboyAbpp.Worker. Также мы задали, что :size этого пула равен 5 обработчикам. Кроме того, на случай, если все обработчики заняты, мы разрешаем создать ещё 2 обработчика в рамках настройки :max_overflow (эти overflow обработчики исчезнут после того, как закончат свою задачу).

И, наконец, мы добавили функцию :poolboy.child_spec/2 к списку потомков, поэтому пул процессов-обработчиков будет создан вместе с запуском приложения. Эта функция принимает два аргумента: имя пула и настройку пула.

Создание процесса-обработчика

Модуль процесса-обработчика будет простым GenServer’ом, который считает квадратный корень числа, затем останавливается на секунду и выводит pid процесса. Создадим lib/poolboy_app/worker.ex:

defmodule PoolboyApp.Worker do
  use GenServer

  def start_link(_) do
    GenServer.start_link(__MODULE__, nil)
  end

  def init(_) do
    {:ok, nil}
  end

  def handle_call({:square_root, x}, _from, state) do
    IO.puts("process #{inspect(self())} считает квадратный корень из #{x}")
    Process.sleep(1000)
    {:reply, :math.sqrt(x), state}
  end
end

Использование Poolboy

Теперь, когда у нас есть PoolboyApp.Worker, мы можем тестировать Poolboy. Создадим простой модуль, генерирующий задачи при помощи Poolboy. :poolboy.transaction/3 - это функция, которую вы можете использовать для взаимодействия с рабочим пулом. Создадим lib/poolboy_app/test.ex:

defmodule PoolboyApp.Test do
  @timeout 60000

  def start do
    1..20
    |> Enum.map(fn i -> async_call_square_root(i) end)
    |> Enum.each(fn task -> await_and_inspect(task) end)
  end

  defp async_call_square_root(i) do
    Task.async(fn ->
      :poolboy.transaction(
        :worker,
        fn pid ->
          # Let's wrap the genserver call in a try - catch block. This allows us to trap any exceptions
          # that might be thrown and return the worker back to poolboy in a clean manner. It also allows
          # the programmer to retrieve the error and potentially fix it.
          try do
            GenServer.call(pid, {:square_root, i})
          catch
            e, r -> IO.inspect("poolboy transaction caught error: #{inspect(e)}, #{inspect(r)}")
            :ok
          end
        end,
        @timeout
      )
    end)
  end

  defp await_and_inspect(task), do: task |> Task.await(@timeout) |> IO.inspect()
end

Запустим тестовую функцию, чтобы увидеть результат.

iex -S mix
iex> PoolboyApp.Test.start()
process #PID<0.182.0> calculating square root of 7
process #PID<0.181.0> calculating square root of 6
process #PID<0.157.0> calculating square root of 2
process #PID<0.155.0> calculating square root of 4
process #PID<0.154.0> calculating square root of 5
process #PID<0.158.0> calculating square root of 1
process #PID<0.156.0> calculating square root of 3
...

Если в пуле не останется свободных процессов, Poolboy вызовет таймаут после периода таймаута по умолчанию (пять секунд) и не будет принимать новые запросы. В нашем примере мы увеличили период таймаута до минуты, чтобы показать, как можно менять это значение. Для конкретно этого приложения вы можете увидеть ошибку, если поменяете значение @timeout на что-то, меньшее 1000.

Несмотря на то, что мы пытаемся создать много процессов (всего двадцать в примере выше), функция :poolboy.transaction/3 ограничит общее количество созданных процессов до пяти (плюс два процесса для обработки перегрузки), как мы и указали в настройках. Все запросы будут обработаны пулом процессов вместо создания по процессу на каждый запрос.

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!