Lesson 13: Building Your First GenServer
Design and build chat_registry, the gen_server that tracks which users are online and cleans up after processes that die
chat_stats from lesson 12 showed the mechanics of gen_server. Now weβll design a server the chat system depends on. Every connected user will be a process, and other parts of the system need to find those processes by username. chat_registry is the process that knows who is online.
Design First
Before writing callbacks, answer three questions. What does the server own, what can callers ask, and what can go wrong?
What it owns. A mapping from username to the pid of that userβs connection process. Usernames are binaries such as <<"alice">>, because they will arrive over the network as bytes.
What callers can ask.
| Function | Returns |
|---|---|
start_link() |
{ok, Pid} |
register_user(Username, Pid) |
ok or {error, username_taken} |
unregister_user(Username) |
ok |
lookup(Username) |
{ok, Pid} or {error, not_found} |
list_users() |
a list of usernames |
What can go wrong. A user process can die without calling unregister_user/1. The browser tab closes, the network drops, or the process crashes. If the registry keeps the dead pid, the name stays taken forever. Asking every user process to clean up after itself cannot work, because a killed process runs no cleanup code.
Lesson 10 had the tool for this. The registry monitors every pid it registers. When a user process dies for any reason, the registry receives a 'DOWN' message and removes the entry itself.
That decides the state. Each username maps to a tuple of pid and monitor reference. We keep the reference so we can cancel the monitor when a user unregisters.
#{<<"alice">> => {<0.180.0>, #Ref<0.3223873074.1202978824.195053>}, <<"bob">> => {<0.182.0>, #Ref<0.3223873074.1202978824.195064>}}The Implementation
Create src/chat_registry.erl:
-module(chat_registry).-behaviour(gen_server).
%% Client API-export([start_link/0, register_user/2, unregister_user/1, lookup/1, list_users/0]).
%% gen_server callbacks-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
%%% Client API
-spec start_link() -> {ok, pid()} | {error, term()}.start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
-spec register_user(binary(), pid()) -> ok | {error, username_taken}.register_user(Username, Pid) -> gen_server:call(?MODULE, {register, Username, Pid}).
-spec unregister_user(binary()) -> ok.unregister_user(Username) -> gen_server:call(?MODULE, {unregister, Username}).
-spec lookup(binary()) -> {ok, pid()} | {error, not_found}.lookup(Username) -> gen_server:call(?MODULE, {lookup, Username}).
-spec list_users() -> [binary()].list_users() -> gen_server:call(?MODULE, list_users).
%%% Callbacks
init([]) -> {ok, #{}}.
handle_call({register, Username, Pid}, _From, Users) -> case maps:is_key(Username, Users) of true -> {reply, {error, username_taken}, Users}; false -> Ref = monitor(process, Pid), {reply, ok, Users#{Username => {Pid, Ref}}} end;handle_call({unregister, Username}, _From, Users) -> case maps:take(Username, Users) of {{_Pid, Ref}, Rest} -> demonitor(Ref, [flush]), {reply, ok, Rest}; error -> {reply, ok, Users} end;handle_call({lookup, Username}, _From, Users) -> case maps:find(Username, Users) of {ok, {Pid, _Ref}} -> {reply, {ok, Pid}, Users}; error -> {reply, {error, not_found}, Users} end;handle_call(list_users, _From, Users) -> {reply, lists:sort(maps:keys(Users)), Users}.
handle_cast(_Msg, Users) -> {noreply, Users}.
handle_info({'DOWN', Ref, process, _Pid, _Reason}, Users) -> Remaining = maps:filter(fun(_Name, {_P, R}) -> R =/= Ref end, Users), {noreply, Remaining};handle_info(_Msg, Users) -> {noreply, Users}.Some details are worth a closer look.
Every operation is a call. register_user/2 has to be a call because the caller needs to know whether the name was free. unregister_user/1 could be a cast, but a call guarantees the name is free again when the function returns. A user who renames can unregister and register in two lines without a race.
monitor/2 runs inside handle_call/3. The process that calls monitor is the one that gets the 'DOWN' message. The callback runs in the registry process, so the registry receives it. Calling monitor in register_user/2 would monitor from the callerβs process instead.
maps:take/2 returns the value and the map without the key, or error when the key is missing. Unregistering an unknown name returns ok, so callers can do it without checking first. demonitor(Ref, [flush]) stops the monitor and removes a 'DOWN' message that may already be queued.
The 'DOWN' clause only has a reference, not a username. maps:filter/2 keeps every entry whose reference differs. This scans the whole map, which is fine for a few thousand users. Exercise 3 removes the scan.
-spec lines document argument and return types. The compiler does not enforce them, but the dialyzer tool (lesson 36) checks them against the code. handle_cast/2 is a required callback, so it gets a do-nothing clause.
Trying It Out
Run rebar3 shell in the erlang_chat directory. The application does not start the registry yet, so start it by hand. We have no connections, so a fake user is a process that waits for stop.
1> chat_registry:start_link().{ok,<0.177.0>}2> FakeUser = fun() -> spawn(fun() -> receive stop -> ok end end) end.#Fun<erl_eval.43.113135111>3> Alice = FakeUser().<0.180.0>4> Bob = FakeUser().<0.182.0>5> chat_registry:register_user(<<"alice">>, Alice).ok6> chat_registry:register_user(<<"bob">>, Bob).ok7> chat_registry:list_users().[<<"alice">>,<<"bob">>]8> chat_registry:lookup(<<"bob">>).{ok,<0.182.0>}9> chat_registry:lookup(<<"carol">>).{error,not_found}A second process that wants the name <<"alice">> is refused.
10> Impostor = FakeUser().<0.189.0>11> chat_registry:register_user(<<"alice">>, Impostor).{error,username_taken}Now Aliceβs process ends normally and Bobβs is killed. Neither unregisters.
12> Alice ! stop.stop13> chat_registry:list_users().[<<"bob">>]14> chat_registry:register_user(<<"alice">>, Impostor).ok15> exit(Bob, kill).true16> chat_registry:list_users().[<<"alice">>]Both entries disappeared without any cooperation from the dying processes, and the name <<"alice">> became free again. sys:get_state(chat_registry) prints the raw state map if you want to look inside.
Why There Are No Locks
Picture two people who register <<"alice">> at the same instant. With shared memory, two threads could both see that the name is free and both insert. You would need a lock around βcheck, then insertβ.
Here both requests land in the registryβs mailbox, one after the other. The server finishes the first handle_call/3, including the state update, before it looks at the second message. The second request sees the name as taken. A gen_server serializes all access to its state, so βcheck, then insertβ cannot be interrupted. No lock exists, and none can be forgotten.
The cost is the same property seen from the other side. One process handles one message at a time on one CPU core. Every lookup/1 from every connection waits in the same queue, and a slow callback delays everyone behind it. Two rules follow.
- Keep callbacks short. Never do slow work such as network calls inside a server that many processes depend on.
- Watch servers that sit on a hot path.
chat_registryhandles a request in a few microseconds, so it copes with a lot of traffic before it becomes the bottleneck.
Lesson 27 measures this server under load and moves the read path to an ETS table that many processes can read at once.
Exercises
Exercise 1: Count Users
Add count/0, which returns the number of registered users. Use maps:size/1.
% Expected:% chat_registry:count(). => 0% chat_registry:register_user(<<"alice">>, self()).% chat_registry:count(). => 1Exercise 2: Reverse Lookup
Add whois(Pid), which returns {ok, Username} or {error, not_found} for a pid.
% Expected:% chat_registry:register_user(<<"alice">>, self()).% chat_registry:whois(self()). => {ok,<<"alice">>}Exercise 3: Remove the Scan
Change the state to a tuple of two maps, {Users, Refs}, where Refs maps a monitor reference to a username. Update every callback so the 'DOWN' clause finds the username with maps:take/2 instead of maps:filter/2. The client API and the shell session above must behave exactly as before.
init([]) -> {ok, {#{}, #{}}}.Exercise 4: Reject Dead Pids
Registering a pid that is already dead works, because the 'DOWN' message arrives right after. Make register_user/2 return {error, not_alive} in that case instead. is_process_alive/1 is enough for local pids.
% Expected:% Dead = spawn(fun() -> ok end).% chat_registry:register_user(<<"ghost">>, Dead). => {error,not_alive}Key Takeaways
- Design the API and state first. Decide what the server owns, what callers can ask and what can go wrong before writing callbacks
- Monitor what you track. A server that stores pids should monitor them and clean up in
handle_info/2when'DOWN'arrives - Call
monitor/2in a callback so the server process is the one that receives the'DOWN'message - Keep the monitor reference in the state so you can
demonitor(Ref, [flush])on a manual unregister - Calls serialize access. A check followed by an update inside one callback cannot be interrupted, so no locks are needed
- A single process is a queue. Keep callbacks fast, because every caller waits behind the current message
-specdocuments types for readers and for dialyzer without changing how the code runs
Interactive Koans
Koans - Test Your Understanding
Fill in the blanks and press Enter or click Run to test your knowledge!
What does maps:take/2 return when the username is not registered?
π‘ Hint
π Explanation
Which function makes the registry receive a 'DOWN' message when the user process dies?
π‘ Hint
π Explanation
What error reason does lookup give for an unknown user?
π‘ Hint
π Explanation
Which option also removes a 'DOWN' message that is already in the mailbox?
π‘ Hint
π Explanation
What error reason does the second registration of the same name get?
π‘ Hint
π Explanation
What does the server reply with after the registered process has been killed?
π‘ Hint
π Explanation
Five processes try to register the same name at once. How many of them get ok?
π‘ Hint
π Explanation
Finished this lesson?
Mark it as complete to track your progress
This open source tutorial is brought to you by East Branch Software - we build reliable software systems.
Found an issue? Edit this page on GitHub or open an issue