Synoema

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 Patterns

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 typeCreationBuffersend blocks when…
Unboundedchanunlimitednever
Boundedbounded_chan nn slotsbuffer 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:

BuiltinTypeReturns
try_sendChan a -> a -> Booltrue if the value was queued; false if buffer full (bounded) or would block
try_recvChan a -> Maybe aJust 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