How to synchronize the asynchronous within a GenServer
Notes
Since Elixir’s processes communicate via message-passing, most communication happens asynchronously.
Sometimes we want to synchronize operations. How can we do that?
The basic way to do that is to send a message and immediately wait for a reply
(in a receive
block). But what can we do when we’re using a GenServer? In
those cases, we shouldn’t use a receive
block – we rely on handle_info/2
instead.
Setting the stage
Suppose you have an asynchronous process that your app uses. In this case, let’s pretend it’s a websocket library. Here’s a sample code that acts asynchronously (but it’s most definitely not a websocket 😃):
defmodule WebSocket do
use GenServer
def start_link(caller) do
GenServer.start_link(__MODULE__, caller)
end
def send_message(pid, msg) do
GenServer.cast(pid, {:send_message, msg})
end
def init(caller) do
{:ok, %{caller: caller}}
end
def handle_cast({:send_message, msg}, state) do
response = "#{msg} to you too!"
send(state.caller, {:message_received, response})
{:noreply, state}
end
end
Now, suppose your application wraps that service with a GenServer to manage the
connection. Let’s say this is your GenServer (which I call Coordinator
due to
lack of creativity):
defmodule Coordinator do
use GenServer
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts)
end
def greet(pid, msg) do
GenServer.call(pid, {:greet, msg})
end
def init(_opts) do
{:ok, pid} = WebSocket.start_link(self())
{:ok, %{async_pid: pid}}
end
def handle_call({:greet, msg}, from, state) do
:ok = WebSocket.send_message(state.async_pid, msg)
{:reply, :ok, state}
end
def handle_info({:message_received, msg}, state) do
# How do we send the message to the caller?
dbg(msg)
{:noreply, state}
end
end
Our goal
What we want is for the rest of our code to be able to use the
Coordinator.greet/2
function in a way that seems synchronous.
This is our desired usage:
response = Coordiantor.greet(pid, "Hello world")
# => "Hello world to you too!"
The problem
Currently, our Coordinator.greet/2
function just returns :ok
. And later (in
an async fashion), our handle_info/2
callback gets called with the websocket
response. At that point, we’ve already replied to the caller of greet/2
.
So, how can we make our GenServer’s API seem synchronous?
The solution
Enter GenServer.reply/2
.
We can store the from
value from our handle_call/3
callback in our state,
and change our reply tuple from a :reply
3-tuple, to a :noreply
2-tuple.
defmodule Coordinator do
# other code
def handle_call({:greet, msg}, from, state) do
:ok = WebSocket.send_message(state.async_pid, msg)
{:noreply, Map.put(state, :caller, from)}
end
end
Then, in our handle_info/2
callback, when we receive the response message, we
can reply to the caller with GenServer.reply/2
:
defmodule Coordinator do
# other code
def handle_call({:greet, msg}, from, state) do
:ok = WebSocket.send_message(state.async_pid, msg)
{:noreply, Map.put(state, :caller, from)}
end
def handle_info({:message_received, msg}, state) do
GenServer.reply(state.caller, msg)
{:noreply, state}
end
end
Now, the caller of Coordinator.greet/2
will receive the response message as
the return value of greet/2
(like a regular function call), and it knows
nothing (nor should it care) about the asynchronous process required to send the
message and receive the response.