Потребление памяти Elixir + Phoenix Channels

Я новичок в Elixir и Phoenix Framework, так что может быть, мой вопрос немного тупой.

У меня есть приложение с Elixir + Phoenix Framework в качестве бэкэнд и Angular 2 в качестве интерфейса. Я использую Phoenix Channels в качестве канала для интерфейсного/межсетевого обмена. И я обнаружил странную ситуацию: если я отправлю большой блок данных из бэкэнда в интерфейс, то потребление канала в канале будет доходить до сотен МБ. И каждое соединение (каждый канал) потребляет такой объем памяти даже после завершения передачи.

Вот фрагмент кода из описания базового канала:

defmodule MyApp.PlaylistsUserChannel do
  use MyApp.Web, :channel

  import Ecto.Query

  alias MyApp.Repo
  alias MyApp.Playlist

  # skipped ... #

  # Content list request handler
  def handle_in("playlists:list", _payload, socket) do 
    opid = socket.assigns.opid + 1
    socket = assign(socket, :opid, opid)

    send(self, :list)
    {:reply, :ok, socket}
  end

  # skipped ... #        

  def handle_info(:list, socket) do

    payload = %{opid: socket.assigns.opid}

    result =
    try do
      user = socket.assigns.current_user
      playlists = user
                  |> Playlist.get_by_user
                  |> order_by([desc: :updated_at])
                  |> Repo.all

      %{data: playlists}
    catch
      _ ->
        %{error: "No playlists"}
    end

    payload = payload |> Map.merge(result)

    push socket, "playlists:list", payload

    {:noreply, socket}
  end

Я создал набор с 60000 записями, чтобы тестировать интерфейс, чтобы справляться с таким количеством данных, но получил побочный эффект - я обнаружил, что потребление канала для конкретного канала составляет 167 Мб. Поэтому я открываю несколько новых окон браузера, и каждое потребление памяти нового канала увеличивается до этой суммы после запроса "плейлисты: список".

Это нормальное поведение? Я ожидал бы большого потребления памяти во время запроса базы данных и разгрузки данных, но он все тот же, даже после завершения запроса.

ОБНОВЛЕНИЕ 1. Поэтому с большой помощью @Dogbert и @michalmuskala я обнаружил, что после ручной сбор памяти мусора собирается освободиться.

Я попытался немного выкопать библиотеку recon_ex и нашел следующие примеры:

iex([email protected])19> :recon.proc_count(:memory, 3)
[{#PID<0.4410.6>, 212908688,
  [current_function: {:gen_server, :loop, 6},
   initial_call: {:proc_lib, :init_p, 5}]},
 {#PID<0.4405.6>, 123211576,
  [current_function: {:cowboy_websocket, :handler_loop, 4},
   initial_call: {:cowboy_protocol, :init, 4}]},
 {#PID<0.12.0>, 689512,
  [:code_server, {:current_function, {:code_server, :loop, 1}},
   {:initial_call, {:erlang, :apply, 2}}]}]

#PID<0.4410.6> - это Elixir.Phoenix.Channel.Server и #PID<0.4405.6> - cowboy_protocol.

Далее я пошел с:

iex([email protected])20> :recon.proc_count(:binary_memory, 3)
[{#PID<0.4410.6>, 31539642,
  [current_function: {:gen_server, :loop, 6},
   initial_call: {:proc_lib, :init_p, 5}]},
 {#PID<0.4405.6>, 19178914,
  [current_function: {:cowboy_websocket, :handler_loop, 4},
   initial_call: {:cowboy_protocol, :init, 4}]},
 {#PID<0.75.0>, 24180,
  [Mix.ProjectStack, {:current_function, {:gen_server, :loop, 6}},
   {:initial_call, {:proc_lib, :init_p, 5}}]}]

и

iex([email protected])22> :recon.bin_leak(3)                  
[{#PID<0.4410.6>, -368766,
  [current_function: {:gen_server, :loop, 6},
   initial_call: {:proc_lib, :init_p, 5}]},
 {#PID<0.4405.6>, -210112,
  [current_function: {:cowboy_websocket, :handler_loop, 4},
   initial_call: {:cowboy_protocol, :init, 4}]},
 {#PID<0.775.0>, -133,
  [MyApp.Endpoint.CodeReloader,
   {:current_function, {:gen_server, :loop, 6}},
   {:initial_call, {:proc_lib, :init_p, 5}}]}]

И, наконец, состояние проблемы обрабатывается после recon.bin_leak (фактически после сбора мусора, конечно - если я запускаю: erlang.garbage_collection() с pids этих процессов, результат будет таким же):

 {#PID<0.4405.6>, 34608,
  [current_function: {:cowboy_websocket, :handler_loop, 4},
   initial_call: {:cowboy_protocol, :init, 4}]},
...
 {#PID<0.4410.6>, 5936,
  [current_function: {:gen_server, :loop, 6},
   initial_call: {:proc_lib, :init_p, 5}]},

Если я не запускаю сборку мусора вручную - память "никогда" (по крайней мере, я ждал 16 часов) стала бесплатной.

Просто помню: у меня такое потребление памяти после отправки сообщения от бэкэнда к интерфейсу с 70 000 записей, полученных из Postgres. Модель довольно проста:

  schema "playlists" do
    field :title, :string
    field :description, :string    
    belongs_to :user, MyApp.User
    timestamps()
  end

Записи автогенерируются и выглядят следующим образом:

description: null
id: "da9a8cae-57f6-11e6-a1ff-bf911db31539"
inserted_at: Mon Aug 01 2016 19:47:22 GMT+0500 (YEKT)
title: "Playlist at 2016-08-01 14:47:22"
updated_at: Mon Aug 01 2016 19:47:22 GMT+0500 (YEKT)

Я бы очень признателен за любые советы здесь. Я считаю, что не собираюсь отправлять такой большой объем данных, но даже меньшие наборы данных могут привести к огромному потреблению памяти в случае многих клиентских подключений. И поскольку я не кодировал никаких сложных вещей, вероятно, эта ситуация скрывает некоторые более общие проблемы (но это просто предположение, конечно).

Ответ 1

Это классический пример утечки двоичной памяти. Позвольте мне объяснить, что происходит:

В этом процессе вы обрабатываете действительно большой объем данных. Это увеличивает кучу процесса, так что процесс способен обрабатывать все эти данные. По завершении обработки этих данных большая часть памяти освобождается, но куча остается большой и, возможно, содержит ссылку на большой двоичный файл, который был создан как последний шаг обработки данных. Итак, теперь у нас есть большой двоичный файл, на который ссылается этот процесс, и большая куча с несколькими элементами в нем. На этом этапе процесс переходит в медленный период, обрабатывая только небольшие объемы данных или даже данные вообще отсутствуют. Это означает, что следующая сборка мусора будет очень задержана (помните, что куча большая), и это может занять некоторое действительно долгое время, пока сбор мусора действительно не запустится и не восстановит память.

Почему память растет в двух процессах? Процесс канала растет из-за запроса базы данных для всех этих данных и ее декодирования. Как только результат декодируется в структуры/карты, он отправляется в транспортный процесс (обработчик ковбоя). Отправка сообщений между процессами означает копирование, поэтому все эти данные копируются. Это означает, что процесс транспортировки должен расти, чтобы соответствовать получаемым им данным. В транспортном процессе данные кодируются в json. Оба процесса должны расти, а затем оставаться там с большими кучами и нечего делать.

Теперь к решениям. Один из способов заключался бы в явном запуске :erlang.garbage_collect/0, когда вы знаете, что только что обработали много данных и не будете делать это снова в течение некоторого времени. Другой может заключаться в том, чтобы избежать роста кучи в первую очередь - вы можете обрабатывать данные в отдельном процессе (возможно, Task) и заботиться только о конечном закодированном результате. После того как промежуточный процесс будет выполнен с обработкой данных, он остановит и освободит всю память. В этот момент вы будете передавать только двоичный код refc между процессами, не увеличивая кучи. Наконец, всегда существует обычный подход для обработки большого количества данных, которые не нужны все сразу - разбиение на страницы.