сравнение spawn vs. Poolboy vs. GenStage
Многие эликсирщики, как и я, используют spawn для организации конкурентности в эликсире, но как оказалось, так делать не очень хорошо, и сейчас я поделюсь мудростью :)
Разберем на синтетическом примере: функция делает какие-то операции в течении ~5 секунд.
defmodule Spawner do
def start_processes(quantity) do
(1..quantity)
|> Enum.each(fn number ->
spawn fn ->
# тут вызов функции и передача аргументов
# но в синтетическом примере должны быть
# синтетические функции
:timer.sleep(5_000)
end
end)
end
end
На небольшом объеме данных проблемы появятся не сразу, давайте запустим много процессов…
В определенный момент будет достигнут лимит по процессам, либо будет съедена оперативная память сервера.
Но даже при 100 000 запущенных процессов мы уже расходуем пол гигабайта оперативной памяти, если вычисления в процессах будут сложнее — то скорей всего раньше будет достигнут лимит оперативной памяти сервера.
Итог: spawn нежелательно использовать для создания процессов, т.к. в данном случае мы не можем контролировать их количество и расход памяти, поэтому рискуем упасть под нагрузкой или перейти в swap режим операционной системы.
Если позволить параллельным процессам выполняться произвольно, то с лёгкостью можно израсходовать все системные ресурсы. Poolboy предотвращает возможность возникновения чрезмерной нагрузки с помощью пула процессов-обработчиков, ограничивающих количество параллельных процессов.
Урок https://elixirschool.com/ru/lessons/libraries/poolboy/
Считаем, что poolboy вы установили, в супервизоре указали воркер:
defmodule Worker do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, nil, [])
end
def handle_cast({:do_something}, state) do
:timer.sleep(5000)
{:noreply, state}
end
end
Немного отредактируем модуль Spawner (теперь это Launcher)
defmodule Launcher do
def start_processes(quantity) do
(1..quantity)
|> Enum.each(&do_something/1)
end
defp do_something(_number) do
:poolboy.transaction(:worker, fn pid ->
GenServer.cast(pid, {:do_something})
end)
end
end
Запустим 1 000 000 операций, это намного больше, чем мы запускали в прошлый раз.
iex> Launcher.start_processes(1_000_000)
:ok
Все операции были успешно выполнены (на самом деле нет, просто все запросы были помещены в почтовый ящик Poolboy и будут выполнены по мере освобождения воркеров).
Так как для выполнения процессов в нашем примере не указан таймаут, то все события, помещенные в почтовый ящик будут обрабатываться воркером и потреблять оперативную память системы.
Таймаут для операций желательно указывать, это поможет выкинуть события, которые в ближайшее время обработать не получится
GenStage — объемная тема про производителей и потребителей, и с ней тоже можно ознакомиться в соответствующем уроке.
Урок https://elixirschool.com/ru/lessons/advanced/gen-stage/
Но если кратко:
- Производители предоставляют данные по мере запросов потребителей;
- Потребителей может быть несколько (а-ля воркеры);
- Если все потребители заняты, то они не запрашивают данные у производителя, тем самым обеспечивается механизм обратного давления.
defmodule Producer do
use GenStage
def start_link(initial \\ 0) do
GenStage.start_link(__MODULE__, initial, name: __MODULE__)
end
def init(counter), do: {:producer, counter}
def handle_demand(demand, state) do
IO.inspect({:demand, state})
event = state + demand
{:noreply, [event], state + demand}
end
end
defmodule Consumer do
use GenStage
def start_link do
GenStage.start_link(__MODULE__, :state_doesnt_matter)
end
def init(state) do
# max_demand = 1
# значит наш потребитель запрашивает и
# обрабатывает 1 событие за раз
{:consumer, state, subscribe_to: [
{Producer, max_demand: 1, min_demand: 0}
]}
end
def handle_events(event, _from, state) do
:timer.sleep(5_000)
{:noreply, [], state}
end
end
Запустив несколько потребителей и одного производителя мы практически не расходуем память приложения, потому что все задачи выполняются по мере возможностей, никакие почтовые ящики не разбухают и все хорошо.
Spawn для обычного использования не подходит, т.к. обычно никто закрытие процесса по истечению некоторого времени и контролем количества запущенных процессов никто не занимается, дешево и сердито в этом случае использовать Task.async, а завершать процесс можно с помощью Task.shutdown
GenStage отлично подойдет, когда источник данных существует и каждое событие ждет обработки. Например, источником для производителя может служить таблица с новыми заказами (или брокер сообщений) в каком-то интернет магазине, а потребитель запрашивает необработанные заказы, отправляет на почту покупателю некоторое сообщение.
Poolboy подойдет для обработки событий которые приходят извне и ожидают некоторого результата в ближайшее время, а в случае загруженности системы на данные события позволено ответить некой ошибкой.