Station 06Open the adventure map
Put a limit on concurrency
Use a plain function for plain calculation. When work needs concurrency, draw a line around the task count.
When ten thousand URL checks arrive in a burst, how do we limit running and waiting work while returning timeout, failure, or busy clearly?
Unlimited Tasks consumed every connection while the mailbox kept growing
The system starts a Task for every URL immediately. Connections and memory are soon exhausted, but requests keep queuing invisibly and callers can only wait for timeout.
- Peak active Task and connection counts
- Queue or mailbox length together with P95 latency
- Separate counts for timeout, execution failure, and capacity rejection
The problem it solves
Processes are good for owning state, expressing a lifetime, and isolating errors. Plain calculations do not all need GenServer. Unlimited Tasks can exhaust connections and memory. Choose tools by state and capacity.
You will be able to
Choose a plain function, process state, or ETS by asking whether state is long-lived and who must read or write it
Limit the number of running tasks and handle timeouts and a waiting queue
Use mailbox length, wait time, and rejection rate to notice when a system cannot keep up
- Know how a GenServer owns state and how a supervision tree watches processes
- Run at least one small independent task with Task
See what it does before how it is written
Elixir sets the concurrency limit directly. The Erlang outline shows monitors, a running set, and a waiting queue.
# Check URLs concurrently without passing the chosen limit
urls
|> Task.async_stream(
&check_url/1,
# Set the concurrency limit from the scheduler count
max_concurrency: System.schedulers_online() * 2,
timeout: 3_000,
on_timeout: :kill_task,
ordered: false
)
# Gather each result into success and failure counts
|> Enum.reduce(%{ok: 0, error: 0}, &count_result/2)Why this shape?
Why bound both running work and waiting work?
A concurrency limit protects active resources. A queue limit stops waiting work from quietly consuming all memory. At capacity, the service must admit it is busy.
async_stream limits running tasks; it is not a complete durable bounded queue. Admission still needs its own design.
Coming from Java, Python, or JavaScript
Java
- Familiar starting point
- Bounded executors, Semaphore, and BlockingQueue.
- What BEAM changes
- BEAM still needs limits for workers, mailboxes, or waiting queues.
- False friend
- Cheap virtual threads do not create infinite database connections.
Python
- Familiar starting point
- asyncio.Semaphore, Queue, and worker pools.
- What BEAM changes
- Messages are cheap, but a mailbox has no natural capacity limit.
- False friend
- Coroutine count and external-resource capacity are different numbers.
JavaScript
- Familiar starting point
- Promise batches, p-limit, or stream backpressure.
- What BEAM changes
- spawn or cast lets the caller move on but may transfer waiting into a mailbox.
- False friend
- Asynchronous is not the same as bounded.
Does one plus one need a GenServer?
Run the same independent calculations two ways: calls to one GenServer and direct function calls. Compare time and mailbox length.
- 01
Build a Calculator GenServer that does only a small CPU calculation and owns no long-lived state
- 02
Call the one server from many Tasks and record total time and mailbox length
- 03
Change the calculation to a plain function and measure the same inputs again
# Run the workload and return elapsed microseconds with its result
:timer.tc(fn -> workload.() end)- One server makes otherwise independent calculations wait in one line
- Plain functions can run inside each caller, making it easier for several schedulers to work together
Set max_concurrency to the full input count and make every task hold one connection. Record the resource peak.
Independent calculations with no shared state usually do not need to squeeze through one process.
This does not mean every GenServer is slow. Its main jobs are state, lifetime, and message agreements.
Key ideas in this code
Backpressure
A signal sent upstream when downstream has too little capacity. Upstream can wait, slow down, be rejected, or enter a bounded queue.
ETS
A BEAM table that many processes can read and write quickly. It still has an owner, and by default the table disappears when that owner exits.
Bounded concurrency
A fixed upper limit on running tasks, making CPU, connection, and memory use easier to control.
Design patterns used here
Bulkhead
Isolate and cap concurrent workers and external connections.
Bounded Buffer
Limit the waiting queue instead of letting work pile up invisibly.
Backpressure
Send capacity information upstream through waiting, slowing, or rejection.
Which job least needs a GenServer?
Build a bounded URL checker
Check a group of URLs while limiting concurrency, timeouts, and waiting items. Report success rate, P95 duration, and rejection count.
Hint 1Take the first step
Write down the largest number of running and waiting tasks before choosing an API.
Hint 2Make it a little smaller
Count “waited too long” separately from an HTTP error. They are not the same failure.
Hint 3You are close
ordered: false lets finished tasks return results without waiting for the slowest earlier task.
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.
Maximum concurrency and maximum waiting count can both be changed
A sudden input spike cannot grow memory forever through an unlimited queue
Results separate ordinary failure, timeout, and rejection because the service was busy
Remember three things
- 1
A process expresses who owns state, how long it lives, and where an error should stop.
- 2
Asynchronous means the caller need not wait here. It does not mean the task count is bounded.
- 3
Backpressure makes “too busy” visible and controls it through waiting, slowing, rejection, or degraded service.