Learning Elixir's Agent
We recently saw how Elixir's GenServer works by building an shared id map. We used a GenServer because we wanted to have the id map periodically refreshed which required that we take advantage of the Process.send_after
function. However, without this requirement, we could have used an Agent to achieve our goal with much less effort.
Where a GenServer has a fairly flexible API and purpose, an Agent is more specialized as it's largely driven by two functions: get
and update
.
When we started our GenServer, we passed it the module which implemented the GenServer behaviour. An Agent, being simpler, doesn't require such decoration. However, just like we wanted to encapsulate the GenServer implementation within our module, so to do we want to encapsulate the Agent implementation. But things are still a little different, consider how we start an Agent:
defmodule Goku.IdMap do
@name __MODULE__
def start_link(_opts) do
Agent.start_link(fn -> load() end, name: @name)
end
defp load() do
%{"goku" => 1, "gohan" => 2}
end
end
Our Agent will still be supervised, which is what will call this module's start_link
function. But, there's no behaviour to implement. An Agent is just state that we can get and update. Anything we add to this module is just there to create a nicer API around the Agent, not something the Agent itself needs (like the GenServer's handle_*
functions).
We use get
to get data from our state:
def get(key) do
Agent.get(@name, fn state ->
Map.get(state, key)
end)
end
Behind the scenes, this behaves like our GenServer. The data/state is owned by our Agent, so while Agent.get
is called by one process, the closure is executed by our Agent's process. What you're seeing here is just a more focused API for doing a GenServer.call
with a handle_call
that returns a value and doesn't modify the state. (I'll repeat this over and over again, in Elixir, data ownership doesn't move around and isn't global like many other languages)
The update
function expects you to return the new state. For completeness, we could reload the state like so:
def update() do
Agent.update(@name, fn _old_state ->
load()
end)
end
A more idiomatic example might be where our state represents a queue that we want to push a value onto:
def push(value) do
Agent.update(@name, fn queue -> [value | queue] end)
end
There's also a get_and_update
which expects a tuple of two values, the first being the value to get, the second being the new state. And, like a GenServer, all these functions let you specify a timeout.
There are a few more functions available, but you get the idea. Anything you can do with an Agent, you can also do with a GenServer (the reverse isn't true). The Agent's advantage are it's simpler API that results in more expressive code. It's probably a safe rule to say that, if you can use an Agent, you should. And, if your needs grow, converting an Agent to a GenServer should be trivial.
But, again, the important point here is to understand the multi-process interaction that's going on here. Because we're using closures, it's less obvious here than with a GenServer. In the push
example above, you have single line of code that represents two statements, each being executed by a distinct process. Practically speaking, that detail probably won't matter. But the more clearly you grasp the fundamentals, the better your code will be.