Concurrency¶
Kaappi offers two concurrency models: green threads (fibers) for cooperative multitasking and OS threads (SRFI-18) for true parallelism.
Green threads (fibers)¶
Fibers run cooperatively within a single OS thread. They are lightweight and communicate via channels:
(import (kaappi fibers))
(define ch (make-channel))
(spawn (lambda ()
(channel-send ch "hello from fiber")))
(display (channel-receive ch)) ;=> hello from fiber
Use fibers for concurrent I/O, event loops, and cooperative task scheduling. See the Fibers reference for the full API.
Fiber I/O is non-blocking¶
A blocking-looking call like (read-line port) only suspends the fiber
that made it. Under the hood, Kaappi runs a per-OS-thread I/O
reactor: if the read would block, the fiber parks and the reactor
waits on the underlying file descriptors (via kqueue on macOS/BSD, epoll
on Linux, or poll_oneoff under WASI), waking whichever fiber becomes
ready. thread-sleep! gets the same treatment — it parks the calling
fiber on the reactor's timer heap instead of blocking the whole thread,
so sleeping fibers and I/O-bound fibers interleave freely:
(import (kaappi fibers))
;; Each fiber's (read-line) yields to the scheduler on EAGAIN instead
;; of freezing every other fiber. Thousands of these coexist on one
;; OS thread and one GC heap.
(define (handle conn)
(spawn (lambda ()
(let loop ()
(let ((line (read-line conn)))
(unless (eof-object? line)
(channel-send out (process line))
(loop)))))))
This is what makes http-listen-fiber
practical: it accepts connections in a non-blocking loop and spawns one
fiber per connection, so a slow client trickling in its request never
delays a fast one on a different connection — all on a single OS
thread. Libraries built on (kaappi net)'s plain-TCP tcp-recv/tcp-send
(kaappi-redis, kaappi-email, kaappi-http's client) work unchanged
under this model; only server accept loops need the explicit
non-blocking treatment http-listen-fiber provides.
OS threads (SRFI-18)¶
Real OS threads via pthread_create. Each thread gets its own VM and GC
with an independent heap. No special flag is needed -- just run your
program:
OS threads are unavailable in --sandbox mode and in the WebAssembly
build (including the browser playground); use fibers there.
(import (srfi 18))
(define t (thread-start!
(make-thread
(lambda ()
(* 6 7)))))
(thread-join! t) ;=> 42
Both interpreted and native (compiled) procedures are accepted by
make-thread and spawn:
Values are deep-copied when crossing thread boundaries:
- At
thread-start!: the thunk closure is deep-copied from parent to child - At
thread-join!: the result is deep-copied from child to parent
Threads cannot share mutable heap state directly -- use return values or channels to communicate. See the SRFI-18 reference for mutexes, condition variables, and the full threading API.
Cross-thread channels
A channel handed to a thread through its thunk works across the thread
boundary — sends and receives are deep-copied at each end, the same way
values already are at thread-start!/thread-join!. Reaching a channel
any other way (through a shared global, for example) raises a
descriptive error rather than corrupting memory. The wakeup-path bugs
in earlier builds were fixed in v0.15.0; see
Standards Conformance
for the subsystem's KEP status.
Multi-core parallelism with (kaappi parallel)¶
Coordinating a pool of worker threads by hand means channels, a task
protocol, and careful shutdown bookkeeping. (kaappi parallel) packages
that pattern on top of cross-thread channels:
(import (kaappi parallel))
;; parallel-map manages its own pool, sized to processor-count
(parallel-map (lambda (n) (* n n)) '(1 2 3 4 5))
;=> (1 4 9 16 25)
For repeated submissions, keep a pool around instead of paying setup cost per call:
(define pool (make-pool (processor-count)))
(define reply (pool-submit pool (lambda () (expensive-computation))))
;; ... do other work ...
(task-wait reply) ;=> the result, or re-raises the task's exception
(pool-shutdown! pool) ;; drains queued tasks, then joins every worker
Every task thunk and result crosses the pool boundary by copy -- the
same rule as raw thread-start!/thread-join!. pool-submit returns a
plain channel; task-wait is just channel-receive with exception
re-raising, so nothing about a pool is special beyond the bookkeeping.
make-pool's workers are real OS threads when available. Under --sandbox
and in the WebAssembly build, where thread creation is unavailable,
make-pool degrades to spawning fiber workers on the calling thread's
scheduler instead -- cooperative rather than parallel, but the same API
keeps working unchanged. processor-count reflects this too: it returns
the real core count natively, and 1 under --sandbox or WASM.
See the Kaappi Extensions reference for the full API, and the cookbook recipe Run Concurrent Tasks for a worked comparison against a hand-rolled fiber pool.
parallel-map/parallel-for-each: one task per element
Both submit one task per list element, so every element pays a
pool-submit/task-wait round trip plus the copy across the worker
boundary. When each call does real work that's fine; for very cheap
per-element operations, use make-pool/pool-submit/task-wait
directly with one task per processor instead of one per element -- see
the Parallel Prime
Search
example.
Choosing a model¶
| Fibers | OS threads | |
|---|---|---|
| Parallelism | No (cooperative, single thread) | Yes (one VM per thread) |
| Shared state | Yes (same heap) | No (values deep-copied) |
Works in --sandbox |
Yes | No |
| Works in WASM / playground | Yes | No |
| Best for | Concurrent I/O, event loops | CPU-bound parallel work |
For CPU-bound parallel work, reach for (kaappi parallel)'s parallel-map/
make-pool (above) rather than raw thread-start! — it handles the
channel/task bookkeeping and degrades to fibers under --sandbox and WASM
automatically. For multi-process serving in production (serve-prefork),
see Deployment. For worked examples
of pipelines and worker pools, see the cookbook recipe
Run Concurrent Tasks.
Next: REPL Guide