Concurrency
Silt provides built-in concurrency based on CSP (Communicating Sequential Processes). Tasks communicate through channels. There is no shared mutable state — every value in silt is immutable, so sending a value through a channel is always safe. Tasks are lightweight and run in parallel on a fixed thread pool.
All concurrency primitives live in two modules: channel and task. There are
no concurrency keywords.
1. The Model
CSP in one paragraph
CSP — Communicating Sequential Processes — is a concurrency model from Tony Hoare’s 1978 paper. Independent tasks run their own sequential code and coordinate by sending messages through channels. No task can see another task’s variables. The only way to share data is to put it in a channel and let the other side take it out. Go’s goroutines and channels are the most well-known modern implementation of CSP.
The core principle:
Do not communicate by sharing memory; share memory by communicating.
Why CSP fits silt
Silt is fully immutable. Every binding is let, data structures are never
modified in place, and there is no mutable reference. This makes CSP a natural
fit:
- Immutability eliminates data races. Any value can be sent through a channel without copying concerns or locking. The receiver gets exactly what the sender put in. Nothing can corrupt it.
- Channels are the only coordination mechanism. No mutexes, no atomics, no
synchronizedblocks. If two tasks need to interact, they use a channel. - Code stays sequential. Each task reads like straight-line code. The concurrency is in how tasks are wired together via channels, not in how individual tasks are written.
- Tasks share the environment via
Arc. This is safe because everything is immutable — multiple tasks can hold references to the same values without risk of data races.
How CSP compares to other models
| Model | Key idea | Tradeoff |
|---|---|---|
| Threads + locks | Shared memory protected by mutexes | Deadlocks, data races, hard to reason about |
| Async/await | Cooperative futures on an event loop | Colored functions, viral async, complex lifetimes |
| Actors | Each actor has private state, communicates via mailboxes | Untyped messages, hard to do request/response |
| CSP (silt) | Independent tasks, typed channels, channel.select | No shared state (by design), real parallelism |
Like Go’s goroutines, task.spawn creates a lightweight task that runs in
parallel. Tasks are multiplexed onto a fixed-size thread pool (one thread per
CPU core), so spawning is cheap and you can run thousands of concurrent tasks
efficiently. Tasks coordinate through channels. Since all silt values are
immutable, there are no data races — channels are the sole communication
mechanism.
2. Channels
A channel is a conduit for passing values between tasks.
Creating channels
-- Rendezvous channel (capacity 0 -- sender blocks until receiver is ready)
let ch = channel.new(0)
-- Buffered channel with capacity 10
let ch = channel.new(10)
channel.new(0) takes zero or one argument. With no argument, it creates a
rendezvous channel (capacity 0) where the sender blocks until a receiver is
ready and vice versa. With an integer argument, it creates a buffered channel
with that capacity.
Channels carry any value type — integers, strings, lists, tuples, records, even other channels. The type is inferred from usage.
Sending: channel.send(ch, value)
channel.send(ch, "hello")
channel.send(ch, 42)
channel.send(ch, [1, 2, 3])
channel.send places a value into the channel’s buffer. If the buffer is full,
the current task is parked until space opens up. The OS thread is not blocked —
it runs other tasks in the meantime. If the channel is closed, sending is an
error.
Returns Unit.
Receiving: channel.receive(ch)
when let channel.Message(msg) = channel.receive(ch) else {
return
}
channel.receive takes one value from the channel’s buffer. Its result type
is the shared ChannelResult(a) enum, which has four variants:
Message(value)— a value was available.Closed— the channel is closed and the buffer is empty. No more values will ever arrive.Empty— never produced by blockingchannel.receive(it parks instead), but listed here because the variant is part of the same enum and the exhaustiveness checker still requires every arm. Returned bychannel.try_receive.Sent— never produced bychannel.receiveeither; it confirms a completedSend(...)arm inchannel.select. Listed here for the same exhaustiveness reason.
Any match over ChannelResult must cover all four variants (or use a
catch-all arm).
If the buffer is empty but the channel is still open, the current task is parked until a value arrives. The OS thread is not blocked — it runs other tasks in the meantime.
Before attempting to receive, the scheduler yields to other tasks so that competing receivers get a fair turn (round-robin fan-out).
Closing: channel.close(ch)
channel.close(ch)
Signals that no more values will be sent on this channel. After closing:
- A send on the closed channel is a runtime error,
send on closed channel N, whichever function was to send:channel.send,channel.try_send, or achannel.selectthat has aSendarm on the channel. A send that was waiting when the channel was closed fails the same way. So a channel is closed by the side that sends on it, or after the senders are done. - Receives drain any remaining buffered values as
Message(value). Once the buffer is empty,channel.receivereturnsClosed.
Returns Unit.
Non-blocking send: channel.try_send(ch, value)
let ch = channel.new(2)
println(channel.try_send(ch, "a")) -- true
println(channel.try_send(ch, "b")) -- true
println(channel.try_send(ch, "c")) -- false (buffer full)
Attempts to send without blocking. Returns true if the value was placed in
the buffer (or handed to a receiver that waits), or false if there is no
room for it now. Never yields to the scheduler. On a closed channel it is the
runtime error of every send on one, not false.
Non-blocking receive: channel.try_receive(ch)
let ch = channel.new(10)
channel.send(ch, 42)
println(channel.try_receive(ch)) -- Message(42)
println(channel.try_receive(ch)) -- Empty
Attempts to receive without blocking. Returns a ChannelResult(a). The
variants try_receive can produce in practice are:
Message(value)— a value was available.Empty— no data yet, but the channel is still open.Closed— no data and the channel is closed.
The fourth ChannelResult variant, Sent, is reserved for channel.select
and is never returned by try_receive, but the exhaustiveness checker still
requires every match to cover it (or use a catch-all arm).
This lets you distinguish “nothing right now” from “nothing ever again.”
On a rendezvous channel, try_receive takes the value of a sender that is
parked on it: the sender waits with its value until somebody takes it (see
Unbuffered channels).
Iterating: channel.each(ch) { val -> ... }
let ch = channel.new(10)
channel.send(ch, "hello")
channel.send(ch, "world")
channel.close(ch)
channel.each(ch) { msg -> println("got: {msg}") }
-- prints:
-- got: hello
-- got: world
channel.each calls a function for each value received from the channel and
returns Unit when the channel closes. It is the channel equivalent of
list.each. When the channel is empty, the task is parked until new data
arrives.
After processing each message, channel.each yields to the scheduler so that
other tasks waiting on the same channel get a fair turn. This is the mechanism
behind round-robin fan-out (see Section 5).
Unbuffered channels (true rendezvous)
channel.new(0) with no arguments creates a true rendezvous channel with
capacity 0. The sender blocks until a receiver is ready, and the receiver
blocks until a sender is ready. The value is handed off directly — it is
never buffered. When channel.send returns, the value has been handed to a
receiver that was waiting for it; the receiver may not have run yet. Use a
reply channel if the sender must know that the value was processed.
A sender on a rendezvous channel waits until its value is taken, and
whoever takes it completes the send: a blocking channel.receive, a Recv
arm of channel.select, a channel.receive_timeout, or a channel.try_receive.
The non-blocking operations do not wait themselves, so they only meet a
counterpart that is already waiting: channel.try_send succeeds only while
a receiver is parked on the channel, and channel.try_receive gives a value
only while a sender is.
let ch = channel.new(0) -- capacity 0, true rendezvous
let _ = task.spawn { ->
-- this blocks until the main task calls channel.receive
channel.send(ch, "hello")
}
-- this blocks until the spawned task calls channel.send
when let channel.Message(msg) = channel.receive(ch) else {
return
}
println(msg) -- hello
Timeout channels
channel.timeout(dur) creates a channel that automatically closes after the
given Duration. It is useful for adding deadlines to channel.select
operations.
let ch = channel.new(10)
let timer = channel.timeout(time.ms(5000)) -- closes after 5 seconds
match channel.select([channel.Recv(ch), channel.Recv(timer)]) {
(^ch, channel.Message(val)) -> println("got: {val}")
(^timer, channel.Closed) -> println("timed out after 5s")
_ -> ()
}
The timeout channel carries no values — it simply closes when the duration
elapses. Receiving from it will block until closure, at which point it
returns Closed. For the common case of a single receive with a deadline,
prefer channel.receive_timeout (next section) — it does the same race in one
call.
Receive with timeout: channel.receive_timeout(ch, dur)
channel.receive_timeout is a blocking receive with a deadline. It is the
direct form of the channel.timeout + channel.select pattern above when
you only need to wait on one channel:
let ch = channel.new(10)
let _ = task.spawn { -> channel.send(ch, 42) }
match channel.receive_timeout(ch, time.ms(500)) {
channel.Message(val) -> println("got: {val}")
channel.Empty -> println("timed out")
_ -> println("channel closed")
}
Like the other receive builtins, it returns a ChannelResult(a):
Message(val)— a value was delivered withindur.Empty—durelapsed with no value and no close: whatchannel.try_receivegives when nothing is there now.Closed— the channel is closed and its buffer is empty.
A value that is there wins over an expired timer: one in the buffer, or one
held by a sender that waits on a rendezvous channel. The non-blocking path
is always tried first, so a ready value is never preempted by the deadline.
A Duration of zero gives try-receive semantics (no timer is scheduled): it
returns such a value, and Empty when there is none.
Negative durations are an error. Positive sub-millisecond durations are
rounded up to one millisecond, so the caller always waits at least one timer
tick.
The dur argument is a Duration built with time.ms / time.seconds
(the same shape task.deadline takes).
Signature: channel.receive_timeout(ch: Channel(a), dur: Duration) -> ChannelResult(a).
3. Tasks
Spawning: task.spawn(fn)
let handle = task.spawn { ->
let result = compute_something()
channel.send(ch, result)
}
task.spawn takes a zero-argument function and submits it as a lightweight task
to the thread pool. Spawning is cheap — it allocates a stack and frames, not an
OS thread. It returns a task.Handle value immediately.
The function is a closure: it captures variables from the surrounding scope.
Since all values in silt are immutable, sharing captured variables between the
spawning task and the spawned task is safe. Under the hood, captured values are
shared via Arc (atomic reference counting).
let multiplier = 10
let ch = channel.new(10)
let h = task.spawn { ->
-- captures `multiplier` and `ch` from outer scope
channel.send(ch, multiplier * 2)
}
task.join(h)
when let channel.Message(val) = channel.receive(ch) else {
return
} -- val = 20
Joining: task.join(handle)
let h = task.spawn { -> 42 }
let result = task.join(h) -- result = 42
task.join parks the current task until the spawned task completes, then
returns its result — the value of the last expression in the spawned function’s
body. While waiting, the OS thread runs other tasks.
If the spawned task panicked, errored, or was cancelled, task.join does
not return an Err value. It raises the failure as a runtime error of
the form joined task failed: <msg> at the call site. Silt has no
try/catch, so a joined failure is terminal for the joining task — design
cancellation-aware code to signal completion through a channel handshake or
sentinel value when cancellation is an expected outcome rather than an error.
Cancelling: task.cancel(handle)
let done = channel.new(1)
let h = task.spawn { ->
-- long-running work
channel.send(done, 42)
}
task.cancel(h)
-- task.join(h) would raise `joined task failed: cancelled` at this point;
-- instead, wait on the sentinel channel if you want a non-raising
-- "task is settled" signal.
task.cancel(h) sets the handle’s result to Err("cancelled") using
first-writer-wins semantics: if the task has already completed with some other
result, task.cancel leaves that result on the handle. Cancelling counts as
handling the task, though: if the task had already failed, the cancel
dismisses the failure, so it is not reported as a failure that nobody joined
and does not make the program exit with status 1 (see
Failures that nobody joins). A later
task.join(h) still raises the task’s own error, because the handle keeps
the result that came first. Its effect on the task
depends on where that task is at the moment of cancellation:
-
The task is waiting (parked in a channel receive or send, a
channel.select, atask.join, atime.sleep, I/O, or similar): its wait ends without taking or sending anything, and the task is ended there. A value sent afterwards goes to the next receiver, not to it. -
The task has never run (spawned, and not yet picked up by a worker): it is dropped without running.
-
The task is not waiting: it is running, or its time slice ran out and it is to run again, or its wait has just ended (a sender handed it a value, a receiver took the value it waited to send, the operation it waited for gave its result) and it has not run again. The task goes on with what it holds, for a bounded amount of further work. After a cancel a task takes nothing new; it may still give what it holds. It is ended:
- at its next operation that would take something, whether or not
the thing is there: a
channel.receive,channel.try_receiveorchannel.receive_timeout, the next message of achannel.each, the next element of a stream,tcp.accept(the connection stays with the listener), and achannel.selectof which noSendarm can be done at once (itsRecvarms, timeouts among them, are not tried); - at its next operation that would have to wait, with nothing sent; and at one that would start something which waits for the outside: reading or writing a file’s contents, the network, a database, standard input. Nothing is started: a file it was about to write is not written;
- at a call that would start a task to take in its place:
task.deadline(its function runs as a task of its own) and every stream function that starts a stage (stream.map,stream.takeand the other transforms, and the sources:stream.from_list,stream.tcp_lines, …). No task is started, so nothing is taken from the channel a stream would have been built over; - when it has run 2,000 steps (about 2,000 instructions, a few hundred rounds of a small loop), if it reaches none of these before. The steps are counted from the first time slice that begins after the cancel, so a task that was computing when the cancel came is ended at most one time slice and 2,000 steps later.
What is done in those steps is what gives: a
channel.sendinto a channel with room or to a receiver that waits,channel.try_send, aSendarm of achannel.selectthat can be done at once,channel.close; and thetask.joinof a task that has ended. It can still start a task withtask.spawn. Atime.sleepof no time is done, since it waits for nothing; any longer one is a wait. (Thefsfunctions, which never wait, are done too:fs.exists,fs.remove,fs.renameand the others.) The side effects of those steps happen. What the task returns or fails with in them changes nothing: the handle keeps the cancellation, and a failure is not reported.This is the rule of a function that is past its
task.deadline, and the same count: a task that is past its deadline and cancelled has 2,000 steps in all, not twice that. - at its next operation that would take something, whether or not
the thing is there: a
What the further work is for, and what it does not give. A task that was just given something has the steps to pass it on by an operation that does not wait: a worker that receives an item and puts it, or its result, into a channel with room does not lose the item to a cancel, and takes no further one. A cancel is still no orderly shutdown. A worker that receives an item, computes, and then has to wait to send its result (its output channel is full, or is a rendezvous channel whose receiver is not there yet) is ended in that send, and the item is gone; so is the item of a worker that computes on it for more than 2,000 steps, or that has to read or write a file for it. Where nothing may be lost when the work stops, tell the task to stop over a channel (a stop message, or the close of its input), let it finish what it has, and join it.
In every case a task.join(h) on the cancelled handle raises joined task failed: cancelled as a runtime error, at once: the handle has its result
when task.cancel returns, whatever the task still does. This matches
the task.cancel section of docs/stdlib/channel-task.md; the two are
kept in sync deliberately.
task.cancel does not wait for the task to stop: the task’s last steps
may still run after it returns. Because task.join raises on
a cancelled handle (it does not return Err("cancelled") as a value), you
typically want one of two patterns when cancellation is an expected outcome:
-
Sentinel channel handshake — have the task signal completion through a channel before it exits, and wait on that channel instead of
task.join:task.cancel(h) -- `task.join(h)` here would raise `joined task failed: cancelled`. -- If you only need to know the cancel was acknowledged and you do not -- need the task's value, omit the join and rely on the sentinel -- channel from the task body for your "settled" signal. -
Join at the boundary — if you do call
task.joinon a cancelled handle, accept that it will raisejoined task failed: cancelledat the call site and scope the surrounding code so that failure is the expected exit path.
Returns Unit.
When a program ends
A program ends when main has returned and its tasks can do no more:
every task has ended or waits, and none of them waits for a sleep, a
timeout or an I/O operation that is still pending. Until then silt run
keeps going:
- A task that is still at work when
mainreturns runs to its end. What it prints is part of the program’s output; if it fails, the run fails. - A task that sleeps, or waits for a timeout or for I/O, is waited for.
A
channel.timeoutchannel counts only while a task waits on it: one that lost its race in aselect, or that nobody looks at any more, holds nothing up, however long it still has to run. - A wait under a
task.deadlineis such a wait: it ends when the deadline has passed at the latest, so it is waited for. A task that nobody joined and that sits intask.deadline(time.hours(1), { -> channel.receive(ch) }), on a channel nobody will send on, keeps the program going for that hour aftermainhas returned; without the deadline it would be dropped at once, as a wait that can never end. - A task that waits for something only another task could give (a receive on a channel nobody will send on) is dropped when nothing else can run: such a wait could never end.
- A task that never stops keeps the program from ending. A ticker, a
server loop or a watcher has to be told to stop, or cancelled, before
mainreturns. That holds for a task that waits in I/O as well (tcp.accept,io.read_line): once it is cancelled nobody waits for the operation, which then counts for nothing. A server started withhttp.serveends when the task that called it is cancelled.
import channel
import task
import time
fn main() {
let ticks = channel.new(8)
let ticker = task.spawn { ->
loop {
time.sleep(time.ms(10))
let _ = channel.try_send(ticks, ())
loop()
}
}
let _ = channel.receive(ticks)
println("one tick")
-- Without this the program would tick for ever.
task.cancel(ticker)
}open in playgroundIf main fails, with a runtime error or by returning Err, the program
has failed: nothing is waited for. Under silt test, each test ends in the same way, for the
tasks that the test spawned (see
Testing).
Failures that nobody joins
A task that ends with a runtime error (a panic, a failed assertion, a
division by zero, …) hands the error to whoever joins it. If the program
never joins the task and never cancels it, nobody receives the error. Silt
reports such a failure itself when the program ends, and silt run then
exits with status 1, even if main returned normally:
import task
fn main() {
let _ = task.spawn { -> 1 / 0 }
println("main done")
}open in playgroundmain done
error[runtime]: task <handle:0> failed and was never joined: division by zero
--> main.silt:4:27
|
4 | let _ = task.spawn { -> 1 / 0 }
| ^ task <handle:0> failed and was never joined: division by zero
= help: join the task with task.join to handle its error, or cancel it with task.cancel
The report is rendered like every other runtime error: the file and line where the task failed, the source line, and the call stack when there is more than one frame. The rules:
- A join handles the failure.
task.join(h)raises the task’s error in the joiner (joined task failed: <msg>), and the failure is not reported a second time. - A cancel handles the failure.
task.cancel(h)on a task that has failed dismisses the failure: no report, no exit status 1. - Every failure counts. The report is made when the program has ended
(see When a program ends): a task that fails
after
mainhas returned still makes the run fail, every time. The example above does not have to wait for its task. (A stage of a stream is different: its failure goes to whoever reads the stream, and is not reported if nobody does. See stream.) - A deadlock shows its cause. When
mainis tolddeadlock on main thread, the failures reported with it are usually the reason: a producer that failed before it sent.
Under silt test, the failure fails the test that spawned the task (see
Testing). In the REPL it is reported
when the session ends, since a later input may still join or cancel the
task; an input is not waited for beyond its own value, so the tasks it
spawned go on while you type the next.
Deadlines: task.deadline(dur, fn)
let outcome = task.deadline(time.ms(200)) { -> io.read_file("/var/log/slow.log") }
task.deadline(dur, f) runs f and gives Ok of what it returns, or
Err(Timeout) if f was ended because dur had passed.
The function runs as a task of its own, and the deadline is that task’s:
when dur has passed, its time is over. From then on the function
takes nothing new; it may still give what it holds, for a small,
fixed amount of further work. It is ended where it would take something
or would have to wait, and a function that does neither is ended after
that work. That is one rule for everything f does, whatever it waits
for: I/O (see Blocking operations), a
time.sleep, a channel.receive or channel.send, a channel.select,
a task.join, a wait deep inside the callback of a list.fold, or the
read of a stream. A channel.receive that nobody will ever answer is no
deadlock inside a deadline: it ends when the deadline has passed. It is
the rule of a task that was cancelled (see
Cancelling), which lists what takes and
what gives.
- A wait ends one way or the other, never both. Either the wait got
what it waited for (the value was received, the send was done, the
operation gave its result), and the function goes on with it; or the
deadline ended the wait, nothing was received and nothing was sent, and
the function is ended there: no further instruction of it runs. So a
receive under a deadline that answers
Err(Timeout)took no value, and a send that answersErr(Timeout)sent none: a loop that tries again after a timeout loses nothing and sends nothing twice. task.deadlineanswers when the function has ended, and says how: it returned (Ok), it was ended by its deadline (Err(Timeout)), or it failed. A function that got its value just in time and returns it isOk, even though the clock has moved past the deadline meanwhile: the answer never contradicts what the function did. It comes at most two time slices of the function after the deadline.- When the time is over the function takes nothing new. An
operation that would take something ends it there,
Err(Timeout), whether or not the thing is there: achannel.receive,channel.try_receiveorchannel.receive_timeout, the next message of achannel.each, the next element of a stream,tcp.accept, achannel.selectof which noSendarm can be done at once (itsRecvarms are not tried). So a receive begun when the time is over is a timeout, every time, and the value stays in its channel. (The function’s time is over from the first time slice that begins after it; in the slice in which it passes, a receive of a value that is there may still complete.) Nor does the function start anything that would take or wait in its place: an operation that waits for the outside (a file’s contents, the network, a database, standard input) is not started, a stream function that would start a stage starts none, and atask.deadlineit calls ends it there, before the inner function is started. - It may still give what it holds. A
channel.sendinto a channel with room or to a receiver that waits,channel.try_send, aSendarm of achannel.selectthat can be done at once andchannel.closeare done; so are thetask.joinof a task that has ended andtask.spawn. That is what a function that got its value just in time has to hand it on. - A duration of zero or less is a time that is over from the start,
and the rule is the same:
task.deadline(time.ms(0), { -> 1 })isOk(1), a function that sends into a channel with room isOk, and a function that receives isErr(Timeout)whether the channel is empty or not. - The work past the time is bounded in all. A function whose time is over runs on for at most 2,000 steps: about 2,000 instructions, a few hundred rounds of a small loop. That is what a function which got its value just in time has to return it, or to hand it on. Every step counts, whatever the function does with it: computing, sends that are done at once. When the steps are used up the function is ended: after the step that used them up, or at the next wait it begins, of which nothing is done then. (The steps are counted from the first time slice that begins when the time is over, so a function that computes is ended at most 2,000 steps and one time slice past its time. One call of a builtin that is under way, the sort of a large list, runs to its end first.) So a function that received a value and, instead of returning it or handing it on, goes on working past its time is ended with the value in hand: a worker that needs more than those steps for the item it holds, or has to wait to send it, loses that item, as after a cancel.
- A failure of the function is the caller’s: a runtime error inside
fis raised bytask.deadlineas it is, at the place infwhere it happened, whatever the clock says. A function that fails is never reported as timed out. - I/O that was given up may have happened. An I/O operation in flight
when the function is ended is given up as for a cancelled task (see
the I/O pool): a
TCP connection it was reading or writing is shut down, and what the
read had taken off the wire is gone with it; a file write, an HTTP
request or a database statement that had started may complete all the
same, and a line that an
io.read_linereads after it was given up is nobody’s.Err(Timeout)says that the function did not finish, not that nothing of it reached the outside. A connection that reaches atcp.acceptwhich was given up is not lost: the listener keeps it for the next accept. - Deadlines nest: the function of an inner
task.deadlinehas the outer time too, if that is earlier, and is ended by the same rule. When the inner time passes first, the outer function goes on with the innerErr(Timeout)as a value; when the outer time does, both functions are over and the outertask.deadlinegivesErr(Timeout). The steps past that time are one count for the two. (A function whose time is already over cannot begin an inner deadline: the call ends it.) - A deadline ends its function and nothing else. A task that the
function started with
task.spawngoes on when the function is ended, and so does a task the function was joining: the join is ended, the task is not. Who wants them ended keeps their handles and cancels them withtask.cancel. A task that goes on and fails later, with nobody left to join it, is a failure that nobody joined (see Failures that nobody joins). The stages of a stream the function built go on until nobody reads them. - The value of
fis inside theOk, so a function that returns aResultgives two layers:Ok(Err(io.NotFound(path)))is a read that failed in time,Err(task.Timeout)one that did not finish.
SILT_IO_TIMEOUT is another thing: it bounds each single wait for I/O,
for the whole process, and gives the module’s own timeout error
(see I/O timeouts).
import io
import task
import time
fn main() {
let outcome = task.deadline(time.ms(200)) { -> io.read_file("/var/log/slow.log") }
match outcome {
Ok(Ok(contents)) -> println(contents)
Ok(Err(e)) -> println("the read failed: {e}")
Err(task.Timeout) -> println("the read took too long")
Err(e) -> println(e.message())
}
}open in playgroundA task with a deadline is the two together:
task.spawn { -> task.deadline(dur, f) }, whose handle joins to the same
Result. That is the shape for fan-out where each child must bound its
own run time, such as racing replicas and dropping the stragglers.
Signature: task.deadline(dur: Duration, f: Fn() -> a) -> Result(a, task.TaskError).
4. Select
channel.select(ops)
channel.select lets a task wait on multiple channel operations at once. It
takes a list of ChannelOp(a) values and returns a tuple of (channel, status)
for whichever operation can make progress first. Each op is built with one of
two constructors:
Recv(ch)— a receive arm that becomes ready whenchhas data, has a rendezvous sender parked on it, or is closed.Send(ch, value)— a send arm that becomes ready whenchhas buffer capacity or a rendezvous receiver parked on it.
match channel.select([channel.Recv(ch1), channel.Recv(ch2)]) {
(^ch1, channel.Message(val)) -> handle_input(val)
(^ch2, channel.Message(val)) -> handle_other(val)
(_, channel.Closed) -> println("all done")
_ -> ()
}
The return value is a 2-tuple:
- First element: the channel that produced the result.
- Second element: one of:
Message(value)— aRecvarm receivedvalue.Closed— aRecvarm’s channel is closed and drained.Sent— aSendarm completed (value was handed off).
The pin operator ^
The ^ (pin) operator matches against the current value of an existing
variable, rather than creating a new binding. This is how you identify which
channel fired:
let urgent = channel.new(5)
let normal = channel.new(5)
channel.send(urgent, "alert!")
channel.send(normal, "status ok")
match channel.select([channel.Recv(urgent), channel.Recv(normal)]) {
(^urgent, channel.Message(msg)) -> println("URGENT: {msg}")
(^normal, channel.Message(msg)) -> println("normal: {msg}")
(_, channel.Closed) -> println("all closed")
_ -> println("no message")
}
^urgent means “match if this is the same channel as the variable urgent,”
not “bind a new variable called urgent.” The pin operator works in any pattern
position, not just with channel.select.
Wildcard matching
You do not always care which channel fired. Use _ to match any channel:
match channel.select([channel.Recv(ch1), channel.Recv(ch2)]) {
(_, channel.Message(val)) -> println("got {val} from somewhere")
(_, channel.Closed) -> println("all done")
_ -> ()
}
Variable binding
You can also bind the channel to a new variable to inspect it later. A channel
has no Display, so log the payload (or a counter) instead of the
channel itself:
match channel.select([channel.Recv(ch1), channel.Recv(ch2)]) {
(_source, channel.Message(val)) -> println("got {val}")
(_, channel.Closed) -> println("all done")
_ -> ()
}
How select works internally
channel.select picks a ready branch at random: it sweeps the list starting
from a pseudo-random index, so if multiple channels are ready, any one of them
may win. Readers must not assume that earlier entries in the list have
priority — the choice is fair, not ordered.
If no branch is ready, the task is parked on all of them at once. Whoever
makes one possible (a sender, a receiver, a close) completes that branch for
the select, and only that one: a select performs exactly one of its
operations. A closed channel is ready for a Recv: that branch gives
(channel, Closed). A Send on a closed channel is no branch at all: a
select that has one is the runtime error of a send on a closed channel,
whatever its other branches could do, and so is one whose channel is
closed while it waits to send.
A select never meets itself: a Send and a Recv on the same channel in
one select do not complete each other. If no task can make progress, the
deadlock is reported.
A stream stage counts as a task here. A wait on the output of a pipeline
that can never deliver and never closes (the usual cause is a channel
feeding the pipeline that was never closed with channel.close) is a
deadlock like any other, and is reported.
5. Patterns
Producer/consumer
The fundamental pattern. One task produces data, another consumes it, connected by a channel.
Producer ----> [channel] ----> Consumer
import channel
import task
fn main() {
let ch = channel.new(10)
let producer = task.spawn { ->
channel.send(ch, "hello")
channel.send(ch, "from")
channel.send(ch, "silt")
channel.close(ch)
}
let consumer = task.spawn { -> channel.each(ch) { msg -> println(msg) } }
task.join(producer)
task.join(consumer)
}
-- prints:
-- hello
-- from
-- siltopen in playgroundThe producer sends values and closes the channel when done. The consumer uses
channel.each to drain the channel, which terminates when the channel closes.
Fan-out / fan-in with workers
Distribute work across multiple tasks, then collect results.
+---> [Worker 1] ---+
| |
[jobs] ------+---> [Worker 2] ---+------> [results]
| |
+---> [Worker 3] ---+
import channel
import list
import task
fn main() {
let jobs = channel.new(10)
let results = channel.new(10)
-- Enqueue work
channel.send(jobs, 10)
channel.send(jobs, 20)
channel.send(jobs, 30)
channel.send(jobs, 40)
channel.send(jobs, 50)
channel.send(jobs, 60)
channel.close(jobs)
-- Spawn three workers
let workers = [1, 2, 3]
|> list.map { id -> task.spawn { -> channel.each(jobs) { n -> channel.send(results, n * 2) } } }
-- Wait for all workers to finish, then close results
workers |> list.each { w -> task.join(w) }
channel.close(results)
-- Collect
channel.each(results) { r -> println(r) }
}open in playgroundWhen multiple tasks call channel.each on the same channel, the scheduler
distributes messages fairly across them: every worker gets a turn, no worker
starves. channel.each yields after processing each message, so the runtime
can hand the next message to a different waiter.
The distribution is fair, not ordered. With three workers and six messages no worker is guaranteed exactly two — on any given run the actual split depends on scheduler timing, and one worker may handle several messages before another sees one. Treat the workers as an interchangeable pool, not a deterministic round-robin.
Pipeline processing
Connect tasks in a linear chain where each stage reads from one channel and writes to the next.
[Stage 1] ----> [ch1] ----> [Stage 2] ----> [ch2] ----> [Stage 3]
import channel
import task
fn main() {
let raw = channel.new(10)
let doubled = channel.new(10)
-- Stage 1: produce raw values
let s1 = task.spawn { ->
channel.send(raw, 1)
channel.send(raw, 2)
channel.send(raw, 3)
channel.close(raw)
}
-- Stage 2: double each value
let s2 = task.spawn { ->
channel.each(raw) { n -> channel.send(doubled, n * 2) }
channel.close(doubled)
}
-- Stage 3: consume the doubled values
let s3 = task.spawn { -> channel.each(doubled) { n -> println("stage 3 got: {n}") } }
task.join(s1)
task.join(s2)
task.join(s3)
}
-- prints:
-- stage 3 got: 2
-- stage 3 got: 4
-- stage 3 got: 6open in playgroundEach stage only knows about its input and output channels. Adding a stage in the middle means inserting a new channel and a new task — nothing else changes.
Multiplexing with select
Use channel.select to merge multiple input streams. When more than one
channel is ready, select picks a branch fairly at random — every ready
branch has an equal chance of being chosen, so you cannot encode priority by
ordering the list.
import channel
import task
fn main() {
let alerts = channel.new(5)
let logs = channel.new(5)
let _ = task.spawn { ->
channel.send(logs, "background task done")
channel.send(logs, "log rotation complete")
channel.close(logs)
}
let _ = task.spawn { ->
channel.send(alerts, "disk full!")
channel.close(alerts)
}
-- Merge both streams into a single handler. Over many runs, messages from
-- `alerts` and `logs` interleave fairly -- neither channel starves the other.
-- `loop _ = () { ... loop(()) }` re-enters explicitly; a bare `loop {}`
-- without a `loop(...)` recursion runs its body only once.
loop _ = () {
match channel.select([channel.Recv(alerts), channel.Recv(logs)]) {
(^alerts, channel.Message(msg)) -> {
println("alert: {msg}")
loop(())
}
(^logs, channel.Message(msg)) -> {
println("log: {msg}")
loop(())
}
(_, channel.Closed) -> {
println("a channel closed")
return ()
}
_ -> loop(())
}
}
}open in playgroundIf you genuinely need priority semantics (for example, “always drain alerts
before touching logs”), check the high-priority channel first with
channel.try_receive and fall back to channel.select only when it is
empty. select itself gives you fairness, not ordering.
Graceful shutdown via channel.close
Use channel.close to signal that a pipeline stage is done. Downstream stages
detect closure via channel.each (which terminates) or by matching Closed
on channel.receive.
import channel
import task
fn main() {
let work = channel.new(10)
let done = channel.new(1)
-- Worker processes until the work channel closes
let worker = task.spawn { ->
channel.each(work) { item -> println("processing: {item}") }
-- channel.each returned, meaning work is closed
channel.send(done, "shutdown complete")
}
-- Send some work, then close
channel.send(work, "task-a")
channel.send(work, "task-b")
channel.send(work, "task-c")
channel.close(work)
-- Wait for the worker to finish
task.join(worker)
when let channel.Message(status) = channel.receive(done) else {
return
}
println(status)
}
-- prints:
-- processing: task-a
-- processing: task-b
-- processing: task-c
-- shutdown completeopen in playgroundThis pattern avoids the need for sentinel values or “poison pills.” The channel’s closed state is the shutdown signal.
Spawn, work, join
The simplest pattern: spawn several tasks, let them do work, join them all.
import channel
import list
import task
fn main() {
let results = channel.new(10)
let workers = [1, 2, 3] |> list.map { id -> task.spawn { -> channel.send(results, id * 10) } }
workers |> list.each { w -> task.join(w) }
channel.close(results)
when let channel.Message(r1) = channel.receive(results) else {
return
}
when let channel.Message(r2) = channel.receive(results) else {
return
}
when let channel.Message(r3) = channel.receive(results) else {
return
}
println("results: {r1}, {r2}, {r3}")
-- output: results: 10, 20, 30 (in some order — the receive order is
-- a permutation of the spawn order, since the workers race to send)
}open in playgroundDraining with try_receive
When you have finished all producers and want to collect results without knowing the exact count:
import channel
fn drain(ch) {
match channel.try_receive(ch) {
channel.Message(val) -> {
println("got: {val}")
drain(ch)
}
channel.Empty -> println("no more data (channel still open)")
channel.Closed -> println("channel closed, all done")
_ -> ()
}
}
fn main() {
let ch = channel.new(10)
channel.send(ch, 1)
channel.send(ch, 2)
channel.send(ch, 3)
channel.close(ch)
drain(ch)
}
-- prints:
-- got: 1
-- got: 2
-- got: 3
-- channel closed, all doneopen in playgroundSupervision and restart
For long-running workers, treat failures as values. Give each worker a
fallible body that returns Result, report completion on an outcome
channel, and have a supervisor task decide whether to restart:
import channel
import task
type Outcome {
Finished,
Crashed(String),
}
fn do_job(id, job) {
when job != "poison" else {
return Err("worker {id} hit poison pill")
}
println("worker {id} handled {job}")
Ok(())
}
fn worker_body(id, jobs, outcomes) {
channel.each(jobs) { job ->
match do_job(id, job) {
Ok(_) -> ()
Err(msg) -> {
channel.send(outcomes, (id, Crashed(msg)))
return
}
}
}
channel.send(outcomes, (id, Finished))
}
fn spawn_worker(id, jobs, outcomes) {
let _ = task.spawn { -> worker_body(id, jobs, outcomes) }
}
fn supervise(jobs, outcomes, outstanding, remaining_restarts) {
when outstanding > 0 else {
return
}
when let channel.Message((id, outcome)) = channel.receive(outcomes) else {
return
}
match outcome {
Finished -> supervise(jobs, outcomes, outstanding - 1, remaining_restarts)
Crashed(msg) -> {
println("worker {id} failed: {msg}")
when remaining_restarts > 0 else {
-- restart budget exhausted: drop this worker without respawning
supervise(jobs, outcomes, outstanding - 1, remaining_restarts)
return
}
spawn_worker(id, jobs, outcomes)
-- restart replaces the crashed worker 1-for-1: outstanding unchanged
supervise(jobs, outcomes, outstanding, remaining_restarts - 1)
}
}
}
fn main() {
let jobs = channel.new(10)
let outcomes = channel.new(10)
spawn_worker(1, jobs, outcomes)
spawn_worker(2, jobs, outcomes)
-- Two workers are spawned, so two outcomes are expected before the
-- supervisor returns. Each restart-on-Crashed adds one more outcome
-- to wait for; the bounded restart budget caps that growth.
let sup = task.spawn { -> supervise(jobs, outcomes, 2, 3) }
channel.send(jobs, "a")
channel.send(jobs, "b")
channel.close(jobs)
task.join(sup)
}open in playgroundKey points:
- Failures are values on a channel. Workers catch their own errors
via
Resultand?, then send a typedOutcomeso the supervisor can decide policy without relying on panic propagation. - Supervisors are just tasks. There is no built-in supervision tree. Compose the policy you need (restart, circuit-break, escalate) in ordinary silt — here, bounded restarts via recursion.
- Cancel children on shutdown. If the supervisor is cancelled
(
task.cancel), cancel each child it spawned to avoid leaks. Track handles in a list and cancel them when the outcome channel closes. task.joinre-raises runtime errors. If a spawned task’s body panics with an unhandled runtime error,task.joinon its handle re-raises that error in the joining task. That is the “safety net” path — design for outcomes-as-values instead, and keeptask.joinfor awaiting clean completion.
6. Runtime Model
Lightweight tasks on a thread pool
Tasks are lightweight — each one is just a stack and frames, not an OS thread.
task.spawn submits a task to a fixed-size thread pool (sized to the number of
CPU cores). Many tasks are multiplexed onto a smaller number of OS threads, so
you can run thousands of concurrent tasks efficiently.
thread pool (N = CPU count)
┌──────────────┬──────────────┬──────────────┐
│ OS thread 1 │ OS thread 2 │ OS thread N │
│ │ │ │
│ task A │ task C │ task E │
│ task B │ task D │ task F │
│ ... │ ... │ ... │
└──────────────┴──────────────┴──────────────┘
When a task performs a blocking operation (channel receive, select, join), it is parked — the OS thread picks up another ready task instead of waiting. When the blocking condition is satisfied (e.g., a value arrives on the channel), the parked task is woken and rescheduled.
Scheduler and time slicing
Silt uses a cooperative scheduler — there is no wall-clock preemption. A task holds its worker thread until one of two things happens:
- It blocks on an I/O operation, channel send/receive/select,
task.join, ortime.sleep. The task is parked and the worker moves on to the next ready task. - It exhausts its opcode time slice. The scheduler runs each task for a fixed budget of bytecode instructions (default 2000 opcodes); when the budget runs out, the task is re-queued at the back of the run queue and the worker picks up whoever is next. This keeps CPU-heavy tasks from starving each other.
The slice budget can be set with the SILT_TIME_SLICE environment variable,
which is read once, when the program starts; its least value is 1. A
smaller value makes tasks take turns more often (for example,
SILT_TIME_SLICE=500 silt run main.silt). Its main use is testing and
debugging: with SILT_TIME_SLICE=1 a task gives way after every
instruction, which brings out the faults that depend on where a task is
interrupted.
One consequence: a task running a tight CPU loop with no blocking calls will still yield every ~2000 opcodes, but between those yield points it owns its worker. If you spawn fewer long CPU-bound tasks than you have workers, the remaining workers simply stay idle; the scheduler does not migrate a running task off a busy worker.
I/O timeouts (SILT_IO_TIMEOUT)
Silt’s I/O builtins (see the Blocking operations table below) normally block indefinitely while they wait on the OS. For deployments where a stuck syscall or hung remote peer must not freeze a task forever, a task’s wait for an I/O builtin can be given an end.
The SILT_IO_TIMEOUT environment variable sets one for every such wait.
Setting SILT_IO_TIMEOUT=5s makes every I/O
builtin that blocks longer than that duration return the module’s own typed
timeout variant instead of its normal value:
io.*andfs.*surfaceErr(Other("I/O timeout (SILT_IO_TIMEOUT exceeded)")).tcp.*surfacesErr(Timeout).http.*surfacesErr(Timeout).
The timeout is invisible at the language surface — no new syntax, no
colored functions — because every I/O builtin already returns Result with
the matching typed error enum. It is for waits for I/O only: a time.sleep
or a wait on a channel is not I/O and is not ended by it.
Accepted format: a suffixed duration only — 5s, 500ms, 2m, 1h.
A bare integer with no unit (e.g. SILT_IO_TIMEOUT=5000) is NOT accepted; it
fails to parse and leaves the waits without an end.
# Any I/O that stalls > 5s surfaces the module's typed timeout variant.
SILT_IO_TIMEOUT=5s silt run server.silt
To bound one piece of work rather than every wait of the process, use
task.deadline(dur, fn): it ends the
function it runs, whatever the function waits for, and says so with its own
Err(Timeout). The two are independent: inside a task.deadline, a wait
for I/O still ends at SILT_IO_TIMEOUT with the module’s error if that
comes first.
The I/O pool, and operations that nobody waits for
Blocking operations run on threads of their own, the I/O pool, so that
they never hold up the scheduler. The model is plain: an operation that
blocks has a thread for as long as it blocks. (silt has no
readiness-based I/O yet; a task that waits in tcp.read costs a thread,
about 25 KiB of memory with its task.) The pool grows and shrinks by
itself: an operation gets a thread at once, a new one if all are busy, and
a thread that finds no work for a few seconds ends. Four tasks that each
wait for a line from a silent peer do not delay a file read. There is
nothing to configure.
There is a bound: 4,096 operations in flight. One more is not queued;
it returns its module’s error at once (Err(Other("too many I/O operations in flight (4096)")), Err(Other(...)), …), because an
operation that waited for a thread might be the very one that would have
released the others. A server that holds more connections than that open
in blocking reads has to refuse some. (http.serve has one operation in
flight for its accept, and one for each connection that waits for a
request or sends a response.)
A task stops waiting for its operation when SILT_IO_TIMEOUT passes,
when it is cancelled by task.cancel, when it is the function of a
task.deadline whose time is up, or when it is
dropped at the end of the program. It resumes, or ends, at that moment.
What becomes of the operation depends on what it is:
- On a TCP connection (
tcp.read,tcp.read_exact,tcp.write, thestream.tcp_*sources and sink): the connection is shut down, as bytcp.close, which makes the operation return and frees its thread. The connection cannot be used afterwards: a read that timed out cannot be tried again. Give a read the time the peer may really take, and treat a timeout as the end of the connection. An operation whose result arrives in the very moment its task stops waiting loses it; the connection is shut down all the same. tcp.accept: the accept is woken and gives up; the listener stays usable, and a client’s connection that reached the accept as it was given up is kept by the listener for the next accept (it is closed with the listener if none comes). (An accept that waits costs nothing: its thread sleeps in the system until a connection comes. To wake it the listener connects to its own port on the loopback address; where that is not possible, say a packet filter forbids it, the thread stays until a client connects.)- Everything else (file I/O,
http.get,tcp.connect,io.read_line): the operation cannot be interrupted. It runs to its end in the background and its result is discarded; a write that had started completes. Its thread no longer counts as one of the pool’s, so operations that never return (a read of a terminal nobody types at) do not use the pool up. An operation that had not started yet never runs. Such stuck operations have a bound of their own, 64: while that many are still running unheard, every new I/O operation fails at once with an error that says so, instead of the program taking a thread for each without end.
Blocking operations
| Operation | Parks when |
|---|---|
channel.send(ch, val) | Buffer is full — resumes when space opens |
channel.receive(ch) | Buffer is empty — resumes when a value arrives or the channel closes |
task.join(handle) | Task not yet complete — resumes when the task finishes |
channel.select([...ops]) | No Recv/Send op is ready — resumes when any becomes ready |
time.sleep(duration) | Always — parks the task for the given duration, then resumes |
io.read_file(path) | Always (file I/O) |
io.write_file(path, content) | Always (file I/O) |
io.read_line() | Always (stdin) |
http.get(url) | Always (network) |
http.request(...) | Always (network) |
None of these block the OS thread. The task is parked and the thread continues running other tasks.
I/O operations follow the same transparent yielding pattern as channel
operations — no special syntax needed. When code calls io.read_file or
http.get, the operation is dispatched to an I/O pool and the task is
parked until the result is ready. main is a task like the others here:
its I/O and its time.sleep are waits too.
Deadlock detection
A program is deadlocked when main waits (a channel.send,
channel.receive, channel.each, channel.select or task.join), every
other task waits too, and nothing is pending that could end one of the
waits: no timer and no I/O operation. silt sees that at the moment it
becomes true — when the last task parks or ends, or the last timer has
fired — and the wait of main fails with a runtime error that starts with
deadlock on main thread; the program exits with status 1. Together with
that error, silt reports the tasks that failed and that nobody joined
(silt run before it, silt test under the failing test, after it; see
Failures that nobody joins): a failed
producer is the usual reason why a counterpart is missing.
A wait that will end by itself counts as able to go on: a time.sleep, a
channel.receive_timeout that has not expired, a wait on a channel.timeout
channel that has not closed yet (a channel.timeout that no task waits on
does not count: it could wake nobody). While a task is in such a wait, no
deadlock is reported, whether or not that task could ever reach the channel
that main waits on. The verdict comes when the wait has ended, if the
program is still stuck then. So a background task that sleeps for a minute
delays the report of an unrelated deadlock by up to a minute. The same holds
for a task that waits for an I/O operation. Only main gets a deadlock verdict; tasks that are stuck while
main is not waiting stay parked.
Implications of real parallelism
- True parallelism. Multiple tasks execute simultaneously on different CPU cores. CPU-bound work benefits from spawning tasks.
- Lightweight spawning. Spawning a task allocates a stack, not an OS thread. You can have tens of thousands of concurrent tasks.
- Non-deterministic ordering. The same inputs may produce different interleavings across runs.
- No data races. All silt values are immutable, so sending a value through a channel is always safe. There is no shared mutable state.
- No colored functions. Unlike async/await, there is no function coloring.
channel.sendandchannel.receivelook and act like normal function calls. I/O operations likeio.read_fileandhttp.getalso transparently yield to the scheduler — there is noasync io.read_fileor special I/O monad.
7. Limitations and Future Work
Current limitations
- No buffered channel resizing. A channel’s capacity is fixed at creation time.
Quick Reference
| Operation | Syntax | Returns |
|---|---|---|
| Create channel | channel.new(0) / channel.new(n) | Channel |
| Send (blocking) | channel.send(ch, val) | Unit |
| Receive (blocking) | channel.receive(ch) | channel.ChannelResult(a) — channel.Message(val) or channel.Closed in practice; channel.Empty/channel.Sent are unreachable here but still required by exhaustiveness |
| Close | channel.close(ch) | Unit |
| Try send | channel.try_send(ch, val) | true or false |
| Try receive | channel.try_receive(ch) | channel.ChannelResult(a) — channel.Message(val), channel.Empty, or channel.Closed in practice; channel.Sent is unreachable here but still required by exhaustiveness |
| Iterate | channel.each(ch) { val -> ... } | Unit (when closed) |
| Select | channel.select([channel.Recv(ch1), channel.Send(ch2, v)]) | (channel, channel.Message(val)), (channel, channel.Closed), (channel, channel.Sent) |
| Timeout channel | channel.timeout(dur) | Channel (closes after the Duration) |
| Receive with timeout | channel.receive_timeout(ch, dur) | channel.Message(val), channel.Empty (nothing came in time), or channel.Closed; a buffered value wins over an expired timer |
| Spawn task | task.spawn({ -> ... }) | task.Handle(a) |
| Join task | task.join(handle) | Task’s return value (raises joined task failed: <msg> if the task errored or was cancelled) |
| Cancel task | task.cancel(handle) | Unit (a failure of the task is then not reported) |
| Deadline | task.deadline(dur, { -> ... }) | Ok(value) of the function, or Err(task.Timeout) if it was ended because its time was over |
The mental model: tasks are independent workers, channels are the pipes between
them, channel.select is a multiplexer, and task.join is a synchronization
barrier. Tasks are lightweight and multiplexed onto a fixed thread pool, giving
you true parallelism without the cost of one OS thread per task.