Concurrency & Async
Sema features a cooperative asynchronous concurrency model using Promises and Channels. Tasks run on the bytecode VM's built-in scheduler and interleave execution at specific yield points (such as waiting for a channel, sleeping, or awaiting another task).
IMPORTANT
Async features are VM-only and require the bytecode VM backend (which is the default since v1.13).
1. Promises and Tasks
A Promise represents the result of a computation that runs asynchronously.
Spawning Tasks (async)
You can spawn a computation as a background task using the async special form, which returns an async promise:
(define p (async (+ 10 20)))Awaiting Results (await)
To wait for a task to complete and get its return value, use the await function:
(await p) ; => 30Concurrent Execution
If you spawn multiple tasks, they run concurrently. You can kick off several jobs and wait for all of them:
(define task1 (async (do-slow-work-1)))
(define task2 (async (do-slow-work-2)))
;; Both tasks are running. Now we wait for their results:
(define result1 (await task1))
(define result2 (await task2))2. Sleeping and Yielding
Within an async task, you can pause execution to let other tasks run, or delay execution for a specific duration.
Sleeping (async/sleep)
Use async/sleep to yield control to the scheduler for at least a certain number of milliseconds:
(async
(println "Starting...")
(async/sleep 1000) ; pause for 1 second
(println "Done!"))Deterministic — and real wall-clock everywhere
The scheduler uses a virtual clock, so sleeps order tasks deterministically — a shorter sleep always wakes before a longer one, the same on every run. The clock advances in real time: on native (a 1-second sleep really waits) and in the browser playground, where eval runs on a Web Worker that blocks on Atomics.wait so the sleep really pauses while the page stays responsive. Browsers without cross-origin isolation fall back to advancing instantly (ordering preserved). Sleep durations are capped at 1 day.
3. Channels
Channels are bounded FIFO (First-In, First-Out) buffers used to communicate and synchronize data between concurrent tasks.
Creating a Channel (channel/new)
Create a channel with a specific buffer capacity. The default capacity is 1:
(define ch (channel/new 3)) ; holds up to 3 valuesSending and Receiving (send / recv)
channel/sendsends a value to the channel. If the channel is full, the sending task yields until space becomes available.channel/recvreceives a value from the channel. If the channel is empty, the receiving task yields until a value is sent.
(define ch (channel/new 1))
;; A worker task sends a message:
(async (channel/send ch "message from worker"))
;; `channel/recv` only blocks (yields) inside an async task, so receive
;; from within one and await the result:
(await
(async
(let ((msg (channel/recv ch)))
(println msg) ; => "message from worker"
msg)))NOTE
Channel operations only block by yielding to the scheduler, which runs async tasks. Calling channel/recv on an empty channel (or channel/send on a full one) from the top level — outside any async task — raises an error instead of waiting, because there is no task to suspend.
Closing Channels (channel/close)
When you are finished sending data, close the channel. Any subsequent sends will raise an error. Receivers waiting on a closed, empty channel will receive nil:
(channel/close ch)4. Producer / Consumer Example
Here is a complete example of a producer task sending a series of numbers to a consumer task via a channel:
(let ((ch (channel/new 1)))
(let ((producer (async
(channel/send ch 10)
(channel/send ch 20)
(channel/send ch 30)
(channel/close ch)))
(consumer (async
(let loop ((sum 0))
(let ((val (channel/recv ch)))
(if (nil? val)
sum
(loop (+ sum val))))))))
(await consumer))) ; => 605. Async inside Higher-Order Functions
Sema's standard library functions like map, filter, and for-each support async callbacks. However, if you pass a yielding native (like channel/recv) directly and it actually needs to suspend, the runtime cannot yield through it. Wrap it in a lambda so the yield can suspend cleanly:
;; ❌ Inside an async task, if `channel/recv` must wait for a value it raises:
;; "yielding native passed directly to a higher-order function"
(async (map channel/recv (list ch1 ch2)))
;; ✓ Wrap the yielding call in a lambda:
(async (map (fn (c) (channel/recv c)) (list ch1 ch2)))