BEAM- en Elixir-processen: enorme gelijktijdigheid zonder gedeelde threads
De BEAM kan miljoenen geïsoleerde lichtgewichtprocessen uitvoeren: elk proces heeft de eigen heap, communiceert alleen via berichten en deelt de status niet. Ontdek waarom dit patroon elimineert raceomstandigheden door het ontwerp en de manier waarop dit zich vertaalt in echte Elixir-code.
Het BEAM-wedstrijdmodel
Gelijktijdigheid in traditionele talen is afhankelijk van threads van het besturingssysteem die de herinnering delen. Dit model leidt onvermijdelijk tot raceomstandigheden, impasses en bugs die moeilijk te reproduceren zijn. De taal moet mutexen, semaforen, lock — mechanismen voor het coördineren van gedeelde toegang.
BEAM heeft gekozen voor een radicaal andere aanpak: het Actor-model. De processen van BEAM zijn volledig geïsoleerde entiteiten die communiceren uitsluitend via asynchrone berichten. Er is geen gedeeld geheugen. Er zijn geen sloten. Raceomstandigheden zijn niet mogelijk door het ontwerp, niet door de discipline van de programmeur.
Wat je gaat leren
- Verschil tussen BEAM-processen en OS-threads
- spawn en spawn_link: creëer geïsoleerde processen
- verzenden en ontvangen: bericht dat tussen processen wordt doorgegeven
- Mailbox: de berichtenwachtrij van elk proces
- Procesmonitoring met Process.monitor
- Self() en pid: identiteit van een proces
- Taak: Abstractie op hoog niveau voor gelijktijdige spawn
BEAM-processen versus Thread OS
Een BEAM-proces is geen thread van een besturingssysteem. Het is een planner Elixir-niveau geïmplementeerd in de VM. Elk Erlang/Elixir-knooppunt gebruikt doorgaans één thread Besturingssysteem per CPU-kern, maar kan honderdduizenden (of miljoenen) processen plannen BEAM op die draden.
# Dimensioni a confronto (approssimative)
# Thread OS (Linux):
# - Stack: 8MB default
# - Context switch: ~1-10 microsecond (syscall kernel)
# - Max per processo: centinaia
# Processo BEAM:
# - Stack iniziale: ~300 parole (pochi KB, cresce dinamicamente)
# - Context switch: sub-microsecondo (nello spazio utente)
# - Max per nodo: milioni (default: 262.144, configurabile)
# Verificare i limiti:
iex> :erlang.system_info(:process_limit)
# 262144 (default, configurabile con +P flag)
iex> :erlang.system_info(:process_count)
# Numero di processi attualmente in esecuzione
# BEAM usa preemptive scheduling basato su "reduction counts"
# Ogni processo ha un budget di "reduction" (operazioni base)
# Quando esaurito, lo scheduler puo' passare a un altro processo
# Questo garantisce fairness anche con processi CPU-intensive
spawn: maak processen
# spawn: crea un processo che esegue una funzione anonima
pid = spawn(fn ->
IO.puts("Sono un processo! PID: #{inspect(self())}")
:timer.sleep(1000)
IO.puts("Fine processo")
end)
IO.puts("Processo padre, PID figlio: #{inspect(pid)}")
# Il padre continua senza aspettare il figlio
# Output (ordine non garantito):
# Processo padre, PID figlio: #PID<0.108.0>
# Sono un processo! PID: #PID<0.108.0>
# Fine processo
# spawn con modulo e funzione
defmodule Worker do
def run(task) do
IO.puts("Worker #{inspect(self())} processing: #{task}")
:timer.sleep(100)
{:done, task}
end
end
pid = spawn(Worker, :run, ["task-1"])
# self(): PID del processo corrente
IO.puts("Main process: #{inspect(self())}")
# Verifica se un processo e' ancora in vita
Process.alive?(pid) # true o false
verzenden en ontvangen: Bericht doorgeven
Processen worden gecommuniceerd via berichten die naar de mailbox worden verzonden. De brievenbus is
één FIFO-wachtrij per proces. receive extraheert het eerste bericht dat overeenkomt
een van de patronen: als er geen bericht overeenkomt, pauzeert het proces met wachten.
# Comunicazione padre-figlio tramite messaggi
defmodule PingPong do
def start do
parent = self()
# Crea processo figlio passando il PID del padre
child = spawn(fn -> child_loop(parent) end)
# Invia messaggio al figlio
send(child, {:ping, "hello"})
# Ricevi risposta
receive do
{:pong, message} ->
IO.puts("Parent received: #{message}")
{:error, reason} ->
IO.puts("Error: #{reason}")
after 5000 ->
IO.puts("Timeout: no response in 5 seconds")
end
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) # Ricomincia il ciclo
:stop ->
IO.puts("Child stopping")
# Il processo termina quando la funzione ritorna
end
end
end
PingPong.start()
# Child received: hello
# Parent received: world
# Pattern: processo server con stato via ricorsione
defmodule Counter do
def start(initial_value \\ 0) do
spawn(__MODULE__, :loop, [initial_value])
end
def increment(pid), do: send(pid, {:increment, 1})
def add(pid, n), do: send(pid, {:increment, n})
def get(pid) do
send(pid, {:get, self()})
receive do
{:value, n} -> n
after 5000 -> {:error, :timeout}
end
end
def stop(pid), do: send(pid, :stop)
# Loop interno: mantiene lo stato tramite ricorsione di coda
def loop(count) do
receive do
{:increment, n} ->
loop(count + n) # Ricorsione: nuovo stato
{:get, caller} ->
send(caller, {:value, count})
loop(count) # Stato invariato
:stop ->
IO.puts("Counter stopping at #{count}")
# Non ricorre: il processo termina
end
end
end
# Uso
counter = Counter.start(0)
Counter.increment(counter)
Counter.increment(counter)
Counter.add(counter, 10)
IO.puts(Counter.get(counter)) # 12
Counter.stop(counter)
spawn_link en crashpropagatie
spawn_link creëert een kindproces en brengt een link
bidirectioneel: als een van de twee processen crasht, wordt het uitgangssignaal doorgegeven
naar de ander. Dit is de basis van het ‘let it crash’-principe van OTP.
# spawn vs spawn_link
defmodule CrashDemo do
def demo_spawn do
# spawn: il crash del figlio NON impatta il padre
child = spawn(fn ->
:timer.sleep(100)
raise "Crash nel figlio!"
end)
IO.puts("Padre #{inspect(self())} vivo, figlio: #{inspect(child)}")
:timer.sleep(500)
IO.puts("Padre ancora vivo nonostante crash figlio")
# Process.alive?(child) == false (il figlio e' morto)
end
def demo_spawn_link do
parent = self()
# spawn_link: il crash del figlio PROPAGA al padre
# Il padre riceve un exit signal {:EXIT, child_pid, reason}
child = spawn_link(fn ->
:timer.sleep(100)
raise "Crash nel figlio con link!"
end)
IO.puts("Padre #{inspect(self())} vivo, figlio: #{inspect(child)}")
:timer.sleep(500)
# Se il padre non e' un Supervisor, anche lui crashera'
# Questo e' intenzionale: il fallimento si propaga verso l'alto
# finche' un Supervisor non lo gestisce
end
def demo_trap_exit do
# trap_exit: intercetta gli exit signal invece di crashare
Process.flag(:trap_exit, true)
child = spawn_link(fn ->
:timer.sleep(100)
raise "Crash!"
end)
receive do
{:EXIT, ^child, reason} ->
IO.puts("Figlio crashato: #{inspect(reason)}")
# Il padre puo' decidere come gestire il crash
end
end
end
Process.monitor: Eenrichtingsbewaking
# Process.monitor: ricevi notifica quando un processo muore
# (senza il link bidirezionale di spawn_link)
defmodule ProcessMonitor do
def watch(target_pid) do
ref = Process.monitor(target_pid)
IO.puts("Monitoring #{inspect(target_pid)}, ref: #{inspect(ref)}")
receive do
{:DOWN, ^ref, :process, ^target_pid, reason} ->
IO.puts("Process #{inspect(target_pid)} down: #{inspect(reason)}")
after 10_000 ->
IO.puts("Timeout: process still running after 10s")
Process.demonitor(ref)
end
end
end
# Crea un processo che si ferma dopo 500ms
worker = spawn(fn ->
:timer.sleep(500)
IO.puts("Worker done")
end)
# Monitora in background
spawn(fn -> ProcessMonitor.watch(worker) end)
# Attendi
:timer.sleep(1000)
# Output:
# Worker done
# Process #PID<...> down: :normal
Taak: Abstractie op hoog niveau
Het formulier Task biedt een handiger abstractie van spawn
voor algemene patronen: voer asynchrone bewerkingen uit en wacht op resultaten.
# Task.async e Task.await: parallelismo strutturato
defmodule ParallelFetcher do
def fetch_all(urls) do
urls
|> Enum.map(fn url ->
# Lancia un task per ogni URL in parallelo
Task.async(fn -> fetch(url) end)
end)
|> Enum.map(fn task ->
# Attende il risultato di ciascun task (max 5 secondi)
Task.await(task, 5_000)
end)
end
defp fetch(url) do
# Simula una chiamata HTTP
:timer.sleep(Enum.random(100..500))
{:ok, "Response from #{url}"}
end
end
# 3 richieste in parallelo: tempo totale ~max(latenze)
urls = ["https://api1.example.com", "https://api2.example.com", "https://api3.example.com"]
results = ParallelFetcher.fetch_all(urls)
# [{:ok, "Response from ..."}, ...]
# Task.async_stream: parallelo con concorrenza limitata
def process_items(items, max_concurrency \\ 10) do
items
|> Task.async_stream(
fn item -> expensive_operation(item) end,
max_concurrency: max_concurrency,
timeout: 10_000,
on_timeout: :kill_task,
)
|> Enum.map(fn
{:ok, result} -> result
{:exit, reason} ->
IO.puts("Task failed: #{inspect(reason)}")
nil
end)
|> Enum.reject(&is_nil/1)
end
# Task.start: fire-and-forget (non attendi il risultato)
Task.start(fn ->
send_notification_email(user) # Background job, non bloccante
end)
Procesregister: processen benoemen
# Process Registry: registra un processo con un nome atom
defmodule NamedCounter do
def start do
pid = spawn(__MODULE__, :loop, [0])
# Registra con nome globale (atom)
Process.register(pid, :main_counter)
pid
end
def increment do
# Invia a nome simbolico invece di PID
send(:main_counter, {:increment, 1})
end
def get do
send(:main_counter, {:get, self()})
receive do
{:value, n} -> n
after 1_000 -> nil
end
end
def loop(n) do
receive do
{:increment, delta} -> loop(n + delta)
{:get, caller} ->
send(caller, {:value, n})
loop(n)
end
end
end
NamedCounter.start()
NamedCounter.increment()
NamedCounter.increment()
IO.puts(NamedCounter.get()) # 2
# Trova il PID da un nome registrato
pid = Process.whereis(:main_counter)
IO.puts(inspect(pid)) # #PID<0.120.0>
Conclusies
Het actormodel van BEAM maakt Elixir uniek. Elk proces is een geïsoleerd universum met zijn eigen geheugen en mailbox. Het voortbrengen van miljoenen processen is goedkoop. Communicatie via berichten elimineert de noodzaak voor expliciete synchronisatie. Crashvoortplanting via link is het mechanisme waarop OTP fouttolerantie bouwt. Het volgende artikel onderzoekt GenServer, de fundamentele bouwsteen daarvoor brengt dit alles naar een hoger abstractieniveau.
Aankomende artikelen in de Elixir-serie
- Artikel 3: OTP en GenServer: gedistribueerde status met ingebouwde fouttolerantie
- Artikel 4: Supervisor Trees - Systemen ontwerpen die nooit sterven
- Artikel 5: Ecto - Composable Query en Schema Mapping voor PostgreSQL







