Skip to main content

02 - BEAM and Elixir Processes: Massive Concurrency without Shared Threads

The BEAM runs millions of isolated lightweight processes: each has its own heap, communicates only by messages and shares no state. Spawn, messages, links, monitors and Tasks in Elixir.

What do you think about this article?

At a Glance

The BEAM runs millions of isolated lightweight processes: each has its own heap, communicates only by messages and shares no state. Spawn, messages, links, monitors and Tasks in Elixir.

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

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects