Channels
Concurrent programming in Synoema — typed, safe, composable
The Synoema channel API provides CSP-style message passing: Chan a is a typed first-class value. Seven builtins cover unbounded and bounded channels, multi-channel select, millisecond timeouts, and non-blocking operations — all running in the interpreter and JIT.
Channel basics
A channel is created with chan. It holds values of a single type a, inferred from usage. send puts a value in; recv blocks until one is available.
-- Create an unbounded channel
c = chan
-- Producer: sends a value
send c 42
-- Consumer: blocks until a value arrives
n = recv c -- n : Int = 42
Channels are first-class values. Pass them to spawned workers the same way you pass any other argument:
worker ch =
v = recv ch
print "got ${show v}"
main = scope {
c = chan
spawn (worker c)
send c 100
}
scope { ... } waits for all spawned goroutines to finish before returning. This guarantees the send completes before the program exits.
Bounded channels
bounded_chan : Int -> Chan a creates a channel with a fixed buffer size. send blocks when the buffer is full, providing automatic backpressure: the producer cannot run ahead of the consumer.
-- bounded_producer.sno
-- Producer/consumer with buffer size 4.
-- Producer blocks on send when buffer fills.
-- Expected result: sum(1..=20) = 210.
drive c n acc =
? n == 0 -> acc
: send c n; drive c (n - 1) (acc + recv c)
main =
c = bounded_chan 4
drive c 20 0
Without a bounded channel, a fast producer could exhaust memory. With one, it is automatically throttled to the consumer’s processing rate — no explicit synchronization code required.
| Channel type | Creation | Buffer | send blocks when… |
|---|---|---|---|
| Unbounded | chan | unlimited | never |
| Bounded | bounded_chan n | n slots | buffer full |
Select
select : [Chan a] -> (a, Int) waits on a list of channels and returns as soon as any one of them has a value. The result is a pair (value, channel_index) — the index tells you which channel fired.
-- channel_select.sno
-- Fan-in: three workers publish to their own channel.
-- select takes whichever value arrives first.
-- After three selects, all 100+200+300 = 600 have been collected.
pair_snd (MkPair _ v) = v
worker ch v = send ch v
main = scope {
c0 = chan
c1 = chan
c2 = chan
spawn (worker c0 100)
spawn (worker c1 200)
spawn (worker c2 300)
r1 = select [c0 c1 c2]
r2 = select [c0 c1 c2]
r3 = select [c0 c1 c2]
pair_snd r1 + pair_snd r2 + pair_snd r3
}
select uses a round-robin polling loop internally, so no channel starves. The implementation is compatible with both bounded and unbounded channels in the same list.
Timeouts
recv_timeout : Int -> Chan a -> Maybe a tries to receive from a channel within a millisecond deadline. Returns Just v on success, None on timeout.
-- channel_timeout.sno
-- Request/response with a hard deadline.
-- Worker doubles the value sent on req and replies on resp.
-- recv_timeout gives up after 1000 ms.
-- Expected result: 42 (worker always finishes in time here).
maybe_or d (Just v) = v
maybe_or d None = d
worker req resp =
n = recv req
send resp (n * 2)
main = scope {
req = chan
resp = chan
spawn (worker req resp)
send req 21
maybe_or (0 - 1) (recv_timeout resp 1000)
}
The timeout argument is in milliseconds. A negative timeout (0 - 1) is treated as zero — return immediately if nothing is available. Combine with maybe_or from the prelude to supply a fallback value.
Non-blocking operations
Two builtins let you probe a channel without ever blocking:
| Builtin | Type | Returns |
|---|---|---|
try_send | Chan a -> a -> Bool | true if the value was queued; false if buffer full (bounded) or would block |
try_recv | Chan a -> Maybe a | Just v if a value was available immediately; None otherwise |
-- Polling pattern: drain a channel without blocking the caller
drain c acc =
m = try_recv c
? m == None -> acc
: drain c (acc + 1)
-- Non-blocking push: drop if buffer is full
enqueue c v =
ok = try_send c v
? ok -> "queued" : "dropped"
Use try_send/try_recv when you need to poll periodically or implement a drop policy instead of backpressure.
Concurrency patterns
Producer / consumer
The simplest pattern: one goroutine produces values, another consumes them. Use bounded_chan when the producer can outrun the consumer.
producer c n =
? n == 0 -> send c (0 - 1) -- sentinel
: send c n; producer c (n - 1)
consumer c acc =
v = recv c
? v == (0 - 1) -> acc -- stop on sentinel
: consumer c (acc + v)
main = scope {
c = bounded_chan 8
spawn (producer c 100)
consumer c 0
}
Fan-out (one producer, multiple consumers)
Send work items to multiple workers via separate channels. Each worker receives from its own dedicated channel.
worker_fan id c results =
v = recv c
send results (id * 1000 + v)
main = scope {
c0 = chan
c1 = chan
results = chan
spawn (worker_fan 0 c0 results)
spawn (worker_fan 1 c1 results)
send c0 7
send c1 13
r0 = recv results
r1 = recv results
r0 + r1
}
Fan-in (multiple producers, one consumer)
Use select to merge output from multiple producers into a single consumer:
pair_snd (MkPair _ v) = v
collect c0 c1 c2 n acc =
? n == 0 -> acc
: v = pair_snd (select [c0 c1 c2])
collect c0 c1 c2 (n - 1) (acc + v)
main = scope {
c0 = chan
c1 = chan
c2 = chan
spawn (send c0 10)
spawn (send c1 20)
spawn (send c2 30)
collect c0 c1 c2 3 0 -- 60
}
Pipeline stages
Chain channels so each stage receives from the previous and sends to the next. Values flow through without any shared mutable state.
stage_double in_c out_c n =
? n == 0 -> ()
: v = recv in_c
send out_c (v * 2)
stage_double in_c out_c (n - 1)
main = scope {
raw = chan
doubled = chan
spawn (stage_double raw doubled 3)
send raw 5
send raw 10
send raw 15
a = recv doubled
b = recv doubled
c = recv doubled
a + b + c -- (10 + 20 + 30) = 60
}
API reference
| Builtin | Type | Description |
|---|---|---|
chan |
Chan a |
Create an unbounded channel |
bounded_chan |
Int -> Chan a |
Create a bounded channel with buffer size n |
send |
Chan a -> a -> Unit |
Send a value; blocks if channel is bounded and full |
recv |
Chan a -> a |
Receive a value; blocks until one is available |
select |
[Chan a] -> (a, Int) |
Wait on multiple channels; returns (value, channel index) |
recv_timeout |
Int -> Chan a -> Maybe a |
Receive within millisecond deadline; returns None on timeout |
try_send |
Chan a -> a -> Bool |
Non-blocking send; returns false if channel full |
try_recv |
Chan a -> Maybe a |
Non-blocking receive; returns None if empty |
All builtins are available in both the interpreter (sno run) and the JIT (sno jit). Channel handles are opaque integers under the hood; the type system guarantees type safety across send/recv pairs.
Next steps
- Language reference — full type system and syntax
- Examples — more concurrent programs
- User guide — setup and first programs
- MCP & RAG — generate concurrent code with an LLM