Talk

Understanding Elixir GenServers

Rewinding all the way to spawn, then rebuilding a GenServer by hand so you can see what the behaviour is actually saving you from.

Event
Curiosum Elixir Meetup #25
Date
14 February 2024
Location
Poznan, Poland and online
Length
28 minutes
  • Elixir
  • OTP
  • GenServer
  • Processes
  • Livebook
  • Teaching
Sigu Magwa presenting GenServers at Curiosum Elixir Meetup 25
Still from the recording of the talk, Curiosum Elixir Meetup #25, February 2024. Source: Curiosum on YouTube.

Takeaways

Alvaro Callero spoke before me and had already covered GenServers. So I did the opposite of building on that: I rewound to the smallest possible process and walked forward until the GenServer became obvious. The aim was never the callback list. It was why the thing exists at all.

Start with the cost you are trying to avoid

Five functions that each sleep five seconds take twenty-five seconds in sequence. Spawn all five and it finishes at once. That gap is the entire motivation and it is worth watching the timer run.

A spawned process is a minion you lose

Send it out, it does the work, it dies. You get nothing back and you cannot talk to it. Solving that is what send and receive are for.

An unmatched message just sits in the mailbox

I sent a pong when the receive was waiting for a pang, on purpose, then let the room watch the cell hang. Messages are first in, first out. One that matches nothing blocks nothing and resolves nothing.

A server is only a loop that never ends

Spawn a process, have it receive a message, act on it, then call itself. Tail recursion, so it runs forever. That is the whole idea. I built a calculator that way, borrowed from Sasa Juric's Elixir in Action.

The GenServer is that loop, with the boilerplate extracted

Starting a server and reacting to messages was so common that it became a behaviour. Once you see the hand-rolled version, cast and call stop being vocabulary and start being obvious.

You can watch a running GenServer from outside

sys.trace prints every message and the state after it. sys.get_state gives you the current state. Push four values, pop one and read the state change back.

I once turned a GenServer into a bottleneck without noticing

Every incoming web request ran its calculation through one GenServer before writing to the database. The concurrency was real everywhere except the point everything had to queue through. Performance dropped.

Watch the talk

Why I did it in Livebook

I like teaching and I lean on pictures because they stick. Livebook renders sequence diagrams from the process activity in the notebook, so every step of this talk ends with a diagram of what actually happened rather than a slide of what should have.

It also means the demo is honest. Cells queue behind each other, a stale process is still running from two cells ago, then you get to watch me reconnect the runtime and re-evaluate. That is what working with processes looks like.

From a slow function to five fast ones

The starting point is an anonymous function that sleeps five seconds and prints a result. Run it once, it takes five seconds. Map it over one to five and it takes twenty-five. I made the room wait out the last seven.

Then spawn it five times instead. Spawn takes a lambda, runs it and the process dies afterwards. The result comes back immediately.

The Livebook diagram shows what happened. The current process, self, spawns five children, lets each go and is free to carry on. It also shows the flaw. We cannot get a result back. We cannot talk to them. They go out, do their thing and die without reporting.

Send and receive

So how do two processes talk? You send and you receive. It lives down in Erlang, but the shape is the same.

Spawn a child that waits for a ping. When it gets one, it sends pong to the parent, which is the notebook process. The parent sends the ping, the child matches it and replies. You give it the address of whoever you want to reach and messages arrive first in, first out.

Then I broke it deliberately. Send something the receive cannot match and the cell keeps running. It has not received anything it can act on, so nothing happens and nothing errors. This is the failure mode people hit first and I would rather they meet it in a notebook.

Building a server before naming it one

Do not call it a GenServer yet. Call it a server, because it runs forever.

The calculator has a start function that spawns a process and that process calls loop. Loop receives a message, acts on it and calls itself. Tail recursion, so it never ends. An always-listening server.

The acting-on-it part is a pattern match. Ask for the value and it sends the value back. Add and it adds. Subtract, multiply, same. Because the reply is an explicit send, the value comes back to the caller.

Start it, add 2, add 7, add 20, multiply, ask for the value. Each call renders in the sequence diagram, so you can see the message going to the child and the response coming back. Then start a second calculator with its own process id and drive both. Two servers, same code, separate state and a diagram showing exactly which one received what.

Now the GenServer

At this point we are back where Alvaro had been. The difference is that the boilerplate now has a shape. Starting a server, keeping it alive, reacting when messages arrive: common enough that a behaviour was created for it.

A GenServer starts something that runs indefinitely and reacts to events. You interact with it through cast and call, then handle those with handle_cast and handle_call. I did not labour the callbacks. The room had already seen the version where you write all of it yourself.

The visualisation is where it pays off. Self spawns something called stack, which is a name rather than a process id this time. Stack acknowledges that it is up. Then push, push, push, push, pop. Same picture as the hand-rolled calculator, with none of the loop code.

Looking inside a running process

sys.trace

Turn it on and every message is printed with the state that resulted. Push 900 and the state is 900. Push 800 and the state is 800, 900. Push 700, then 600. Call pop and 600 is gone.

sys.get_state

Ask a running GenServer what it is holding right now. It looks complicated. It returns the plain answer: the current state, no longer containing the 600.

Last demo, a supervisor starting three children: the stack GenServer, a task and an agent. The diagram shows all three under it. I left the strategy, the child specs and the rest of it alone, since supervision trees were the following week's meetup.

The questions, which were the useful part

Spawn, task or GenServer?

Spawn and you lose it. It is hard to keep track of, a minion sent into the wild. If you need it to report back or be watched, wrap it in a task, which you can supervise: you sit and watch how many you sent out, tell them what to do and when. GenServers are for long-running processes, which tasks are not meant for.

Where does a GenServer stop being the right answer?

Buffers with a million items to process. At that volume a GenServer is a poor fit and you want GenStage.

Do you write them often?

The host suggested maybe once every couple of months on a normal project, since Phoenix already contains plenty of GenServers written for you. I agree. Coming up with a genuine use case is not easy unless it is a new project and once a project has the GenServers it needs, it rarely needs more. They handle the recurrent and long-running middle ground. Past that you reach for something else.

The bottleneck I built myself

The one I volunteered without being asked, because it is the mistake worth passing on. Every web request ran its calculations through a GenServer before saving to the database. Everything had to enter the server, wait and then try to be concurrent again on the other side. I had put a queue in the middle of a concurrent system and watched performance drop, without connecting the two for a while.

Think your organisation has outgrown its systems?

Let's figure out what is actually broken.