Isolated processes that write each other letters
In Elixir every piece of code runs inside a process, and processes cannot touch each other: each has its own memory and communicates with the others only by sending messages. The official Elixir guide says it in one line: processes are isolated from each other, run concurrently and communicate via message passing. The practical consequence is that you do not need locks to protect memory, because there is no shared memory to protect.
A comparison that holds up: an office where every employee has their own desk and a mailbox. Nobody goes into another person's desk; if you need something, you write a note and leave it in their mailbox. They read it when they have time, and can answer you with another note. The desk is the private memory of the process, the mailbox is its mailbox.
Beware of a promise you often read: "race conditions are not possible". Races on memory are impossible, because there is no shared memory. Races of logic remain possible: if two clients ask a counter process for the value, each computes "value plus one" and then sends it back as the new absolute value, an increment is lost, even though every single operation is perfectly isolated. The remedy is to have the operation done by the process that owns the state, as you will see in the counter below.
BEAM process and operating system thread
A BEAM process is not an operating system thread: it is scheduled by the virtual machine, which typically uses a certain number of system threads and distributes all the processes over them. The Erlang documentation defines processes as "lightweight", with a small memory footprint, fast creation and termination and low scheduling overhead. I do not report numerical comparisons with threads, nor context-switch times, because I did not measure them in this session and the documentation does not give them as figures.
One piece of data you can read in the documentation is the limit: in the current version of OTP (29.1.2, the
"System Limits" page consulted on 2026-11-12) the maximum number of simultaneously alive processes
is 1,048,576 by default, configurable at startup with the +P option. The value may be
different in your version: you read it with :erlang.system_info(:process_limit) in
iex, while :erlang.system_info(:process_count) tells you how many your
instance has right now.
Millions of processes are possible, but they are not free: every process has a memory and a mailbox, and a million idle processes still occupy memory.
spawn: creating a process
spawn/1 starts a function in a new process and immediately returns its
PID, the identifier with which you reach it. The parent process does not wait.
# Save as processi.exs and run with: elixir processi.exs
pid =
spawn(fn ->
IO.puts("child #{inspect(self())} started")
:timer.sleep(500)
IO.puts("child finished")
end)
IO.puts("parent #{inspect(self())} created #{inspect(pid)}")
IO.puts("child alive right after spawn? #{Process.alive?(pid)}")
:timer.sleep(700)
IO.puts("child alive after 700 ms? #{Process.alive?(pid)}")
The lines that matter are the first and the last. spawn(fn -> ... end) returns a PID before
the child has printed anything; Process.alive?(pid) right after should therefore
give true and after 700 ms false, because the child sleeps 500 ms and then its
function ends, and a process ends when its function returns.
The order between the child's line ("started") and the parent's ("created") is not guaranteed: they are two concurrent processes. I could not run this program, so these are predictions deduced from the documentation, not observed outputs.
Besides spawn/1 there is the form with module, function and arguments,
spawn(Module, :function, [args]), useful when the function is already a public name of a
module. The downside of plain spawn is that there is no link between parent and child: if
the child dies, the parent does not know. We come back to this in the section on links.
send and receive: the notes in the mailbox
send(pid, message) puts a message in the recipient's mailbox and returns immediately:
the sender does not block, as the Elixir guide specifies. receive looks at the incoming messages and
takes the first one that matches one of the patterns; if none matches, the process
waits. To avoid waiting forever you add an after clause with a timeout.
defmodule PingPong do
def start do
parent = self()
child = spawn(fn -> child_loop(parent) end)
send(child, {:ping, "hello"})
receive do
{:pong, message} -> IO.puts("parent received: #{message}")
after
5_000 -> IO.puts("timeout: no reply in 5 seconds")
end
send(child, :stop)
end
defp child_loop(parent_pid) do
receive do
{:ping, message} ->
IO.puts("child received: #{message}")
send(parent_pid, {:pong, "world"})
child_loop(parent_pid)
:stop ->
IO.puts("child is stopping")
end
end
end
PingPong.start()
:timer.sleep(100)
The parent creates the child passing it its own PID (parent = self(): inside the anonymous
function self() would be the child's PID, so it has to be captured outside), sends
{:ping, "hello"} and waits for the reply: the clause
after 5_000 fires after five seconds if it does not arrive. The child runs in
child_loop, which at the end of each message calls itself: that is how a process
stays alive and answers several messages.
The expected output is "child received: hello" followed by
"parent received: world", in this order guaranteed by the code itself (the child prints before
replying); then "child is stopping" after the :stop message. It is deduced, not observed.
A consequence to know: messages that match no pattern stay in the
mailbox, and occupy memory until the process dies. A process that receives messages it does not know how to
handle accumulates them, and receive has to scan them every time. The remedy is a final
clause that collects "everything else". Another useful guarantee, from the Erlang documentation: if a
process sends several messages to the same recipient, the order is preserved. Between different senders
there is no guarantee.
A process that holds a state
From here comes the most important pattern: a process as the owner of a piece of data. The state does not live in a modifiable variable but is the argument of the process's recursive function: each message produces a new value and the function calls itself with that. A counter is the smallest example.
defmodule Counter do
def start(initial_value \\ 0) do
spawn(fn -> loop(initial_value) end)
end
def add(pid, n), do: send(pid, {:add, n})
def stop(pid), do: send(pid, :stop)
def get(pid) do
send(pid, {:get, self()})
receive do
{:value, n} -> n
after
5_000 -> {:error, :timeout}
end
end
# The state is the function's argument: each message recurs with the new value
defp loop(count) do
receive do
{:add, n} ->
loop(count + n)
{:get, caller} ->
send(caller, {:value, count})
loop(count)
:stop ->
IO.puts("counter stopped at #{count}")
end
end
end
counter = Counter.start()
Counter.add(counter, 1)
Counter.add(counter, 1)
Counter.add(counter, 10)
IO.puts(Counter.get(counter))
Counter.stop(counter)
:timer.sleep(100)
loop(count) is the heart. On {:add, n} it calls itself with
count + n: the state has changed, without assigning anything. On {:get, caller}
it replies to the sender and calls itself with the same value. On :stop it does not call itself, the
function returns and the process dies. The client never touches the value: it sends messages, and
get closes them with a receive waiting for the reply.
The program adds
1+1+10 and should print 12, then "counter stopped at 12". The result is certain thanks to the ordering
guarantee cited above: the three add calls and the get start from the same
process towards the same recipient, and arrive in that order (outcome deduced, not observed).
The process handles one message at a time, and that is exactly what eliminates the races: two clients incrementing together are serialized by the mailbox. The flip side is a bottleneck: if all the traffic goes through a single process, its speed limits that of the whole, and if a message takes a long time the others wait.
A state read by many
and written rarely usually does not want a single process. In practice you do not write the loops by hand: you use
GenServer, which encapsulates exactly this scheme, as you will see in the
next article of the series.
When a process dies: link, monitor and trap_exit
A process can die from an error, and the others want to know. By default they do not. There are three tools, which answer different questions.
- No link (
spawn): the child crashes, the parent carries on unaware. Right when the work is truly independent. - Link (
spawn_link): the two processes are tied together bidirectionally, and if one dies from an error the exit signal hits the other too. It is the basis of OTP's "let it crash". - Monitor (
Process.monitor): you receive a{:DOWN, ...}message when the other dies, without risking dying yourself. Unidirectional.
The order of the items does not change the meaning, but the criterion does: do you want the fate of the two to be shared (link), one to observe the other (monitor), or no link at all. The link is the right tool when a process makes no sense without the other (the task and whoever awaits its result); the monitor when your process must decide what to do, for example retry. In almost all real programs you do not handle these signals by hand, but let a supervisor do it.
defmodule CrashDemo do
# The child crashes: the parent does not notice, apart from the error log
def senza_link do
child =
spawn(fn ->
:timer.sleep(100)
raise "crash in the child"
end)
:timer.sleep(500)
IO.puts("parent alive: #{Process.alive?(self())}, child alive: #{Process.alive?(child)}")
end
# With link and trap_exit the child's death arrives as a message
def con_trap_exit do
Process.flag(:trap_exit, true)
child =
spawn_link(fn ->
:timer.sleep(100)
raise "crash in the child"
end)
receive do
{:EXIT, ^child, reason} ->
IO.puts("the parent received an exit, reason: #{inspect(reason)}")
after
1_000 -> IO.puts("no exit received")
end
end
end
CrashDemo.senza_link()
CrashDemo.con_trap_exit()
The block shows the two extreme behaviors without killing the program. In
senza_link the child sleeps 100 ms and raises an error; the parent waits 500 ms and
prints that it is alive and the child is not (prediction deduced: the error appears in the execution log
but does not touch the parent, as the corresponding example in the official guide shows). In
con_trap_exit the parent sets Process.flag(:trap_exit, true) and so, instead of
dying together with the child, it receives a {:EXIT, pid, reason} message.
The reason for a
raised error is, according to the Erlang documentation, a tuple with the exception and the stack. The pin
^child in receive forces the comparison with that precise PID instead of
binding a new name. If you removed trap_exit, the error in the child would kill the
parent: I did not try it here, but that is exactly why the link exists.
Monitor: watching without being dragged along
With Process.monitor(pid) you get a reference and, when that process ends, a
{:DOWN, ref, :process, pid, reason} message. If the process was already dead, the
message arrives anyway with reason :noproc, as the Process documentation
shows.
worker =
spawn(fn ->
:timer.sleep(300)
IO.puts("worker finished")
end)
ref = Process.monitor(worker)
receive do
{:DOWN, ^ref, :process, ^worker, reason} ->
IO.puts("worker terminated, reason: #{inspect(reason)}")
after
5_000 ->
Process.demonitor(ref)
IO.puts("worker still alive after 5 seconds")
end
The receive pattern uses the ^ symbol twice to fix the expected reference
and PID, so as not to confuse this notification with that of another monitor. Two lines are
expected, in the order "worker finished" and then "worker terminated, reason: :normal": the
second event can happen only after the first (deduced, not observed).
The
after clause with Process.demonitor(ref) is there to clean up the monitor if you stop
waiting. One limit: if the dead process leaves a job half done, nobody repairs it for you; the
monitor warns you, the decision stays yours.
Task: parallelism without writing receive
For the "launch N jobs, wait for the results" case the Task module saves you from writing
send and receive by hand. Task.async creates a process that
runs the function; Task.await collects the reply.
defmodule ParallelFetcher do
def fetch_all(urls) do
urls
|> Enum.map(fn url -> Task.async(fn -> fetch(url) end) end)
|> Enum.map(fn task -> Task.await(task, 5_000) end)
end
# Fake network call: lasts between 100 and 500 ms
defp fetch(url) do
:timer.sleep(Enum.random(100..500))
{:ok, "response from #{url}"}
end
end
urls = ["https://a.example", "https://b.example", "https://c.example"]
{micros, results} = :timer.tc(fn -> ParallelFetcher.fetch_all(urls) end)
IO.inspect(results)
IO.puts("total time: #{div(micros, 1000)} ms")
The crucial point is the shape of the code: first all the Task.async calls (in the first
Enum.map), then all the Task.await calls. Had I alternated async and await, the
jobs would have started one at a time. With three random waits between 100 and 500 ms, the expected total time
is close to the longest of the three, not to the sum (the "total time" print measures this; I did not
observe it, so I do not write a number).
The Task documentation specifies that a
task created with async is linked and monitored by the caller: if the caller dies, the task
dies too, and vice versa, and that you must always await because the reply arrives
in your mailbox anyway.
When the jobs are many, launching them all together is wrong: you saturate the CPU, the network or the database.
Task.async_stream runs them with a maximum concurrency.
defmodule Batch do
def run(items, max_concurrency \\ 4) do
items
|> Task.async_stream(&slow_square/1,
max_concurrency: max_concurrency,
timeout: 1_000,
on_timeout: :kill_task
)
|> Enum.map(fn
{:ok, value} -> value
{:exit, reason} -> {:failed, reason}
end)
end
defp slow_square(n) do
:timer.sleep(100)
n * n
end
end
IO.inspect(Batch.run(1..8))
max_concurrency: 4 means at most four processes together; timeout:
1_000 is the maximum time per element, and on_timeout: :kill_task says to
kill the expired task and carry on, instead of bringing down the whole operation. The elements that
fail come back as {:exit, reason} and the code turns them into {:failed,
reason}. The expected output for 1..8 is [1, 4, 9, 16, 25, 36, 49, 64]: by
default the results come out in input order (the documentation lists the
ordered option).
For a job that produces no result there is Task.start:
it starts and you wait for nothing; the guide recommends, for tasks that matter, putting them in a
supervisor to see and control them.
Giving a process a name
Passing the PID around everywhere is inconvenient, and a PID changes if the process is restarted. With
Process.register/2 you assign the process a name (an atom) and you can send it
messages using the name.
# Append at the end of the counter script
pid = Counter.start(0)
Process.register(pid, :main_counter)
send(:main_counter, {:add, 5})
IO.puts(Counter.get(Process.whereis(:main_counter)))
IO.inspect(Process.whereis(:senza_nome))
This block reuses the Counter module from the previous listing. After registration,
send(:main_counter, ...) works without knowing the PID, and
Process.whereis/1 retrieves it from the name (it returns nil if the name does not exist,
as the last line does). You expect to see 5 and then nil, outcome deduced. The documentation specifies two constraints: the name is valid on the local node, not on a whole
cluster, and register/2 fails with ArgumentError if the name is already in use, if
the process does not exist or if it is already registered under another name.
Atoms, moreover, have a limit by default (1,048,576 according to the OTP 29.1.2 "System Limits" page): do not generate names from user data.
When NOT to create a process
A process is not a way of organizing code: for that there are modules and functions. Create a process when you need one of three things: to run something concurrently, to hold a state that several parts must use, or to isolate a failure so that it does not drag the others down.
A function that computes a value does not need a process, and turning it into one adds messages, timeouts and a new way to fail. When in doubt start with a normal function call, and move to a process when you measure or when you need one of those three things.
What you can do now
You can create a process with spawn, make two processes communicate with
send/receive avoiding a mailbox that fills up, keep a state inside
a recursive loop, choose between link, monitor and trap_exit depending on who must know what,
launch jobs in parallel with Task and limit their concurrency. Remember that the outputs
above are deduced, not observed: the Elixir compiler was not available where I wrote the
article, so run the listings before trusting them.
For the syntax and pattern matching that is taken for granted here, there is the first article of the series; the next step, OTP and GenServer, turns the recursive counter into a standard component with state, synchronous calls and automatic restart. For the comparison with other concurrency models (threads, event loop, goroutines) read the first article of the Concurrency series.
Sources
- Elixir - Processes (guide) - process isolation, non-blocking send, receive and mailbox, link and Task, consulted on 2026-11-12
- Erlang Reference Manual - Processes - lightweight processes, signal ordering guarantee between two processes, exit reason {Reason, Stack}, consulted on 2026-11-12
- Erlang System Limits - default process limit (1,048,576 in the OTP 29.1.2 documentation), +P option, consulted on 2026-11-12
- Elixir - Task - linked and monitored async/await, async_stream and its options, consulted on 2026-11-12
- Elixir - Process - register/2 on the local node and the ArgumentError cases, whereis/1, monitor with :noproc, consulted on 2026-11-12
They are all official Elixir and Erlang/OTP documentation, consulted at the points indicated. No program in the article was run, and the outputs are deduced from these pages.
Commenti
Caricamento commenti...
Sign in to comment