Skip to content

Latest commit

 

History

History
171 lines (117 loc) · 8.53 KB

elixir_spawn_trouble.md

File metadata and controls

171 lines (117 loc) · 8.53 KB

Elixir: spawn опасен для здоровья вашего к̶о̶т̶и̶к̶а BEAM сервера

сравнение 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

Если позволить параллельным процессам выполняться произвольно, то с лёгкостью можно израсходовать все системные ресурсы. 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 и будут выполнены по мере освобождения воркеров).

Так как для выполнения процессов в нашем примере не указан таймаут, то все события, помещенные в почтовый ящик будут обрабатываться воркером и потреблять оперативную память системы.

Таймаут для операций желательно указывать, это поможет выкинуть события, которые в ближайшее время обработать не получится

Потребляется 150MB RAM Потребляется 150MB RAM

GenStage

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 подойдет для обработки событий которые приходят извне и ожидают некоторого результата в ближайшее время, а в случае загруженности системы на данные события позволено ответить некой ошибкой.