Station 04Open the adventure map
OTP rules for messages
GenServer organizes receiving, replying, and system messages into rules everyone can recognize.
After replacing a hand-written loop with GenServer, how should call and cast be divided, and how can slow work be kept from filling the mailbox?
Senders are fast, but the service gets slower and slower
Callers keep casting while the service performs slow I/O inside a callback. Sending never waits, the mailbox grows, and later synchronous queries begin to time out.
- Mailbox length over time
- Call latency and timeout count
- Input rate, completion rate, and busy rejection count
The problem it solves
Rewriting startup, system messages, and replies every time makes details easy to miss. An OTP behaviour supplies a common pattern. We still decide the API, state owner, and overload policy.
You will be able to
Separate the public API used by callers from callbacks that handle messages inside the process
Choose call or cast by asking whether a result is required and what happens when the service is too busy
Put a long-running GenServer or gen_server under a supervision tree
- Finish the messages and mailboxes station and write one message loop by hand
- Know the jobs of requests, replies, references, and timeouts
See what it does before how it is written
The API hides message shapes, and callbacks follow the contract. Both languages return tuples with the same meaning.
# GenServer owns this map and updates it in order
defmodule KV do
use GenServer
# These functions are the public API seen by callers
def start_link(opts), do: GenServer.start_link(__MODULE__, %{}, opts)
def get(server, key), do: GenServer.call(server, {:get, key})
def put(server, key, value), do: GenServer.call(server, {:put, key, value})
# Callbacks handle the real messages and state
@impl true
def init(state), do: {:ok, state}
@impl true
def handle_call({:get, key}, _from, state),
do: {:reply, Map.fetch(state, key), state}
def handle_call({:put, key, value}, _from, state),
do: {:reply, :ok, Map.put(state, key, value)}
endWhy this shape?
Why replace a hand-written loop with GenServer/gen_server?
OTP standardizes startup, calls, casts, and system messages so supervisors and tools recognize the service.
The framework does not create capacity limits or make a slow callback fast.
Coming from Java, Python, or JavaScript
Java
- Familiar starting point
- An interface plus a service object.
- What BEAM changes
- A behaviour defines callbacks; GenServer adds a process protocol.
- False friend
- One GenServer runs callbacks serially. It is not an automatic thread pool.
Python
- Familiar starting point
- An ABC/Protocol plus a long-running task.
- What BEAM changes
- The public API and callback layer are intentionally separate, with one process owning state.
- False friend
- A cast skips a reply; it does not remove queue pressure.
JavaScript
- Familiar starting point
- A class, event emitter, or async handler.
- What BEAM changes
- Calls, casts, and system messages share one process protocol.
- False friend
- A slow callback delays the messages behind it.
Sending fast is not finishing fast
Send updates quickly with cast, then watch the mailbox and the delay of a synchronous query.
- 01
Add
put_async/3using cast - 02
Send a large batch of updates and record when sending ends and when processing ends
- 03
Make a synchronous
getat the same time and record its wait andmessage_queue_len
# Read the pid's mailbox length and see whether casts are piling up
:erlang.process_info(pid, :message_queue_len)- The sending loop can finish quickly even while the server still has work left
- A synchronous
getmay wait behind many casts and take much longer
Add slow I/O inside handle_cast and send even faster. See whether the mailbox keeps growing.
An asynchronous API lets the caller leave without waiting, but the wait may simply move into the server mailbox.
A local experiment cannot set the limit for a real service. Hardware, message size, and outside I/O all change the result.
Key ideas in this code
behaviour
A contract made of callbacks. A module implements the required callbacks while the framework handles the message loop, system messages, and debugging support.
call
A synchronous request that needs a reply. The caller waits, but waiting alone is not a complete backpressure plan.
cast
An asynchronous message with no reply. When senders are too fast, casts pile up in the mailbox.
Design patterns used here
Template Method
Let the behaviour fix the lifecycle and callback skeleton while business code fills the required steps.
Facade
Hide raw message shapes behind a clear client API.
Bounded Buffer
Cap pending work and return busy explicitly when capacity is full.
Which situation is the best reason to consider cast?
Build a bounded task service
Write the API in one language and the worker in the other. Run at most N tasks, and return busy when the bounded queue is full.
Hint 1Take the first step
Let one process own the queue and capacity, but give each real task to its own process.
Hint 2Make it a little smaller
Use a monitor to notice both worker completion and abnormal exits, and always return capacity.
Hint 3You are close
Do not quietly accept unlimited tasks. Tell callers when the service is full.
Hint 4Work backward from the finish
Choose one success signal and write the smallest test for it. If the computer cannot show the result, rewrite the signal as something you can truly observe.
The concurrency limit and waiting-queue limit are both visible
The capacity count returns to the correct value even after a worker crashes
A caller clearly learns whether a task was accepted or rejected as busy
Remember three things
- 1
A behaviour supplies reliable message-loop rules, but the business agreement is still ours to design.
- 2
A friendly client API hides internal message shapes so callers can state their purpose.
- 3
A cast lets the sender leave, but may leave pressure in the mailbox. Capacity and overload handling must be designed clearly.