Erlang/OTP-style actor concurrency for Clojure using JVM virtual threads (Project Loom).
loom-otp provides processes, message passing, links, monitors, gen_server, and supervisors. Unlike otplike which uses core.async, loom-otp uses virtual threads where blocking is cheap and natural.
Key differences from otplike:
receive! (always blocking) instead of receive!/receive!!async/await! - just block directlyspawn-link (no explicit link/unlink API)selective-receive!┌─────────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER │
│ supervisor - Supervision trees, restart strategies │
├─────────────────────────────────────────────────────────────────┤
│ BEHAVIOR LAYER │
│ gen-server - Generic server pattern │
│ timer - Delayed/periodic operations │
├─────────────────────────────────────────────────────────────────┤
│ CORE PROCESS LAYER │
│ process - Spawning, messaging, monitors, receive │
│ process.spawn - Process lifecycle (control + user threads) │
│ process.link - Bidirectional links (stored per-process) │
│ process.monitor - Unidirectional monitors │
│ process.exit - Exit handling │
├─────────────────────────────────────────────────────────────────┤
│ FOUNDATION LAYER │
│ mailbox - Message queue with watch-based notification │
│ context-mailbox - [ctx msg] pairs for distributed tracing │
│ registry - Process registration by name │
│ state - Global state management (mount.lite) │
│ types - Pid, TRef wrapper types │
│ trace - Event tracing infrastructure │
└─────────────────────────────────────────────────────────────────┘
Each process has two virtual threads:
┌─────────────────── Process ───────────────────┐
│ │
│ ┌─────────────────┐ ┌───────────────────┐ │
│ │ Control Thread │ │ User Thread │ │
│ │ │ │ │ │
│ │ • Sets up links │ │ • Runs user code │ │
│ │ • Starts user │ │ • Calls receive! │ │
│ │ • Handles exits │ │ • Pattern matches │ │
│ │ • Runs cleanup │ │ │ │
│ │ │ │ Notifies control │ │
│ │ Orchestrates │ │ on termination │ │
│ │ lifecycle │ │ │ │
│ └─────────────────┘ └───────────────────┘ │
│ │
│ Shared: mailbox, control, flags, exit-reason │
└───────────────────────────────────────────────┘
spawn-process (caller's thread):
1. Create process with promises for coordination
2. Add to process table, register name
3. Start control thread (virtual)
4. Wait for :spawned promise
5. Return pid
control-thread:
1. Deliver :control-thread promise
2. Set up link if requested (may fail with :noproc)
3. Start user thread (virtual)
4. Wait for :user-thread promise
5. Deliver :spawned promise
6. Loop: handle signals via cmb/receive!
- [:exit from reason] → handle-exit-signal!, maybe interrupt user
- [:user-terminated-return value] → set exit-reason
- [:user-terminated-throw exception] → set exit-reason
- [:user-terminated-interrupted] → exit-reason already set
7. Cleanup: notify links/monitors, unregister, remove from table
user-thread:
1. Deliver :user-thread promise
2. Run user function
3. Send termination signal to control thread
{:pid Pid ; Unique identifier
:mailbox Atom<Queue> ; User messages [ctx msg]
:control Atom<Queue> ; Control signals [ctx signal]
:exit-reason Promise ; Delivered on exit (write-once)
:message-context Atom<Map> ; Context for distributed tracing
:last-control-ctx Atom<Map> ; Context from last exit signal
:flags Atom<{:trap-exit bool}>
:links Atom<#{pid-ids}> ; Linked process ids
:user-thread Promise<Thread> ; For interrupt
:control-thread Promise<Thread>
:spawned Promise<bool>} ; Coordination
| Scenario | Exit Reason |
|---|---|
User function returns value | [:normal value] |
User calls (exit/exit reason) | reason |
| User throws exception | [:exception (Throwable->map e)] |
| spawn-link to non-existent process | :noproc |
| Exit signal with reason R (not trapped) | R |
Exit signal with :kill | :killed |
Normal exits are [:normal value] or bare :normal. The normal-exit-reason? helper checks both forms.
Bidirectional relationships for fault propagation. Stored in each process's :links atom.
Key properties:
spawn-link or spawn-opt {:link true}link/unlink public API:linksExit signal handling:
| Receiver's trap-exit | Reason = :normal | Reason = :kill | Reason = other |
|---|---|---|---|
| false | Ignored | Dies with :killed | Dies with reason |
| true | Receives [:EXIT pid reason] | Dies with :killed | Receives [:EXIT pid reason] |
Unidirectional observation without coupling.
Key properties:
:DOWN message (no trap flag needed):noproc)Message format:
[:DOWN ref :process target reason]
send)(send dest message) ; Returns true if delivered, false if process not found
Messages are wrapped with sender's context for distributed tracing.
receive!)Pattern-matching receive with optional timeout:
(receive!
[:hello name] (println "Hello" name)
[:exit reason] (handle-exit reason)
(after 1000 :timeout))
selective-receive!)Scans mailbox for first matching message:
(selective-receive!
[:priority msg] (handle-priority msg)
(after 500 :no-priority))
Uses mount.lite for lifecycle management.
(require '[mount.lite :as mount])
(mount/start) ; Initialize system
;; ... use processes ...
(mount/stop) ; Terminate all processes
{:processes Atom<{pid-id -> process-map}>
:monitors Atom<{ref-id -> monitor-info}>
:registry Atom<{:forward {name -> pid}, :reverse {pid-id -> name}}>
:trace-fn Atom<handler-fn>
:pid-counter AtomicLong
:ref-counter AtomicLong}
Note: Links are stored per-process, not globally.
(use-fixtures :each
(fn [f]
(mount/start)
(try (f) (finally (mount/stop)))))
| Function | Description |
|---|---|
spawn, spawn-link, spawn-opt | Create processes |
spawn!, spawn-link!, spawn-opt! | Macro versions (wrap body in fn) |
spawn-trap, spawn-trap! | Spawn with trap-exit enabled |
self | Current process pid |
send | Send message |
exit | Exit self or send exit signal to another |
monitor, demonitor | Manage monitors |
register | Register name for process |
alive?, processes, process-info | Introspection |
receive!, selective-receive! | Receive messages (see process.match) |
| Macro | Description |
|---|---|
receive! | Pattern-matching receive with optional timeout |
selective-receive! | Scan mailbox for matching message |
| Function | Description |
|---|---|
start, start-link | Start server |
call, cast | Send requests |
reply | Explicit reply from handle-call |
stop | Stop server |
| Function | Description |
|---|---|
start-link | Start supervisor |
start-child | Add child dynamically |
terminate-child | Stop a child |
which-children | List children |
| Function | Description |
|---|---|
send-after | Send message after delay |
exit-after, kill-after | Send exit signal after delay |
apply-after | Call function after delay |
send-interval, apply-interval | Periodic operations |
cancel | Cancel timer |
read-timer | Get remaining time |
| Function | Description |
|---|---|
trace | Set trace handler |
untrace | Remove trace handler |
(trace/trace (fn [event]
(println (:event event) (:pid event))))
;; Events: :spawn, :exit, :exit-signal, :send
(require '[loom-otp.types :as t])
(t/pid? x) ; Check if Pid
(t/ref? x) ; Check if TRef
(t/->pid n) ; Create Pid from number
(t/->ref n) ; Create TRef from number
(t/pid->id pid) ; Extract number from Pid (or return number if already)
org.clojure/core.match - Pattern matching in receivefunctionalbytes/mount-lite - State lifecycle managementCan you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |