Hi all,
I’m working an an app where the end user has significant influence over some GenServer process memory usage. We can enforce some limits on these GenServers, so I need to monitor specific processes and kill processes are over their assigned limits.
I used Process.info(pid, :memory) and Process.info(pid, :binary) to calculate the amount of memory used. But I ran into a scenario where large binaries are not reported at all.
Here is some sample code:
defmodule BinaryTest do
use GenServer
def start_link() do
GenServer.start_link(__MODULE__, nil)
end
def init(_) do
{:ok, %{}}
end
def use_mem(pid) do
GenServer.call(pid, :use_mem, :infinity)
end
def copy_binary(pid) do
GenServer.call(pid, :copy_binary, :infinity)
end
def handle_call(:use_mem, _, state) do
seed_binary = 1024 * 256
|> :crypto.strong_rand_bytes()
large_binary = Enum.reduce((0..10), seed_binary, fn count, acc ->
acc <> acc
end)
state = Map.put(state, :large_binary, large_binary)
{:reply, :ok, state}
end
def handle_call(:copy_binary, _, state) do
state = Map.put(state, :large_binary, :binary.copy(state.large_binary))
{:reply, :ok, state}
end
end
{:ok, pid} = BinaryTest.start_link
IO.puts("Before concatenating, memory:#{inspect(Process.info(pid, :memory))}")
IO.puts(" binaries:#{inspect(Process.info(pid, :binary))}")
# Use all the memory
res = BinaryTest.use_mem(pid)
Process.sleep(5000)
IO.puts("After concatenating, memory:#{inspect(Process.info(pid, :memory))}")
IO.puts(" binaries:#{inspect(Process.info(pid, :binary))}")
binary_len = BinaryTest.copy_binary(pid)
Process.sleep(5000)
IO.puts("After :binary.copy , memory:#{inspect(Process.info(pid, :memory))}")
IO.puts(" binaries:#{inspect(Process.info(pid, :binary))}")
This example GenServer starts doing nothing.
The by calling use_mem, it generates a large binary by concatenating a binary to itself a few times.
Finally calling :copy_binary uses Binary.copy on the large binary.
It produces this output:
Before concatenating, memory:{:memory, 2776}
binaries:{:binary, []}
After concatenating, memory:{:memory, 2776}
binaries:{:binary, []}
After :binary.copy , memory:{:memory, 4640}
binaries:{:binary, [{5705928744, 536870912, 1}]}
I would have expected the :binary results to include at least the initial seed_binary (with a refcount of 10 perhaps?), but no binaries show up at all.
What’s even more curious is that the beam process already has 1G allocated before the copy_binary step.
I have also used the recon_alloc and it told me that the binary allocated has this amount of memory.
When using a simple process like this, the binaries do show up.
spawn(fn ->
seed_binary = 1024 * 256
|> :crypto.strong_rand_bytes()
large_binary = Enum.reduce((0..10), seed_binary, fn count, acc ->
acc <> acc
end)
receive do
:stop -> nil
end
end)
So I wonder, why is it not reported in the Process binaries before the copy_binary call? Could there be a bug in OTP where GenServer state is reported differently here? Is there a way to find out how much memory is allocated like this, but not reported in the :binary ?
I have tested this on different OTP versions, current version showing this behaviour is
Elixir 1.14.2 (compiled with Erlang/OTP 25)