This is an adaptation of a knowledge base article that I've written at The Internet of Behaviors Company.
At its heart, Elixir (and other BEAM languages) has only:
- processes (lightweight/green threads, not OS processes),
- functions, and
- data.
There are no language level "objects" like in object-oriented languages that tie these into a stateful combination. However, it has been argued that the things you can build on top of these primitives, bring you closer to the true ideal of object-oriented programming than languages like, say, Java. BEAM might even be the only true OO runtime! 😜
Anyway, you might have noticed that keeping state is quite different in Elixir compared to OO/other languages. You don't have instances of objects in which you can keep state. Sure there are data structures like structs, maps and lists that you can pass around, but where do they live? Nowhere. You just pass them around, transform them, and return the result. Of course, you can read and write data to and from external sources, such as a DB and that is what libraries like Ecto helps with. But is there no other way of keeping state in Elixir?
Yes there is. There are actually a couple of ways, some built into the runtime (like ETS tables), and others just arise naturally: functions can call themselves recursively, passing the result as the next input. This may sound like a recipe for a stack overflow, but Elixir eats this for breakfast.
The standard library comes with an abstraction around this recursion-based way of keeping state: GenServer
. You can get pretty far in Elixir without caring about GenServer
s and related concepts, but under the hood they are the foundation for everything from connections, to high throughput data processing pipelines, and even real-time server side rendered content like in Phoenix LiveView.
Understanding GenServer
s can be daunting to newcomers. You are told to implement a couple of callbacks, but the inner workings can feel like a mystery.
Let's change that by building a GenServer
from first principles!
Preliminaries
There are three operators that are fundamental to how GenServer
works:
-
spawn/3
- spawns a new process
spawn(MyModule, :my_function, [my, args]) # calls MyModule.myfunction(my, args) in a new process
-
send/2
- sends message to a target process, possibly itself (fire and forget style)
send(target_process_id, message)
-
receive/1
- blocks a process until it receives a message from another process (or itself)
receive do message -> do_something_with_the_message(message) end
A process that can receive messages
Here is a simple process that is able to receive an arbitrary number of messages. It is very rudimentary and doesn't (yet) keep any state. We'll build on this in iterations.
defmodule StatelessProcess do def loop do receive do message -> IO.puts("Received: #{message}") loop() # Keep listening for new messages end end end # Spawn a new process and make it call StatelessProcess.loop() pid = spawn(StatelessProcess, :loop, []) # Then send a message to the new process send(pid, "Hello, process!") # Output: Received: Hello, process!
The basic idea is that the loop/0
function calls itself recursively until infinity. On every execution, it waits for a message with receive/1
. receive/1
is blocking, so it keeps waiting for a message until it gets one before re-entering the loop.
Problem
We have no record of previous messages. We are not keeping state.
Adding state
Let's modify the process to keep track of a counter.
defmodule StatefulProcess do def loop(count) do receive do :increment -> IO.puts("Incrementing: #{count + 1}") loop(count + 1) # Update the state by passing it forward :get_value -> IO.puts("Current count: #{count}") loop(count) # State remains unchanged end end end pid = spawn(StatefulProcess, :loop, [0]) send(pid, :increment) # Output: Incrementing: 1 send(pid, :increment) # Output: Incrementing: 2 send(pid, :get_value) # Output: Current count: 2
Now loop/1
calls itself with an argument: the count. We've also modified the receive
block to wait until it receives very specific messages. It will wait indefinitely until it receives either an :increment
or a :get_value
. It will crash when receiving an unexpected message. Finally it re-enters the loop, possibly altering the counter.
We can now send messages to the process to alter its state and to interrogate its state.
Problem
We can make the process print its state, but we can't actually retrieve its state. There's no way to get a response back.
Allowing requests to return data
Let's teach the process to talk back.
defmodule SyncStatefulProcess do def loop(count) do receive do {:increment, sender} -> new_count = count + 1 send(sender, {:ok, new_count}) # Send response back loop(new_count) # Update state {:get_value, sender} -> send(sender, {:ok, count}) # Send state back loop(count) # State remains unchanged end end end pid = spawn(SyncStatefulProcess, :loop, [0]) send(pid, {:increment, self()}) # We pass our own PID to receive a response receive do {:ok, new_value} -> IO.puts("New count: #{new_value}") end send(pid, {:get_value, self()}) receive do {:ok, value} -> IO.puts("Current count: #{value}") end
In addition to sending data to the process, we also send along the process id (pid
) of the calling process, so that the callee process knows to whom to respond. We can now communicate with the process synchronously. That is, we are waiting for a response.
Problem
The process doesn't support asynchronous calls and it takes a lot of typing to call it.
Supporting asynchronous calls and removing boilerplate
defmodule MyGenServerLike do def start do spawn(MyGenServerLike, :loop, [0]) end def call(pid, request) do send(pid, {:call, self(), request}) receive do response -> {:ok, response} end end def cast(pid, request) do send(pid, {:cast, request}) :ok end def loop(state) do receive do {:call, from, request} -> {reply, new_state} = handle_call(request, state) send(from, {:response, reply}) loop(new_state) {:cast, request} -> new_state = handle_cast(request, state) loop(new_state) end end defp handle_call(:get_value, state), do: {state, state} defp handle_cast(:increment, state), do: state + 1 end pid = MyGenServerLike.start() :ok = MyGenServerLike.cast(pid, :increment) {:ok, count} = MyGenServerLike.call(pid, :get_value)
We can now interact very nicely with the process.
Problem
Among others, it is not reusable.
GenServer
Implementing the code above can get old very quickly. That is why Elixir comes with a prepackaged solution: GenServer
. We can get the same functionality as before with this:
defmodule Counter do use GenServer # --- Public Client API --- # These execute in the caller process def start_link(initial_value) do GenServer.start_link(__MODULE__, initial_value) end def increment(pid) do GenServer.cast(pid, :increment) end def get_value(pid) do GenServer.call(pid, :get_value) end # --- Server Callbacks --- # These execute in the callee process. So while # they are public, calling them directly will # be useless. def init(initial_value) do {:ok, initial_value} # Initial state end def handle_cast(:increment, state) do {:noreply, state + 1} end def handle_call(:get_value, _from, state) do {:reply, state, state} end end {:ok, pid} = Counter.start_link(0) :ok = Counter.increment(pid) value = Counter.get_value(pid)
The reusability lies in the use GenServer
line. It expands the GenServer.__using__
macro, which hides a ton of repetitive code like the loop function and much much more. You strictly only have to implement the server callbacks, but the start_link
, increment
, and get_value
functions just make it even nicer (hides the calling and casting) to interact with the counter.
Conclusion
Can you see how counter
(the process id) now almost behaves like an instance?
{:ok, counter} = Counter.start_link(0) Counter.increment(counter) value = Counter.get_value(counter)
Compare this to the following OO pseudocode:
counter = new Counter(0) counter.increment() value = counter.get_value()
One of the differences is that we have thread-safety out of the box in Elixir. No process can change the state of another process except through message passing. But in your day to day usage of Elixir you will barely be aware of all the message passing that happens under the hood.
Top comments (1)
Great way to introduce GenServers with step-by-step iterations! 👏