for term <- [42, :ok, {1, 2}, [1, 2, 3], %{a: 1}] do
IO.puts("#{inspect(term)}: #{:erts_debug.flat_size(term)} words")
end
42: 0 words
:ok: 0 words
{1, 2}: 3 words
[1, 2, 3]: 6 words
%{a: 1}: 6 words
A node's memory keeps growing, but no process holds anything big. Two BEAM rules explain most of these mysteries, and both follow from how processes stay isolated: every process has its own heap, and a message is copied into the receiver's heap.
Sizes here are in words (8 bytes on a 64-bit system,
4 in the browser's runtime),
as reported by :erts_debug, a module meant for
exploring the runtime like this, not for production
code. Small integers and atoms take no space of their
own: they fit inside the word that refers to them. (A
float takes a header word plus 8 bytes, which is 2 words
on a 64-bit system and 3 in the browser's 32-bit one.)
for term <- [42, :ok, {1, 2}, [1, 2, 3], %{a: 1}] do
IO.puts("#{inspect(term)}: #{:erts_debug.flat_size(term)} words")
end
42: 0 words
:ok: 0 words
{1, 2}: 3 words
[1, 2, 3]: 6 words
%{a: 1}: 6 words
Rule 1. A tuple holding the same 1000-element list twice
stores the list once: size/1 counts shared parts once,
flat_size/1 counts them every time they appear.
list = Enum.to_list(1..1000)
pair = {list, list}
IO.inspect(:erts_debug.size(pair), label: "pair, as stored")
IO.inspect(:erts_debug.flat_size(pair), label: "pair, flattened")
pair, as stored: 2003
pair, flattened: 4003
Sending it copies it into the receiver's heap, and the copy is flat: the receiver ends up with twice the data.
me = self()
receiver =
spawn(fn ->
receive do
term -> send(me, {:size, :erts_debug.size(term)})
end
end)
send(receiver, pair)
receive do: ({:size, words} -> IO.inspect(words, label: "pair, after sending"))
pair, after sending: 4003
Rule 2. Binaries over 64 bytes are stored once, outside
any process, and passed around by reference, so sending
one is cheap. A slice of one is a reference too, into
the original. :binary.referenced_byte_size/1 reports
how many bytes a binary keeps alive.
payload = :binary.copy("x", 1_000_000)
for n <- [10, 64, 65, 1000] do
slice = binary_part(payload, 0, n)
IO.puts("slice of #{n} bytes keeps #{:binary.referenced_byte_size(slice)} bytes alive")
end
slice of 10 bytes keeps 10 bytes alive
slice of 64 bytes keeps 64 bytes alive
slice of 65 bytes keeps 1000000 bytes alive
slice of 1000 bytes keeps 1000000 bytes alive
Slices up to 64 bytes are copied, so they don't hold on
to the original; bigger ones do. :binary.copy/1 makes
an independent copy of exactly the slice.
slice = binary_part(payload, 0, 1000)
IO.inspect(:binary.referenced_byte_size(:binary.copy(slice)), label: "copied slice keeps")
copied slice keeps: 1000
Pitfall: keeping a slice of a large request body, file or message (an id, a header) in a GenServer's state or in ETS keeps the whole original in memory. Copy slices you keep for long with
:binary.copy/1.
Pitfall: a large binary is freed only when every process that referenced it has garbage-collected. A long-lived process that rarely does work (a registry, a cache owner) can hold many dead binaries. Hibernating it (
{:noreply, state, :hibernate}) or calling:erlang.garbage_collect/1lets them go.
Pitfall: sending a big, internally shared term (a tree built from repeated subtrees, a list referenced from many places) multiplies its size in transit, and the same goes for storing it in ETS. Measure with
:erts_debug.flat_size/1before sending big terms.
Go deeper: The BEAM Book by Erik Stenman explains how a process is laid out in memory (chapter 3), how terms and binaries are represented (chapter 4), and how the per-process garbage collector works (chapter 12).