Memory management - is there a general rule?

In a project I had a genserver that holds its local state. This state isnt exceptionally big but part of it was a list of 1000 ids that is a few times per second updated( items removed or added). The effect of this was that every few seconds the process appeared stuck ( i guess something related to garbage collection ). This problem got resolved by putting the list into a separate process.

Now I wonder what could be a general rule for processes. When should i separate a part of a state into a separate state?

If I add a item to the beginning of a list inside a map does this imply the complete map is copied?

Maybe you should have used ETS table instead of list? If there are random updates it can make it slower even further.

3 Likes

If the order of IDs in you list doesnā€™t matter, you can keep the list sorted to get better performance when inserting or removing IDs.

defmodule IDList do
  def new do
    []
  end

  def add(list, [id | ids]) do
    add(add(list, id), ids)
  end

  def add(list, []) do
    list
  end

  def add(list, id) do
    insert_unique(list, id)
  end

  def rm(list, [id | ids]) do
    rm(rm(list, id), ids)
  end

  def rm(list, []) do
    list
  end

  def rm(list, id) do
    remove_unique(list, id)
  end

  def insert_unique([], id) do
    [id]
  end

  def insert_unique([top | rest], id) when id > top do
    [top | insert_unique(rest, id)]
  end

  def insert_unique([id | rest], id) do
    [id | rest]
  end

  def insert_unique([top | rest], id) do
    [id, top | rest]
  end

  def remove_unique([], _) do
    []
  end

  def remove_unique([top | rest], id) when top < id do
    [top | remove_unique(rest, id)]
  end

  def remove_unique([id | rest], id) do
    rest
  end

  def remove_unique(rest, _) do
    rest
  end
end

ExUnit.start()

defmodule IDListTest do
  use ExUnit.Case

  test "inserting" do
    list = IDList.add(IDList.new(), [1, 1, 2, 1, 2, 2, 1])

    assert [1, 2] = list

    assert list == IDList.add(list, [])

    assert [1, 2, 3] = IDList.add(list, 3)
    assert [1, 2, 3] = IDList.add(list, [3])
  end

  test "removing" do
    list = IDList.add(IDList.new(), [4, 3, 2, 1])
    assert list == IDList.rm(list, [])
    assert [1, 2, 3] = IDList.rm(list, 4)
    assert [1, 2, 3] = IDList.rm(list, [4])
    assert [1, 2, 3] = IDList.rm(list, [4, 4])
    assert [2, 3] = IDList.rm(list, [1, 4])
    assert [2, 3] = IDList.rm(list, [4, 1])
  end
end

Or using a MapSet could be better too. But in any case 1000 ids ā€œa few times per secondā€ is small and you should not notice a GC pause for that.

1 Like

As @hauleth notes, :ets may be the right call here. It has the further perk of not creating garbage collector pressure, since its items are stored in its own managed memory space instead of within the process memory.

2 Likes

When it get slow. However, as other has noted, what you are doing should not be slow; and even if it does get slow, there is other ways to make it faster, such as ets.

2 Likes

I am sure I am have a GC pause for something related to this list. 1) Because I could tweek it a little bit by changing the GC parameters of the GenServer. 2) Because the separation of the list into another process made everything smooth.

But you are right its not the list itself. to be a bit more precise its a list of unordered integers that provide available unique ids (within a specified range).

Am I right that a list updated with [head|tail] triggers little memory movement (sorry i donā€™t know the right term)?

Does updating a list within a map create significantly more ā€œmemory movementā€, so that the GC has to work?

%{a: mylist, b: someotherstuff}

For example does a change in a implies that b has to be copied?

No, the value associated to b should not be copied if you change a. Rather, a new map that references the same b should be created. The details probably depend on the size of the map (I guess bigger maps are implemented as HAMT, smaller ones possibly not), but in any case b should not be copied if something else in the map changes: the implementation will generally try to reference as much as possible of the old immutable data structure.

I suspect there is something more to your case, could you maybe share some code?

2 Likes

I ran a quick benchmark based on a list of 1000 ids. When adding 500 existing ids and 500 new ids, then removing 500 existing and 500 not existing, ETS was the fastest.
Adding and removing 200 IDs, MapSet was faster (still on a 1000 IDs list at start).
My quick and dirty unique list implementation was the slowest in any case.

But ETS comes with its own new problems because it is like a mutable array. Now, that should not be a problem since you use another process currently, so you already have a ā€œkind of mutableā€ state. But if you need to do a lot of things locally with those IDs, donā€™t use ETS if it is impractical I guess.

But I agree, show us some code!

1 Like

ETS is a nice solution to your problem. One consideration, most guides show an ETS table ā€œmanagedā€ by a GenServer but this is suboptimal. ETS allows concurrent access and a GenServer in front of it limits the concurrency. Just create a table, maybe in your Application, and access it through itā€™s name from a module containing only functions.

I think youā€™re confusing the practice of having a specific process that owns the :ets table with the practice of serializing access to that table through the genserver. The two are orthogonal. You can setup a named table with read / write concurrency that is accessed directly, but still is owned by a supervised process. It is generally a best practice to put :ets tables under such managed processes because it allows them to be started / stopped / restarted effectively within your applicationā€™s supervision tree.

6 Likes

It should probably be said that an entire book chapter could be dedicated to these situations (I believe there are a few in existence!). Iā€™d maybe go as far as to say itā€™s one of the main quirks with immutable data, since you can find yourself copying the data over and over instead of what is a simple pointer update in mutable languages.

Also, a thousand IDs should not cause what you are describing; which makes me think you are replicating the data. If the ETS option doesnā€™t solve your problem, Iā€™ve picked up a few tricks when dealing with this type of scenario; which might be applicable for you:

  1. you can inspect the processā€™ memory usage using Observer. Runaway increases in memory may be a sign that old data is not being cleaned up as you update the ID list.

  2. if youā€™re processing the IDs each in a dedicated process, you can ā€œmonitorā€ the process, listen for the :DOWN message, and then force garbage collection (:erlang.garbage_collect()). Not ideal, but it can work. You can target particular processes as well (eg. parent process).

  3. state (memory) can come along for the ride when using Task.Supervisor with anonymous functions. Hereā€™s a nice explanation (not a bug). Short answer, MFA is preferred. If youā€™re doing any supervised processing in a loop, you may be inadvertently passing a lot of data around (locking it up).

3 Likes

The article is great. I have to look if i do something similar in my process. And his way to find the bug was similar to mine. ā€¦ one question what is MFA?

Module, function, arguments tuple:

{Foo, :bar, [1,2]}

Which make the handler call it as:

Foo.bar(1,2)

Module Function arguments. Generally refers to a tuple where each item the tuple provides one of those items in order:

{SomeModule, :function, list_of_args}

Sorry, shouldnā€™t have assumed that you knew what MFA meant. As mentioned above, rather than using an anonymous function, you can pass the Module, Function, and Arguments. In the docs, you will often see two variations of supervised / linked processes. One will take an anonymous function, and an alternate option of passing the module, function, and args.

For example:

start_child/3 (anonymous fn) vs start_child/5 (MFA)

This may or may not apply to your specific situation, but itā€™s something I have experienced myself where I had a queue within a GenServer that was expanding / contracting as work was completed.

Other than inadvertently pulling more than you actually need, is there any other concern using closures vs MFA?

MFAs (and some captures) can be faster and can be translated into compile time literals, which mean less copying. That is why in telemetry you should use &__MODULE__.func/2 instead of captures of private functions or lambdas.

2 Likes

MFA also works better with hot code loading. When a module is re-compiled the anonymous functions get a new opaque identifier even if their contents did not change.

2 Likes

My rule of thumb from other languages is: The closure should not out live the function context where it is defined. Otherwise, there could be hidden cost you may not be willing to pay.

1 Like

Not sure Iā€™m aware of notable ā€œconcernsā€ beyond the state being captured by the closure. There are some perf benefits (as mentioned above). Thereā€™s also the readability / testability of nesting linked process logic in an anonymous function.

My general rule, is that if the logic is more than a few lines, itā€™s deserving of being broken out.

You can always refer to the same module in your MFA with __MODULE__. For example:

def foo do
  Task.Supervisor.start_child(SomeTaskSupervisor, __MODULE__, :bar, ["baz", "qux"])
end

def bar(arg1, arg2) do
  IO.inspect binding(), label: "args"
  # args: [arg1: "baz", arg2: "qux"}
end