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:

How CSP compares to other models

ModelKey ideaTradeoff
Threads + locksShared memory protected by mutexesDeadlocks, data races, hard to reason about
Async/awaitCooperative futures on an event loopColored functions, viral async, complex lifetimes
ActorsEach actor has private state, communicates via mailboxesUntyped messages, hard to do request/response
CSP (silt)Independent tasks, typed channels, channel.selectNo 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()

-- Buffered channel with capacity 10
let ch = channel.new(10)

channel.new() 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 Message(msg) = channel.receive(ch) else { return }

channel.receive takes one value from the channel’s buffer. It returns one of two variants:

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:

Returns Unit.

Non-blocking send: channel.try_send(ch, value)

let ch = channel.new(2)
channel.try_send(ch, "a")   -- true
channel.try_send(ch, "b")   -- true
channel.try_send(ch, "c")   -- false (buffer full)

Attempts to send without blocking. Returns true if the value was placed in the buffer, or false if the buffer is full or the channel is closed. Never yields to the scheduler.

Non-blocking receive: channel.try_receive(ch)

let ch = channel.new(10)
channel.send(ch, 42)

channel.try_receive(ch)   -- Message(42)
channel.try_receive(ch)   -- Empty

Attempts to receive without blocking. Returns one of three variants:

This lets you distinguish “nothing right now” from “nothing ever again.”

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() 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. This provides the strongest synchronization guarantee: when channel.send returns, you know the receiver has accepted the value.

let ch = channel.new()   -- capacity 0, true rendezvous

task.spawn(fn() {
  -- this blocks until the main task calls channel.receive
  channel.send(ch, "hello")
})

-- this blocks until the spawned task calls channel.send
when let Message(msg) = channel.receive(ch) else { return }
println(msg)  -- "hello"

Timeout channels

channel.timeout(ms) creates a channel that automatically closes after the given number of milliseconds. It is useful for adding deadlines to channel.select operations.

let ch = channel.new(10)
let timer = channel.timeout(5000)  -- closes after 5 seconds

match channel.select([ch, timer]) {
  (^ch, Message(val))  -> println("got: {val}")
  (^timer, 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.

3. Tasks

Spawning: task.spawn(fn)

let handle = task.spawn(fn() {
  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 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(fn() {
  -- captures `multiplier` and `ch` from outer scope
  channel.send(ch, multiplier * 2)
})

task.join(h)
when let Message(val) = channel.receive(ch) else { return }  -- val = 20

Joining: task.join(handle)

let h = task.spawn(fn() { 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 failed with a runtime error, task.join propagates the error.

Cancelling: task.cancel(handle)

let h = task.spawn(fn() {
  -- long-running work
  42
})
task.cancel(h)
-- h is marked as Cancelled; it will not run further

task.cancel marks a task as cancelled. The task is removed from the scheduler’s ready queue and will not execute again. Joining a cancelled task produces an error.

Returns Unit.

4. Select

channel.select(channels)

channel.select lets a task wait on multiple channels at once. It takes a list of channels and returns a tuple of (channel, status) for whichever channel has data first.

match channel.select([ch1, ch2]) {
  (^ch1, Message(val))  -> handle_input(val)
  (^ch2, Message(val))  -> handle_other(val)
  (_, Closed)           -> println("all done")
  _                     -> ()
}

The return value is a 2-tuple:

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([urgent, normal]) {
  (^urgent, Message(msg)) -> println("URGENT: {msg}")
  (^normal, Message(msg)) -> println("normal: {msg}")
  (_, 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([ch1, ch2]) {
  (_, Message(val)) -> println("got {val} from somewhere")
  (_, Closed)       -> println("all done")
}

Variable binding

You can also bind the channel to a new variable to inspect it later:

match channel.select([ch1, ch2]) {
  (source, Message(val)) -> println("got {val} from channel {source}")
  (_, 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 channel is ready, the task is parked until one of the channels becomes ready (via waker-based notification). If a closed channel is selected during the sweep, it returns (channel, Closed) for that channel. If no tasks can make progress and no channels have data, it detects a deadlock and reports an error.

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(fn() {
    channel.send(ch, "hello")
    channel.send(ch, "from")
    channel.send(ch, "silt")
    channel.close(ch)
  })

  let consumer = task.spawn(fn() {
    channel.each(ch) { msg ->
      println(msg)
    }
  })

  task.join(producer)
  task.join(consumer)
}
-- prints:
--   hello
--   from
--   silt

The 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(fn() {
      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)
  }
}

When multiple tasks call channel.each on the same channel, the scheduler distributes messages in round-robin order. With three workers and six messages, each worker processes exactly two: worker 1 gets messages 1 and 4, worker 2 gets 2 and 5, worker 3 gets 3 and 6. This happens because channel.each yields after processing each message, giving the next worker a turn.

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(fn() {
    channel.send(raw, 1)
    channel.send(raw, 2)
    channel.send(raw, 3)
    channel.close(raw)
  })

  -- Stage 2: double each value
  let s2 = task.spawn(fn() {
    channel.each(raw) { n ->
      channel.send(doubled, n * 2)
    }
    channel.close(doubled)
  })

  -- Stage 3: consume the doubled values
  let s3 = task.spawn(fn() {
    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: 6

Each 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)

  task.spawn(fn() {
    channel.send(logs, "background task done")
    channel.send(logs, "log rotation complete")
    channel.close(logs)
  })

  task.spawn(fn() {
    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 {
    match channel.select([alerts, logs]) {
      (^alerts, Message(msg)) -> println("alert: {msg}")
      (^logs,   Message(msg)) -> println("log: {msg}")
      (_, Closed) -> {
        println("a channel closed")
        return ()
      }
      _ -> ()
    }
  }
}

If 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(fn() {
    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 Message(status) = channel.receive(done) else { return }
  println(status)
}
-- prints:
--   processing: task-a
--   processing: task-b
--   processing: task-c
--   shutdown complete

This 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(fn() {
      channel.send(results, id * 10)
    })
  }

  workers |> list.each { w -> task.join(w) }
  channel.close(results)

  when let Message(r1) = channel.receive(results) else { return }
  when let Message(r2) = channel.receive(results) else { return }
  when let Message(r3) = channel.receive(results) else { return }
  println("results: {r1}, {r2}, {r3}")
  -- output: results: 10, 20, 30
}

Draining 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) {
    Message(val) -> {
      println("got: {val}")
      drain(ch)
    }
    Empty  -> println("no more data (channel still open)")
    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 done

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 1OS thread 2OS 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:

  1. It blocks on an I/O operation, channel send/receive/select, task.join, or time.sleep. The task is parked and the worker moves on to the next ready task.
  2. 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 is tunable via the SILT_TIME_SLICE environment variable (for example, SILT_TIME_SLICE=500 silt run main.silt for more frequent rotation, or a larger value to reduce scheduling overhead).

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, the scheduler runs a watchdog that can surface a timeout error to any I/O builtin that blocks for too long.

The SILT_IO_TIMEOUT environment variable enables this watchdog globally. Setting SILT_IO_TIMEOUT=5000 (or SILT_IO_TIMEOUT=5s) makes every I/O builtin that blocks longer than that duration return Err("I/O timeout (SILT_IO_TIMEOUT exceeded)") instead of its normal value. The deadline is invisible — no language-surface changes, no new syntax — because all blocking I/O builtins already return Result.

Accepted formats: bare integer (milliseconds), or a suffixed duration 5s, 500ms, 2m, 1h.

# Any I/O that stalls > 5s surfaces an Err(...) to the caller.
SILT_IO_TIMEOUT=5s silt run server.silt

For scoped, per-call deadlines rather than a process-wide cap, use task.deadline(dur, fn) or task.spawn_until(dur, fn). Scoped deadlines nest with SILT_IO_TIMEOUT; whichever elapses first fires, and the error message identifies the source so silt code can distinguish them.

Caveat: I/O threads continue after timeout

The watchdog is cooperative on the task side only. When the deadline elapses, the scheduler writes Err("I/O timeout (SILT_IO_TIMEOUT exceeded)") into the pending completion and wakes the parked task so it resumes with the timeout error. The OS thread in the I/O pool that dispatched the blocking syscall (for example io.read_file, http.get, or an io.write_file) is not interrupted — it continues executing the syscall until the kernel returns, then discards the result.

Consequences:

Mitigations:

Blocking operations

OperationParks 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([...])No channel has data — resumes when any channel 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 a spawned task 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. From the main thread, these operations block synchronously, just like channel operations.

Implications of real parallelism

7. Limitations and Future Work

Current limitations

Quick Reference

OperationSyntaxReturns
Create channelchannel.new() / channel.new(n)Channel
Send (blocking)channel.send(ch, val)Unit
Receive (blocking)channel.receive(ch)Message(val) or Closed
Closechannel.close(ch)Unit
Try sendchannel.try_send(ch, val)true or false
Try receivechannel.try_receive(ch)Message(val), Empty, or Closed
Iteratechannel.each(ch) { val -> ... }Unit (when closed)
Selectchannel.select([ch1, ch2])(channel, Message(val)), (channel, Closed)
Timeout channelchannel.timeout(ms)Channel (closes after ms milliseconds)
Spawn tasktask.spawn(fn() { ... })Handle
Join tasktask.join(handle)Task’s return value
Cancel tasktask.cancel(handle)Unit

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.