Rakka: Actor Model for Racket
| (require "main.rkt") |
Rakka brings Erlang/OTP-style actors to Racket 9. Build concurrent systems where isolated processes communicate through message passing, crashes are contained, and supervision trees automatically recover from failures.
For you if: You’re building concurrent Racket applications and want fault-tolerant, message-passing architecture instead of shared-state threading.
1 Quick Start
Get a working actor system in 30 seconds.
1.1 Your First Actor
#lang racket/base (require rakka racket/match) ;; Define an actor that echoes messages back (define (echo-actor) (let loop () (define msg (receive)) (match msg [(list 'echo payload from) (send! from (list 'reply payload)) (loop)] ['stop (void)] [_ (loop)]))) ;; Run it (define (main) (define echo (spawn echo-actor)) (send! echo (list 'echo "hello!" (self))) (define reply (receive)) (printf "Got: ~a\n" reply) (send! echo 'stop)) ;; Must run inside a process (spawn main)
Spawned an isolated process
Sent it a message
Received a reply
2 Understanding Actors
2.1 The Mental Model
Think of actors like people in separate rooms, communicating by passing notes.
Isolation: Each actor has its own memory. No shared state.
Messages: The only way to communicate. Drop a note in their mailbox.
Mailboxes: Each actor has one. Messages queue up, processed one at a time.
Failures stay local: If one actor crashes, others keep running.
No locks needed—
no shared state to protect No race conditions—
messages processed sequentially Crashes contained—
one bad actor doesn’t corrupt others
2.2 Spawning Processes
spawn creates a new actor running your function:
(define pid (spawn (lambda () (printf "I'm alive!\n") (sleep 1) (printf "Goodbye!\n")))) (printf "Spawned: ~a\n" pid) (printf "Alive? ~a\n" (pid-alive? pid)) (sleep 2) (printf "Still alive? ~a\n" (pid-alive? pid))
The spawned process runs in parallel. pid-alive? checks if it’s still running.
2.3 Sending and Receiving Messages
send! drops a message in an actor’s mailbox. It returns immediately—
(send! some-pid 'hello) (send! some-pid (list 'data 1 2 3)) (send! some-pid (hash 'key "value"))
receive blocks until a message arrives:
(define msg (receive)) ; blocks
receive/timeout adds a deadline (in milliseconds):
(define msg (receive/timeout 1000)) ; wait up to 1 second (if msg (printf "Got: ~a\n" msg) (printf "Timed out\n"))
2.4 The Actor Loop Pattern
Most actors follow this pattern—
(define (counter-actor initial) (let loop ([count initial]) (define msg (receive)) (match msg ['increment (loop (add1 count))] [(list 'get from) (send! from count) (loop count)] ['stop (printf "Final count: ~a\n" count)] [_ (loop count)]))) ; ignore unknown messages
State lives in the loop arguments. Each iteration can pass new state to the next.
3 Green Threads vs OS Threads
Rakka 0.7.0 introduces green threads—
3.1 When to Use Each
Mode |
| Use When |
| Trade-off |
Green (default) |
| Many actors, I/O-bound, message-heavy |
| Fast spawning, no parallelism |
Threaded |
| CPU-bound work, true parallelism needed |
| Slower spawning, real parallel |
3.2 Switching Modes
Use runtime-mode to select the execution model before spawning actors:
#lang racket/base (require rakka) ;; Green mode (default) - lightweight, cooperative (runtime-mode 'green) (scheduler-start!) (for ([i 10000]) (spawn (lambda () (receive)))) ; 10,000 actors, no problem (scheduler-run-until-done!) (scheduler-stop!)
For CPU-bound parallel work:
;; Threaded mode - real OS threads (runtime-mode 'threaded) (spawn (lambda () ;; CPU-bound work runs in parallel (heavy-computation)))
3.3 Performance Characteristics
Green threads excel at actor-heavy workloads:
Operation |
| Green |
| Threaded |
| Green Advantage |
Spawn 1000 actors |
| 2ms |
| 45ms |
| 22× faster |
50k messages |
| 32ms |
| 5,300ms |
| 165× faster |
Use green mode unless you need CPU parallelism.
3.4 How It Works
Green threads use continuation-based cooperative scheduling:
No OS thread per actor: Thousands of actors share one thread
Cooperative yielding: Actors yield at message receive points
Two-list mailbox queue: O(1) amortized message operations
Timeout support: Actors can wait with deadlines
The scheduler maintains a ready queue of actors. When an actor calls receive with no messages, it suspends (saving its continuation) and the scheduler runs the next ready actor. When a message arrives, the waiting actor is added back to the ready queue.
4 GenServer: Abstracting the Pattern
Writing message loops by hand gets repetitive. GenServer abstracts the pattern.
4.1 Why GenServer?
Write the receive loop
Handle request/reply correlation
Manage timeouts
Handle shutdown
GenServer provides this infrastructure. You just implement callbacks.
4.2 Implementing a GenServer
Define a struct that implements gen:server:
#lang racket/base (require rakka racket/match) (struct counter-server () #:methods gen:server [(define (init self args) (ok args)) ; args becomes initial state (define (handle-call self msg state from) (match msg ['get (reply state state)] ['increment (reply 'ok (add1 state))])) (define (handle-cast self msg state) (match msg ['reset (noreply 0)] [_ (noreply state)])) (define (handle-info self msg state) (noreply state)) (define (terminate self reason state) (void))])
4.3 The Callbacks
Callback |
| When Called |
| Returns |
init |
| Server starts |
| (ok state) or (stop-init reason) |
handle-call |
| Synchronous request |
| (reply value new-state) |
handle-cast |
| Async notification |
| (noreply new-state) |
handle-info |
| Other messages |
| (noreply new-state) |
terminate |
| Server stopping |
| (void) |
4.4 Using a GenServer
;; Start the server (define pid (gen-server-start (counter-server) 0)) ;; Synchronous call (blocks for reply) (printf "Count: ~a\n" (gen-server-call pid 'get)) ; => 0 (printf "Inc: ~a\n" (gen-server-call pid 'increment)) ; => ok (printf "Count: ~a\n" (gen-server-call pid 'get)) ; => 1 ;; Async cast (fire and forget) (gen-server-cast! pid 'reset) (sleep 0.01) ; let it process (printf "Count: ~a\n" (gen-server-call pid 'get)) ; => 0 ;; Stop (gen-server-stop pid)
5 Links and Monitors: Handling Failure
Actors crash. Links and monitors let you respond.
5.1 Links: Shared Fate
Linked processes die together. If one crashes, the other receives an exit signal.
;; Spawn and link in one step (define pid (spawn-link (lambda () (sleep 1) (error "boom!")))) ;; Or link explicitly (define pid2 (spawn some-actor)) (link! pid2)
By default, exit signals kill the receiver too. To handle them instead:
(set-trap-exit! (self) #t) ;; Now exit signals arrive as messages (define msg (receive)) (match msg [(exit-signal from reason) (printf "~a died: ~a\n" from reason)])
5.2 Monitors: One-Way Watching
Monitors watch without shared fate. The watcher gets notified, but doesn’t die.
(define ref (monitor! some-pid)) ;; Later, if some-pid dies: (define msg (receive)) (match msg [(down-signal ref pid reason) (printf "~a died: ~a\n" pid reason)])
Use demonitor! to stop watching.
6 Supervisors: Automatic Recovery
Supervisors watch child processes and restart them on failure.
6.1 Why Supervisors?
Instead of handling every possible error, let processes crash and restart clean. This is the "let it crash" philosophy.
Starting child processes
Monitoring them for failures
Restarting them according to a strategy
6.2 Restart Strategies
Strategy |
| Behavior |
'one-for-one |
| Restart only the crashed child |
'one-for-all |
| Restart all children if one crashes |
'rest-for-one |
| Restart crashed child and those started after it |
'simple-one-for-one |
| Dynamic children, all same spec |
6.3 Defining Children
Use child-spec* to define how to start each child:
(child-spec* #:id 'worker #:start (lambda () (gen-server-start-link (my-worker) '())) #:restart 'permanent)
'permanent —
always restart 'temporary —
never restart 'transient —
restart only on abnormal exit
6.4 Starting a Supervisor
(define sup-pid (supervisor-start-link 'one-for-one ; strategy 3 ; max 3 restarts 5 ; in 5 seconds (list (child-spec* #:id 'worker1 #:start worker1-start #:restart 'permanent) (child-spec* #:id 'worker2 #:start worker2-start #:restart 'permanent))))
If a child crashes more than 3 times in 5 seconds, the supervisor gives up and terminates (which may trigger its own supervisor to restart it).
7 Applications: Putting It Together
An Application is your top-level entry point—
(struct my-app () #:methods gen:application [(define (start self args) ;; Start your supervision tree here (define sup (supervisor-start-link ...)) (app-ok sup)) (define (stop self state) (void))]) ;; Start the application (application-start (my-app) '())
8 Process Registry
Name processes for easy lookup:
(register! 'cache cache-pid) ;; Later, from anywhere: (define cache (whereis 'cache)) (gen-server-call cache 'get-stats)
whereis returns #f if not registered.
9 API Reference
9.1 Process Primitives
procedure
(receive) → any/c
procedure
timeout-ms : exact-nonnegative-integer?
procedure
predicate : (-> any/c boolean?) timeout-ms : (or/c #f exact-nonnegative-integer?) = #f
procedure
(pid-alive? pid) → boolean?
pid : pid?
9.2 Runtime Mode
procedure
(runtime-mode-green?) → boolean?
procedure
(scheduler-start!) → void?
procedure
(scheduler-stop!) → void?
procedure
(scheduler-run-until-done!) → void?
procedure
(scheduler-running?) → boolean?
9.3 Registry
9.4 Links and Monitors
procedure
(link! pid) → void?
pid : pid?
procedure
(unlink! pid) → void?
pid : pid?
procedure
(demonitor! ref) → void?
ref : monitor-ref?
struct
(struct exit-signal (from reason) #:extra-constructor-name make-exit-signal) from : pid? reason : any/c
struct
(struct down-signal (ref pid reason) #:extra-constructor-name make-down-signal) ref : monitor-ref? pid : pid? reason : any/c
9.5 GenServer
procedure
(gen-server-start-link impl args [ #:name name]) → pid? impl : server? args : any/c name : (or/c symbol? #f) = #f
procedure
(gen-server-call server request [ #:timeout timeout]) → any/c server : (or/c pid? symbol?) request : any/c timeout : exact-positive-integer? = 5000
procedure
(gen-server-stop server [reason]) → void?
server : (or/c pid? symbol?) reason : any/c = 'normal
struct
(struct reply (value new-state) #:extra-constructor-name make-reply) value : any/c new-state : any/c
struct
(struct stop (reason reply-value new-state) #:extra-constructor-name make-stop) reason : any/c reply-value : any/c new-state : any/c
9.6 Supervisor
procedure
(supervisor-start-link strategy max-restarts max-seconds child-specs) → pid? strategy : strategy? max-restarts : exact-nonnegative-integer? max-seconds : exact-positive-integer? child-specs : (listof child-spec?)
procedure
(child-spec* #:id id #:start start [ #:restart restart #:shutdown shutdown]) → child-spec? id : any/c start : (-> pid?) restart : (or/c 'permanent 'temporary 'transient) = 'permanent
shutdown : (or/c 'brutal 'infinity exact-nonnegative-integer?) = 5000
9.7 Testing Utilities
| (require rakka/testing) | package: Rakka |
procedure
(wait-until pred [#:timeout timeout-ms]) → boolean?
pred : (-> boolean?) timeout-ms : exact-positive-integer? = 1000
procedure
(check-process-exits pid expected-reason [ #:timeout timeout-ms]) → boolean? pid : pid? expected-reason : any/c timeout-ms : exact-positive-integer? = 1000
10 #lang rakka: Actor-Friendly Racket
For long-running computations, use #lang rakka instead of #lang racket. It automatically yields control at function calls, preventing any single actor from monopolizing the scheduler.
10.1 Why #lang rakka?
In green thread mode, actors cooperatively yield at receive points. But what if an actor computes for a long time without receiving? It starves other actors.
#lang rakka solves this by counting "reductions" (function calls). After 4000 reductions, it automatically yields. This is how Erlang achieves fair scheduling.
10.2 Using #lang rakka
#lang rakka ; <-- Just change this line (require rakka) ;; Every function call now includes a yield point (define (fib n) (if (<= n 1) n (+ (fib (- n 1)) (fib (- n 2))))) ;; Even fib(30) won't starve other actors (spawn (lambda () (printf "fib(30) = ~a~n" (fib 30)))) (spawn (lambda () (printf "I'm not blocked!~n")))
10.3 Manual Reduction Control
#lang rakka (set-reduction-limit! 1000) ; Yield more often (reset-reductions!) ; Reset counter (current-reductions) ; Check current count
10.4 Anti-Pattern Warnings
#lang rakka warns about patterns that break actor isolation:
#lang rakka (define x 1) (set! x 2) ; Warning: mutation may break actor isolation (thread (lambda () ...)) ; Warning: raw threads bypass actor scheduler
Disable with (disable-rakka-warnings!).
11 Distribution: Actors Across Machines
Rakka supports Erlang-style distribution with EPMD (Erlang Port Mapper Daemon) protocol compatibility.
11.1 Starting a Distributed Node
#lang racket (require rakka) ;; Start EPMD (once per machine, typically port 4369) (define epmd (start-epmd! 4369)) ;; Start this node (start-node! "mynode@localhost" 9001 #:cookie "secret-cookie")
The cookie must match between nodes that want to communicate.
11.2 Connecting Nodes
;; On node2 (start-node! "node2@localhost" 9002 #:cookie "secret-cookie") ;; Connect to node1 (connect-node! "mynode@localhost") ;; See connected nodes (nodes) ; => '("mynode@localhost")
11.3 Global Registry
Register processes visible across all connected nodes:
;; Register globally (global-register! 'calculator my-pid) ;; From any connected node (global-send! 'calculator '(add 1 2)) ;; Lookup (define calc-pid (global-whereis 'calculator))
11.4 Remote Spawning
Spawn actors on remote nodes:
;; On the remote node, register a spawn handler (register-spawn-handler! 'my-worker (lambda (args) (spawn (lambda () (worker-loop args))))) ;; From any node, spawn remotely (define remote-pid (request-spawn! "node2@localhost" 'my-worker '(initial args)))
11.5 Distributed Links and Monitors
Links and monitors work across nodes:
;; Link to remote process (dist-link! remote-pid) ;; Monitor remote process (define ref (dist-monitor! remote-pid))
11.6 Distribution API Reference
procedure
port : exact-positive-integer? = 4369
procedure
(stop-epmd! epmd) → void?
epmd : epmd-server?
procedure
(start-node! name port [#:cookie cookie]) → void?
name : string? port : exact-positive-integer? cookie : string? = "nocookie"
procedure
(stop-node!) → void?
procedure
(dist-link! pid) → void?
pid : pid?
12 Hot Code Reload
Update running code without stopping your system. Rakka supports Erlang-style code upgrades with state migration.
12.1 Basic Reload
#lang racket (require rakka) ;; Reload a module (re-evaluates the file) (hot-reload! "my-server.rkt")
12.2 State Migration with code-change
When code changes, your GenServer’s code-change callback transforms old state to new:
(struct my-server (data version) #:methods gen:server [;; ... other callbacks ... (define (code-change self version-info state) ;; version-info contains old-version, new-version, extra (match (version-info-new-version version-info) ["2.0" ;; Migrate state from v1 to v2 (ok (struct-copy my-server state [version "2.0"] [data (upgrade-data (my-server-data state))]))] [_ (ok state)]))])
12.3 Triggering Code Change
;; Trigger code-change on a specific process (trigger-code-change! pid (version-info "1.0" "2.0" #f)) ;; Upgrade with suspend/resume (safer) (upgrade-process! pid (version-info "1.0" "2.0" #f))
12.4 Tree-Wide Upgrades
Upgrade an entire supervision tree safely (bottom-up order):
(upgrade-supervisor-tree! supervisor-pid (version-info "1.0" "2.0" #f))
Children are upgraded before their supervisor, ensuring consistent state.
12.5 Rollback on Failure
(hot-reload-with-rollback! "my-server.rkt" #:validate (lambda () (run-health-checks)) #:timeout 5000)
If validation fails, the old code is restored.
12.6 Hot Reload API Reference
struct
(struct version-info (old-version new-version extra) #:extra-constructor-name make-version-info) old-version : any/c new-version : any/c extra : any/c
procedure
(hot-reload! module-path) → void?
module-path : path-string?
procedure
(upgrade-supervisor-tree! sup-pid version-info [ #:skip-ids skip-ids]) → void? sup-pid : pid? version-info : version-info? skip-ids : (listof any/c) = '()
13 GenStatem: Finite State Machines
For state-dependent behavior, use GenStatem (generic state machine).
13.1 Implementing a State Machine
(struct traffic-light () #:methods gen:statem [(define (statem-init self args) (statem-ok 'red 0)) ; initial state = 'red, data = 0 (define (callback-mode self) 'handle-event) (define (handle-event self event-type event state data) (match* (event-type (call-event-request event)) [('call 'next) (define new-state (match state ['red 'green] ['green 'yellow] ['yellow 'red])) (define new-data (if (eq? new-state 'red) (add1 data) data)) (next-state new-state new-data (statem-reply event new-state))] [('call 'get-cycle-count) (keep-state-and-data (statem-reply event data))])) (define (statem-terminate self reason state data) (void))])
13.2 Using a State Machine
(define light (gen-statem-start (traffic-light) #f)) (gen-statem-call light 'next) ; => 'green (gen-statem-call light 'next) ; => 'yellow (gen-statem-call light 'next) ; => 'red (gen-statem-call light 'get-cycle-count) ; => 1
13.3 GenStatem Callbacks
Callback |
| Returns |
statem-init |
| (statem-ok initial-state initial-data) |
callback-mode |
| 'handle-event |
handle-event |
| next-state, keep-state, or keep-state-and-data |
statem-terminate |
| (void) |
14 Operational Features
14.1 Logging
Rakka provides structured logging with per-process log levels:
;; Set global log level (current-rakka-log-level 'debug) ;; Set per-process log level (set-process-log-level! some-pid 'error) ;; Log messages (rakka-log-info "Starting server on port ~a" port) (rakka-log-error "Connection failed: ~a" reason)
Lifecycle events are logged automatically: SPAWN, EXIT, CRASH, RESTART.
14.2 Metrics
Built-in metrics for monitoring:
;; Read built-in metrics (counter-value metrics-process-spawns) (counter-value metrics-messages-sent) (gauge-value metrics-active-processes) ;; Create custom metrics (define requests (make-counter 'http-requests)) (counter-inc! requests) (define connections (make-gauge 'active-connections)) (gauge-inc! connections) (gauge-dec! connections) (define latency (make-histogram 'request-latency)) (histogram-observe! latency 42.5)
14.3 Graceful Shutdown
Register shutdown hooks for clean termination:
(on-shutdown (lambda (ctx) (printf "Cleaning up...~n") (close-database-connections)) #:priority 10 ; higher = runs first #:name 'db-cleanup) ;; Trigger graceful shutdown (graceful-shutdown #:timeout 30000 #:reason 'maintenance)
14.4 Process Introspection
Inspect and control running processes:
;; Get current state of a GenServer (sys-get-state some-pid) ;; Suspend/resume processing (sys-suspend some-pid) (sys-resume some-pid) ;; Enable tracing (sys-trace some-pid #t)
15 License
Rakka is released under the MIT License.
MIT License |
|
Copyright (c) 2024 Aldric Giacomoni |
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
of this software and associated documentation files (the "Software"), to deal |
in the Software without restriction, including without limitation the rights |
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
copies of the Software, and to permit persons to whom the Software is |
furnished to do so, subject to the following conditions: |
|
The above copyright notice and this permission notice shall be included in all |
copies or substantial portions of the Software. |
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
SOFTWARE. |