I love Elixir and it’s functional style. That’s probably not much of a secret to you at this point, but one of the questions I often get is about transitioning to a functional style from an object oriented one.
Luckily, I have some really good experience with this, coming from Ruby, where everything is an object.
More to the point, the question really comes down to managing some sort of state in a software system beyond simple data structures being passed around, a replacement for the objects they previously used.
Elixir processes can manage state in the same way that long-lived objects do in a Ruby or Javascript application and we have two OTP abstractions that make this sort of thing easy: Agent
and GenServer
.
Agent
is built on top of GenServer
so let’s start exploring there.
GenServer
GenServer
is exactly what it kinda sounds like, a general server.
In this case, it’s not a web server or an application server, but more like a “state” server.
A GenServer
process can hold and manage state, but it can also perform calculations, wait for completion, process incoming messages, and do things completely on it’s own.
It runs as a separate process (on the BEAM virtual machine, not the OS), so it is often used to handle non-blocking processing of things.
In fact, another abstraction is used specifically for non-blocking processing, in the Task
module in the Elixir standard library.
In my experience, it’s very common to back a GenServer with a module written for it with a nicer API.
That’s because GenServers are super simple, responding to only a few calls, namely GenServer.handle_call/3
and GenServer.handle_cast/2
.
A “call” is used when you want to send a message to the GenServer and then wait for the response.
In contrast, a “cast” is made when you want to send a message, but don’t need a response and the processing can be done asynchronously.
A really basic example module looks something like this:
defmodule Queue do
use GenServer
def start_link(queue, name) do
GenServer.start(__MODULE__, queue, name: name)
end
# GenServer callbacks
def handle_call(:get, _from, [item | queue]) do
{:reply, item, queue}
end
def handle_cast({:add, item}, queue) do
{:noreply, queue ++ [item]}
end
end
Without writing the nicer API, calls to your imaginary queue GenServer look ugly:
GenServer.cast(:personal_queue, {:add, "check emails"})
GenServer.call(:personal_queue, :get)
Commonly, you’ll define extra functions in your module that handle the ugly parts for you:
defmodule Queue do
use GenServer
def start_link(queue, name) do
GenServer.start(__MODULE__, queue, name: name)
end
def add(queue, item) do
GenServer.cast(queue, {:add, item})
end
def get(queue) do
GenServer.call(queue, :get)
end
# other code ...
end
That’s much nicer, because now your calls look like:
Queue.add(:personal_queue, "check emails")
Queue.get(:personal_queue)
It’s less typing and much more intuitive.
Agent
The Agent
module lets you easily create a process to hold state without implementing a bunch of stuff for a GenServer.
It will usually require writing less code, but it is still common to back an Agent with a module.
Building off our Queue
example:
defmodule QueueAgent do
def start_link(queue, name) do
Agent.start_link(fn -> queue end, name: name)
end
def add(queue, item) do
Agent.cast(queue, fn(state) -> state ++ [item] end)
end
def get(queue) do
Agent.get_and_update(queue, fn([item | state]) -> {item, state} end)
end
end
We wrote less code and we can still use the same syntax for accessing our queue.
Since we have two tools here at our disposal, what tasks are they better suited to? The examples are available on Github, so you can follow along.
They are both fast, but GenServer is slightly faster
I ran some benchmarks on my poor computer to see about how fast the same, simple functions run in both a GenServer and an Agent:
## CreateBench
benchmark iterations average time
Agent 100000 11.54 µs/op
GenServer 100000 12.03 µs/op
## PlainBench
benchmark iterations average time
add 1000000000 0.01 µs/op
get 1000000000 0.01 µs/op
## QueueAgentBench
benchmark iterations average time
set 500000 6.45 µs/op
add 200000 8.40 µs/op
get 100000 12.84 µs/op
## QueueAsyncBench
benchmark iterations average time
add 1000000 2.01 µs/op
get sync 200000 7.66 µs/op
## QueueBench
benchmark iterations average time
set 500000 6.25 µs/op
add 500000 7.57 µs/op
get 100000 12.57 µs/op
The first set is just to create a process using GenServer.start_link
or Agent.start_link
and here you see that GenServer is slightly slower.
When it comes to the messages you’ll be passing to the Queue process, look at QueueBench
(GenServer version) and QueueAgentBench
(Agent version).
You’ll see that time difference between the two functions are very slight, but the GenServer version is slightly faster.
This makes sense considering that Agent
is built on top of GenServer
.
Compare these against the PlainBench
results, where I had similar basic operations, but not run in a separate process and waiting for a response.
With these results, it’s pretty clear that the parts that take the longest are related to message passing and communication between the processes.
I added the QueueAsyncBench
just for fun, showing you that an async call to add a item to the queue is blazing fast.
After all, it’s only waiting to receive the :ok
message, confirming that the Queue process has been sent the message.
It’s probably good to note here that the :ok
message doesn’t ensure that the Queue process received the message, just that it was sent to the process.
Also, make sure to note that these benchmarks are measured in microseconds, so the one microsecond difference between a GenServer
and an Agent
is imperceptible and won’t matter except under very heavy load.
Agents are easier to write for simple cases
Since both state management tools are fast, it’s more up to our preference. I tend to optimize for developer ergonomics and code maintainability.
For me, Agents are easier to write for simple things, like maintaining a simple queue or collecting results from multiple, concurrent tasks. In these cases, the Agent is just accepting some data and adding it to a list or iterating over a set of data to return a simple result.
If you’re new to Elixir or functional programming in general, you might find the anonymous-function-as-argument part of calls to Agent actually harder to write. Keep reading and writing them and it’ll come naturally pretty quickly.
GenServer is easier to understand in more complex cases
Part of this comes from simply having to separate functions to have a nice interface and handle callbacks. I have more opportunities for code documentation and the functions are likely to be more well thought out and single-purposed as a result.
Additionally, if the GenServer you’re writing is part of a package you’re building, GenServer makes it harder to crack open, hopefully preventing those bugs that come up from someone doing something their not supposed to. Personally, I’m very much against this philosophy, but not everyone agrees with me. I love that Ruby and its community is very trusting of developers and things are designed so you can do whatever you want with whatever you want. I think it leads to innovative use cases.
It’s really up to you
You’re the boss and it’s also good to note that there isn’t a wrong choice here.
If you choose to use Agent now and then, halfway through development, realize that it’s getting clunky and you’d prefer it to be a GenServer, extracting the calls to Agent
is pretty straightforward.
It’s great tools like these that makes me agree with James Edward Grey about Erlang (and Elixir) being the most object-oriented language
Comments