Concurrency
Memory: Each task gets its own arena; arguments are deep-copied in and the result is copied out, so a task shares nothing with its spawner. This stops at the FFI boundary.
Concurrency is the ordinary call convention run on another thread: after the copy-in, a task shares nothing with its spawner, so separate tasks cannot race on Tycho values. There are four constructs: spawn/wait, parallel for, channels, and select.
The safety envelope
The rule first: the race-freedom guarantee covers pure Tycho values — the values the compiler copies — and nothing past the FFI.
Calling C drops the guarantee. The compiler checks the boundary types but cannot see what C code touches. If a C function reads or writes shared state, two tasks calling it race exactly as two C threads would.
Keep shared C state per thread or serialize the calls, and assume nothing about the C side's thread-safety. The analysis behind this rule is in rfc/ffi-threading-design-review.md.
spawn and wait
Rule: spawn f(args) starts the call as a task and returns a Task(T) handle. The argument list is the capture list — explicit and by value.
spawn (like wait and the other task words) is a reserved word, not a name: a function or variable cannot be called spawn. The full list is appendix B (a few words there, such as pass, are contextual rather than reserved and stay usable as names).
fn count(path: string) -> int:
return len(path)
t1 := spawn count("a.txt") # args deep-copied into the task's own arena
t2 := spawn count("b.txt") # runs in parallel
_total := wait(t1) + t2.wait() # blocks; result deep-copied back out
Tasks are affine and structured: a handle cannot be copied, reassigned, stored in a container, captured by a closure, or discarded; it is waited at most once (a second wait aborts), and any task still running when its variable's scope exits — block end, break/continue, early return, or_return — is joined there. A function can never return while its tasks run, and a task can never leak or detach.
parallel for
Rule: parallel for runs a loop body as chunk tasks and joins them at the end of the statement. Only a reduction accumulator may be written from the body; any other write to an outer variable is a compile error:
fn score(i: int) -> int:
return i % 7
total := 0
parallel for i in 0..<1000000: # K = min(ncpu(), 64) chunk tasks (TYCHO_THREADS overrides)
total += score(i) # reduction: chunk-local partials, folded at join
The stricter form names the width at the loop when the default is wrong for that loop: parallel(4) for i in 0..<n: runs exactly 4 chunk tasks. The literal must be 1..64 and is checked at compile time; a computed width aborts at run time rather than silently clamping. An explicit width beats TYCHO_THREADS, so it pins the one loop whose shape is known without changing how the rest of the program scales.
A reduction accumulator (+ or * on int/float) starts from the operator's identity per chunk and folds at the in-order join, so an integer result is identical for any thread count.
Channels
Rule: a channel is the one shared object — a bounded queue. send deep-copies the payload in (blocking when full) and recv copies it out, returning Option(T) where None means closed and drained:
fn consumer(ch: Channel(string)) -> int:
return 0
i := 1
ch := channel(string, 256) # bounded; the creating scope frees it
w := spawn consumer(ch) # fn consumer(ch: Channel(string)) -> int
ch.send("item-" + str(i)) # deep copy in (blocks when full)
match recv(ch): # deep copy out
Some(s):
println(s)
None: # closed and drained
println("drained")
close(ch)
_n := wait(w)
Channel memory is bounded by capacity × payload, for every element type. Channels cannot be returned, stored, or captured; the creating scope frees the channel after joining its tasks, so the handle can never dangle. A channel(T, cap) call is legal only as a declaration's direct right-hand side.
select
Rule: select waits on several channels at once — recv arms, plus an optional default (non-blocking) and closed (every listed channel closed and drained) arm:
fn do_job(j: int):
println("job " + str(j))
fn note(e: string):
println("event " + e)
jobs := channel(int, 4)
events := channel(string, 4)
close(jobs)
close(events)
for true:
select:
recv(jobs, j):
do_job(j)
recv(events, e):
note(e)
closed:
break
Every value crossing a thread boundary is a deep copy — the same rule as an ordinary call.
Working with it
The sections above are the whole model. What follows is the full rule set for each construct, the three cases the compiler warns about, and the deliberate limits. The model removes data races, not deadlock: a recv with no live sender and no close parks forever (see Limits). The compiler warns about the three cases it can prove — see What the compiler warns about — but proving the absence of a live sender in general is not attempted.
spawn / wait — structured tasks
fn count(path: string) -> int:
return len(path)
t := spawn count("a.txt") # args deep-copied into the task's arena, then the thread starts
u := spawn count("b.txt")
total := wait(t) + u.wait() # join; result deep-copied into the waiting scope; task arena freed
println(str(total))
spawn takes a direct call to a named function — the argument list *is* the capture list, explicit and by value. The result is Task(T), a type with no source syntax, so a handle can only live in a local. Tasks are affine and structured, meaning:
- Copying or rebinding a handle, reassigning it, storing it in any container or closure, discarding a bare
spawn, or spawning a builtin / extern /voidfunction / function withinoutparameters are all compile errors. - Implicit join: any task still unwaited when its variable's scope exits — block end, each loop iteration,
break,continue, earlyreturn,or_return— is joined and freed right there. A function can never return while its tasks run, and an unwaited task can never leak or detach. - A second
waiton the same task aborts at runtime (tycho: task already waited) — never undefined behavior.
parallel for — fork-join data parallelism
total := 0
parallel for i in 0..<400000000: # K = ncpu chunk tasks; TYCHO_THREADS overrides
total += (i * 31 + 7) % 1000003 # reduction: chunk-local partial, folded at the join
Works over 0..<N or a collection. Captures deep-copy into each task, and reduction accumulators — acc = acc + e / acc = acc * e and the +=/*= forms, on outer int/float locals, up to four — start from the operator's identity per chunk and fold at the in-order join. Integer results are therefore identical for any thread count; float reductions may reassociate, like every parallel reduce. Everything else fails closed: any other write to an outer variable, reading an accumulator mid-loop, break/return/or_return at the parallel-loop level, an inout of a capture, and range steps are all compile errors — there is nothing left to race on. All chunk tasks join inside the statement.
Naming the width. parallel(W) for … fixes the chunk count for one loop: parallel(4) for i in 0..<n: runs exactly four. The literal is checked at compile time and must be 1..64; a computed width aborts at run time rather than silently clamping. An explicit width beats TYCHO_THREADS: it pins the one loop whose shape is known (a short body where ncpu() chunks would cost more in copy-in than they save) without changing how the rest of the program scales. Otherwise the default stands.
#### Bounded fan-out over a channel
parallel for x in ch: drains a channel with K = ncpu() workers, each pulling items until the channel is closed and drained:
struct Job:
id: int
fn work(j: Job) -> int:
return j.id
fn produce(ch: Channel(Job), n: int) -> int:
for i := 0; i < n; i += 1:
send(ch, Job(i))
close(ch)
return n
n := 8
results := 0
jobs := channel(Job, 16) # cap bounds buffered work — backpressure
pr := spawn produce(jobs, n) # producer sends, then close(jobs) when done
parallel for j in jobs: # K = ncpu() workers share the one queue
results = results + work(j) # ordinary reduction, folded at the join
wait(pr) # join the producer before reporting
println(str(results))
This is the bounded-fan-out form — items not known up front, at most cap buffered, work spread across ncpu() workers — without sizing a 0..<N. Each item is taken by exactly one worker, so integer reductions are deterministic. The producer must close(ch) when done or the workers park waiting for more. The same fail-closed gates apply and the source must name a variable. ncpu() (online CPUs, overridable with TYCHO_THREADS) is also callable directly, but it is the *requested* width, not always the width used: the chunk count is min(ncpu(), N) and the compiler caps it at 64, so TYCHO_THREADS=100 makes ncpu() report 100 while 64 chunks run.
A parallel for body may also send on a captured channel — that is how a per-item result gets out of a loop whose only permitted outer write is a reduction. The capture is a scalar handle, so every chunk sends into the one queue; send blocks on a full channel, which is the loop's backpressure.
Channels — the one shared object
fn consumer(ch: Channel(string)) -> int:
return 0
i := 1
ch := channel(string, 256) # bounded; created here, freed at THIS scope's exit
w := spawn consumer(ch) # fn consumer(ch: Channel(string)) -> int
ch.send("item-" + str(i)) # deep copy IN (blocks when full; dies if closed)
match recv(ch): # deep copy OUT -> Option(T)
Some(s): println(s)
None: println("drained") # closed AND drained
close(ch)
n := wait(w)
println(str(n))
A channel is a bounded queue, internally synchronized so value semantics outside it are untouched. send deep-copies the payload in — for *every* element type, not just strings — and recv copies it out into the receiver's arena, returning Option(T) where None means closed and drained. Channel memory is bounded by capacity × payload.
Lifetime is structural. ch := channel(T, cap) is the only legal creation form, and the creating scope frees the channel at exit — the implicit task joins run first, so the channel outlives every task that uses it. A channel cannot be returned, stored in a container/field/closure, or reassigned; Channel(T) is valid parameter syntax so workers can take one. Send-after-close and double-close abort the program.
select — multi-channel fan-in
fn do_job(j: int):
println("job " + str(j))
fn note(e: string):
println("event " + e)
jobs := channel(int, 4)
events := channel(string, 4)
close(jobs)
close(events)
for true:
select:
recv(jobs, j):
do_job(j)
recv(events, e):
note(e)
closed: # every listed channel closed AND drained
break
Arms are one or more recv(ch, x):, plus at most one default: (runs when nothing is immediately ready, making the select non-blocking) and at most one closed:. Without default, select waits until one arm is ready. break/continue inside an arm bind to the *user's* enclosing loop.
A select is also allowed inside a parallel for body, giving parallel consumers: the chunk tasks share the captured channels (a channel is a scalar handle, passed by value — not deep-copied per chunk), so any chunk can take any ready item, and a reduction inside an arm folds at the join like any other. An early exit still cannot cross a chunk boundary, so return in an arm is rejected just as it is anywhere else in a parallel for body.
Server shape
select waits on channels, not on socket readiness; socket I/O still belongs in a task. Because a task handle cannot be stored, a server usually pre-spawns a fixed worker pool and feeds it accepted descriptors through a channel, rather than spawning one task per connection inside the accept loop.
Three rules keep that shape correct under load:
- Identify a connection by a generation-bearing ID, not by its raw fd. The OS can reuse a closed descriptor immediately; a queued event keyed only by fd can then be applied to the new connection.
- Order all replies for one connection through one writer. If normal requests go through a dispatcher but errors are written directly by the reader, pipelined requests can return out of order or lose a valid reply.
- Never block the
selectloop on a peer write. Put the blocking write in a per-connection writer task, or bound it; otherwise one client that stops reading stalls every other connection and new accepts.
Reuse each slot's buffers: root arenas live for the process, so a fresh per-connection ring on every accept grows RSS. The pool size is compile-time: task handles cannot be stored, so declare the maximum pool explicitly. net.write had no timeout before net.set_write_timeout_ms (0.9.1); with it, a connection-owning worker can bound the wait on a slow peer.
Measured
make bench-conc runs the same reduction, pipeline, and pool workloads in Tycho, C, Go, and Rust with cross-checked checksums. The numbers live in performance; the full table and its reading are in bench/conc/RESULTS.md.
What the compiler warns about
A channel handle can only reach code by being passed as an argument — it cannot be returned, stored in a struct/enum/container, captured by a closure, or rebound. So the set of code that can touch one channel is a closed graph rooted at its ch := channel(T, cap), and the compiler can prove that an operation appears nowhere in it. Three such cases warn:
jobs := channel(int, 16)
p := spawn produce(jobs, 50)
parallel for j in jobs: # producer never calls close(jobs)
total += j
warning: close(jobs) is never called, so this `closed:` arm can never run
(a `parallel for` over a channel ends only when it is closed)
- Nothing ever sends on a channel that has a *blocking* receive —
recv(ch), or aselectarm with nodefault:(a select withdefault:polls, so it never parks and never warns). - Nothing ever receives from a channel that is sent to: the send parks once the buffer fills.
- Nothing ever closes a channel a drain waits on — the
parallel for x in chhang above.
These are lints, not errors: they never reject a program. And they are *absence* proofs, never "this path might not send" — so a conditional send, a send behind a flag, or a send in a loop that may not run all suppress the warning. Anything the analysis cannot follow (a call it can't resolve) silences the channel entirely. Missing a real deadlock is expected; warning about a working program is a bug.
Limits (deliberate)
These limits are deliberate:
- No
async/awaitcolouring, and no actors — per-task arenas plus channels already isolate state. - No mutex or atomic in the language, and no work-stealing runtime: a blocked waiter is a parked OS thread.
- Deadlock is the caller's bug. Beyond the three provable cases above, the compiler does not detect it: a
recvwhose only sender took a branch that returns early parks forever and nothing warns. A panic or abort in any task kills the whole process.
Verification
make conc runs the concurrency fixtures with golden output under AddressSanitizer/LeakSanitizer and ThreadSanitizer.
How this is implemented — the runtime, the channel core, and the cost of a spawn — is in design-concurrency, for maintainers.