The model
Recoil concurrency is single-threaded and cooperative. go tasks are stackful coroutines multiplexed onto one OS thread by a scheduler built into the runtime. A task yields only at well-defined blocking points — sleeping, waiting on port I/O, or a channel send/receive — so code between yield points never observes interleaving.
Reference counts of the managed heap stay non-atomic. Atomics exist for a different boundary: foreign threads, such as a plugin host’s audio callback.
Tasks: go and go/after
go [body] queues a task. go/after ms [body] queues one that becomes runnable only after ms milliseconds. Both are statement forms, valid only inside function bodies.
run: func [return: [none!]] [
go [
print "A1"
sleep 1
print "A2"
]
go [
print "B1"
sleep 1
print "B2"
]
go/after 100 [
print "heartbeat"
]
print "main"
]
run
Output: main, A1, B1, A2, B2, heartbeat.
Scheduling rules
- Tasks start running when the main flow reaches the scheduler: at the end of
main, at an explicitruntime/run, or whenever the main flow blocks (insleep, a reactorwait, orserve). - Ready tasks run in FIFO spawn order. A task that never yields runs to completion before the next starts.
- Captures follow the ownership model: shareable native series are retained and shared, move-sensitive values are consumed.
sleep
sleep ms inside a task parks it until the deadline and yields; sleep 0 is a pure cooperative yield to the back of the ready queue. Outside any task it blocks the main flow but drives the scheduler meanwhile, so queued tasks keep progressing.
Channels
A channel is a typed, bounded FIFO between tasks:
run: func [return: [none!]] [
ch: make channel! [i32! 4] ; element type + capacity (default 1)
go [
send ch 1
send ch 2
send ch 3
]
print receive ch ; 1
print receive ch ; 2
print receive ch ; 3
]
run
make channel! [T n]creates a channel ofnslots. Element types are scalars and heap series values; by-value aggregates (struct!,port!,slice!) are rejected withT4142.make channel! [T 0]is a rendezvous channel: everysendpairs directly with areceive, like Go’s unbuffered channels.send ch valueon a full buffer parks the sender until a receiver drains a slot (backpressure).receive chis an expression; on an empty channel it parks.close chcloses it. Buffered values still drain;receiveon a closed-and-empty channel and anysendon a closed channel setERR_CHANNEL_CLOSED(check witherrored?).
The channel handle itself has copy semantics, so the same ch can be captured by any number of tasks and used from the main flow. (send is shared with fsm dispatch — send machine event [payload] — the grammar tells them apart.)
Element typing
A channel slot is 8 bytes, erased to i64, f64 or ptr. The slot kind is not a type: many different pointer types share ptr without sharing a representation, so a value that merely lands in the right slot could be reinterpreted into something it is not. send therefore checks against the declared element type and rejects a mismatch with T4143.
ch: make channel! [i64! 2]
go [n: make i32! 5 send ch n] ; fine — same-family widening
ch: make channel! [string! 2]
go [u: make url! http://example.com send ch u] ; T4143
Accepted: an exact match and same-family numeric widening (i8! → i16! → i32! → i64!, the unsigned chain, f32! → f64!). Rejected: representation changes inside a slot kind, lossy narrowing, and non-numeric aliases that share the i64 slot (logic!, char!, date!, time!). A value whose type cannot be inferred is let through.
Ownership across a send
A send hands the value to an unknown future owner. Shareable native series are retained and shared — the sender keeps a valid binding. Values that are move-only (#unique series, ports, errors, dicts, pointer-likes) are consumed: using the sender’s binding afterwards is B3000. Sending a borrowed (:name) reference is rejected with B3007, because it would leave the sender holding a live alias to memory another task now owns. Scalars copy.
s: make [#unique string!] "hi"
go [send ch s]
print s ; B3000: 's' used after move
Deadlock detection
Cooperative scheduling makes deadlock exact: if every remaining task is parked on a channel and the ready, sleeping and I/O lists are empty, nothing can ever complete. The runtime fails fast, naming the blocked tasks, and exits 1:
recoil: deadlock: all tasks blocked on channels
recoil: task '__go_task_1' waiting to receive
recoil: task '__go_task_2' waiting to receive
Yielding I/O
Inside a task, the blocking port operations park the task instead of blocking the process:
read/blocking :port :buf nparks until the port is readable, then reads.write/from :port :buf ntreats a full send buffer as backpressure: the task parks until writable and retries.accept :listenerparks until a connection is pending.
Files and the console are always ready and never park. read/into remains the non-blocking poll API and reports would-block instead of waiting. That makes task-per-connection servers a plain composition — one task can serve another in the same process:
run: func [return: [none!]] [
go [
server: make [#mutable port!] open "tcp://:8080"
client: make [#mutable port!] accept :server ; parks, doesn't block
buf: make c-pointer! unsafe/malloc 256
n: make i32! read/blocking :client :buf 255 ; parks until data
write/from :client :buf n
unsafe/free buf
close client
close server
]
go [
conn: make [#mutable port!] open "tcp://127.0.0.1:8080"
msg: make string! "ping!\n"
tcp/write msg/data 1 6 :conn
close conn
]
]
run
Still process-blocking: DNS resolution and the TLS handshake.
Reactor and serve
The callback style (reactor, port/awake:, serve) runs on the same scheduler. wait :reactor timeout outside a task drives the scheduler while it waits, so queued tasks can be what makes a watched port ready; inside a task it poll-yields. serve :port is a loop over that wait. port/awake: handlers must be top-level functions — closures are rejected with T4141 — so keep per-source state at module level. See Ports.
Errors in tasks
Each task has its own pending-error state. An error raised in task A and not yet checked stays invisible to task B across yields; errored? consumes only the current task’s error.
A task that finishes with an unconsumed error fails fast: the process aborts with exit code 1 and recoil: task '<name>' terminated with unhandled error <code> on stderr. A silently half-dead server is worse than a loud crash; check errored? inside the task if it should survive a fallible operation.
runtime/run
runtime/run runs the scheduler until no live tasks remain (ready, sleeping and I/O-blocked all count as live). There is no implicit join other than the one at the end of main.
Platforms & task stacks
| Backend | Task model |
|---|---|
| hosted-posix (macOS / Linux) | coroutines (minicoro, asm backend) |
| hosted-windows | coroutines (asm under mingw, fibers under MSVC) |
| esp-idf | run-to-completion queue |
| browser-wasm | run-to-completion queue |
On the run-to-completion backends tasks run FIFO at drain points and do not yield, go/after delays are ignored, and sleep blocks the whole process. Channels work as bounded buffers: a send or receive that would block first drains the pending queue, then aborts as a deadlock if it still cannot proceed. RISC-V ESP32 variants (C3/C6) are coroutine-compatible; xtensa has no minicoro backend.
Task stacks are fixed-size: 56 KB by default (32 KB minimum). Override per process with RECOIL_TASK_STACK_SIZE (bytes, read at the first task’s first resume) or at build time with -DRECOIL_TASK_STACK_SIZE=<bytes>; the environment variable wins. Stacks are allocated lazily when a task first runs and recycled through a pool, so memory peaks with concurrently live tasks, not the number spawned.
Atomics
#atomic is a type attribute that qualifies scalar storage so reads, writes and explicit read-modify-write operations are indivisible.
counter: make [#atomic i32!] 0
counter: 5 ; atomic store
n: counter ; atomic load
atomic-add counter 1
atomic-sub counter 1
old: atomic-exchange counter 7
ok: atomic-compare-exchange counter 7 9 ; logic! success flag
Plain reads and writes keep their ordinary spelling and are already sequentially consistent, so there is deliberately no way to do a non-atomic access to an #atomic binding. All operations are seq-cst; weaker orderings are not exposed.
Element types
| Type | load / store | exchange, compare-exchange | atomic-add, atomic-sub |
|---|---|---|---|
logic! | yes | yes | no |
i32! u32! i64! u64! | yes | yes | yes |
c-pointer! | yes | yes | yes |
f32! f64! | yes | yes | no (P1760) |
Everything else — string!, block!, dict!, structs, closures, port! — is rejected with P1756.
Positions
#atomic is accepted on a binding, on a struct or c-struct! field, and on a borrowed parameter:
state!: make struct! [count: [#atomic i64!]]
bump: func [:c [#atomic i32!] n [i32!] return: [none!]] [
atomic-add c n
]
- A non-borrowed
#atomicparameter is rejected (P1761): passing one by value would atomically load and then hand over a plain copy. #atomicon a field of an importedc-struct!is rejected (P1758), since that layout must match the C header byte for byte.- An atomic parameter needs addressable atomic storage of the exact element type (
P1762): an#atomic i32!cannot be passed to an#atomic i64!parameter.
What the compiler does and does not catch
counter: counter + 1 is rejected (B3012): each half is atomic, the pair is not, so it reads as correct code while losing updates under contention. Use atomic-add. The check is syntactic, so it does not catch the split form tmp: counter then counter: tmp + 1.
string!, block!, vector! or dict! across OS threads is unsupported — their reference counts are not atomic — and #atomic on such a type is a compile-time error.Requirements
#atomic lowers to C11 _Atomic(T) and <stdatomic.h>. Under --c-profile c99 it is rejected (P1757). Support is also gated per --target: wasm32-emscripten and esp32 reject it (P1765); native, arm-linux-gnueabihf and windows-msvc support it.
Example: plug-in parameter sync
A CLAP host may call parameter getters and setters from its UI thread while the audio callback reads the same values. Only host-facing scalars are atomic; audio-thread-private data stays plain:
processor-state!: make c-struct! [
sample-rate: [f64!] ; audio-thread-private, plain
gain: [#atomic f64!] ; host-facing parameter
mix: [#atomic f64!] ; host-facing parameter
scratch: [c-array! [f32! 256]] ; audio-thread-private, plain
]
processor-set-parameter: func [
#export
:raw [c-pointer!]
parameter [u32!]
value [f64!]
return: [logic!]
] [
state: make [#mutable processor-state!] as processor-state! raw
if parameter = (as u32! 0) [
state/gain: value ; atomic store
return true
]
return false
]
This buys per-field tear-freedom, not a cross-field snapshot: if the audio callback reads gain and mix together, a host update can land between the two loads.