Elixir + Phoenix Channels 内存消耗

Elixir + Phoenix Channels memory consumption

我是 Elixir 和 Phoenix 框架的新手,所以我的问题可能有点愚蠢。

我有一个使用 Elixir + Phoenix Framework 作为后端和 Angular 2 作为前端的应用程序。我正在使用 Phoenix Channels 作为 front-end/back-end 交流的渠道。而且我发现了一个奇怪的情况:如果我从后端向前端发送大块数据,那么特定通道进程内存消耗会高达数百 MB。并且每个连接(每个通道进程)都会占用如此多的内存,即使在传输结束后也是如此。

这是后端渠道描述中的代码片段:

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 Mb。所以我打开了几个新浏览器 windows 并且在 "playlists:list" 请求之后每个新通道进程内存消耗都增长到这个数量。

这是正常行为吗?我预计在数据库查询和数据卸载期间会消耗大量内存,但即使在请求完成后它仍然是一样的。

更新 1。因此,在@Dogbert 和@michalmuskala 的大力帮助下,我发现在手动垃圾收集后内存将被释放。

我试着对 recon_ex 库进行了一些挖掘,发现了以下示例:

iex(n1@192.168.10.111)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(n1@192.168.10.111)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(n1@192.168.10.111)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}]},

如果我不 运行 手动进行垃圾回收 - 内存 "never"(至少,我已经等了 16 个小时)变得空闲。

请记住:从后端向前端发送消息并从 Postgres 获取 70 000 条记录后,我有这样的内存消耗。模型非常简单:

  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)

如果有任何建议,我将不胜感激。我相信我不会发送如此大量的数据,但在许多客户端连接的情况下,即使更小的数据集也可能导致巨大的内存消耗。而且由于我没有编写任何棘手的代码,所以这种情况可能隐藏了一些更普遍的问题(当然,这只是一个假设)。

这是二进制内存泄漏的经典例子。让我解释一下发生了什么:

您在此过程中处理的数据量非常大。这会增加进程堆,以便进程能够处理所有这些数据。处理完该数据后,大部分内存将被释放,但堆仍然很大,并且可能包含对作为处理数据的最后一步创建的大二进制文件的引用。 所以现在我们有一个进程引用的大二进制文件和一个包含很少元素的大堆。此时进程进入缓慢期,只处理少量数据,甚至根本不处理数据。这意味着下一次垃圾收集将非常延迟(记住 - 堆很大),并且可能需要很长时间才能真正进行垃圾收集 运行s 并回收内存。

为什么内存在两个进程中增长?由于查询数据库中的所有数据并对其进行解码,通道进程会增长。一旦结果被解码为 structs/maps,它就会被发送到传输进程(牛仔处理程序)。在进程之间发送消息意味着复制,因此所有数据都被复制过来。这意味着传输过程必须增长以适应它接收的数据。在传输过程中数据被编码成json。两个进程都必须增长,然后留在那里有大堆,无事可做。

现在到解决方案。一种方法是显式 运行 :erlang.garbage_collect/0 当您知道您刚刚处理了大量数据并且在一段时间内不会再这样做时。另一个可能是首先避免增加堆——您可以在单独的进程(可能是 Task)中处理数据,并且只关心最终的编码结果。中间进程处理完数据后,它将停止并释放所有内存。到那时,您将只在进程之间传递一个 refc 二进制文件,而不会增加堆。最后,总是有一种通常的方法可以同时处理大量不需要的数据——分页。