Repository navigation
Guide concurrency
Mirrored from docs/guides/concurrency.md in the repo — that is the canonical source; edits to this wiki page are overwritten when the wiki is re-synced.
Tycho is value-semantic: every call deep-copies its arguments in, copies its result
out, and runs against a private arena. That call convention is already a sound
thread boundary. Concurrency is the same convention run on other threads — after
the copy-in, a task shares zero bytes with its spawner, so race freedom falls out
of value semantics. There's no Sendable, no lifetime annotation, and no lock in
the language. (Erlang gets the same guarantee from per-process heaps and message
copying; Swift's Sendable/region machinery exists to patch first-class aliasing,
which Tycho doesn't have.)
This guarantee is over pure Tycho values — the world the transpiler owns. It
stops at the FFI boundary: a task calling C that mutates process-global or
static C state races just as it would in C, invisibly to the transpiler. Isolate
that state per thread (thread-local storage, as the core:crypto shim does) or
serialize the calls. The full analysis is in
rfc/ffi-threading-design-review.md.
The model does not remove deadlock or livelock: a recv with no live
sender and no close parks forever, undetected (see
Limits). Data races and use-after-free fall out of value
semantics; deadlock doesn't.
The model has four constructs:
-
spawn/wait— structured tasks with by-value capture and automatic join. -
parallel for— fork-join data parallelism with reductions. - channels — bounded lock-free queues, the one shared object.
-
select— multi-channel fan-in.
I'm upfront about the cost: every value crossing a thread boundary is a deep copy, the same rule as an ordinary call.
Concurrency works in both the C reference transpiler (tychoc) and the
self-hosted transpiler (tychoc0).
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
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 dies loudly at runtime (tycho: task already waited) — never undefined behavior.
total := 0
parallel for i in range(400000000): # K = ncpu chunk tasks; TYCHO_THREADS overrides
total += (i * 31 + 7) % 1000003 # reduction: chunk-local partial, folded at the join
Works over a range or a collection. The body lifts into a chunk procedure:
captures deep-copy into each task (that's the real per-chunk cost), 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, a 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.
On the compute-bound reduction this keeps up with C-pthreads here: 37 ms vs C's 36 ms, same peak RSS.
parallel for x in ch: drains a channel with K = ncpu() workers, each pulling
items until the channel is closed and drained:
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
This is the bounded-fan-out idiom — N items not known up front, at most cap
buffered, work spread across ncpu() workers — without you sizing a range
yourself. Each item is taken by exactly one worker (the MPMC queue), so integer
reductions are deterministic. The producer must close(ch) when done or the
workers park waiting for more. It desugars to a parallel for over range(0, ncpu()) whose body is for true: select { recv(ch, x): … ; closed: break }, so
the same fail-closed gates apply and the source must name a variable. ncpu()
(the fan-out width, overridable with TYCHO_THREADS) is also callable directly.
Worked example: tests/conc/workers.ty.
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): ...
None: ... # closed AND drained
close(ch)
n := wait(w)
A channel is a bounded lock-free MPMC queue, internally synchronized so value
semantics outside it are untouched. send deep-copies the payload into the
claimed cell's arena — 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 (per-cell
arenas recycle on reuse); capacity rounds up to a power of two.
Lifetime is structural. ch := channel(T, cap) is the only legal creation form,
and the creating scope frees the channel at exit — sound because the implicit
task joins run first (the finalizer stack is LIFO, so tasks declared after the
channel join before it frees). 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 die loudly.
for true:
select:
recv(jobs, j):
handle(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 on a bounded spin → yield → 1ms-park
ladder — a select cannot park on N condition variables, so worst-case wake
latency is ~1ms, fine for fan-in servers. break/continue inside an arm bind
to the user's enclosing loop.
A select is also allowed inside a parallel for body, giving parallel
work-stealing 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.
make bench-conc runs identical logic with cross-checked checksums in all four
languages (AMD Ryzen 7 7735HS, 16 hardware threads — full details and the honest
read in bench/conc/RESULTS.md):
| workload | tycho | C | Go | Rust |
|---|---|---|---|---|
| parreduce (4×10⁸ int reduce) | 37 ms / 1.6 MB | 36 ms / 1.6 MB | 62 ms | 43 ms |
| pipeline (10⁶ strings, 1→4 consumers) | 73 ms / 2.8 MB | 654 ms (mutex ring) | 91 ms / 7.5 MB | 141 ms |
pool (10⁶ jobs, parallel for x in ch:) |
150 ms / 2.7 MB | 1975 ms (mutex ring) | 157 ms / 2.3 MB | 395 ms |
parallel for is at C parity (within a millisecond, same peak RSS). The lock-free channels hold their own against
Go, and are faster than the other reference implementations in this benchmark, while paying two
deep copies per message that the others don't.
On the pool workload — a bounded-channel worker pool written as the single
line parallel for x in ch: — tycho is at Go parity, ~5% faster (150 vs
157 ms) against Go's hand-written for j := range jobs + WaitGroup pool, both
lock-free on the hot path. The C and Rust figures are just the honest cost of a naive
mutex channel and of std lacking an MPMC channel, respectively (see RESULTS.md);
they aren't a tycho win — the win is that the one-liner gets the tuned Vyukov
queue for free.
These are on purpose, not gaps I forgot about:
- 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 (a
recvwith no live sender and noclose) is your bug, and I don't detect it. A panic or abort in any task kills the whole process.
make conc runs every fixture natively, under AddressSanitizer + LeakSanitizer,
and under ThreadSanitizer against recorded goldens, and checks that tychoc and
tychoc0 produce the same outputs. It's part of make ci. The cross-language
benchmarks live in bench/conc/.
For contributors. The feature lives in both transpilers — src/tychoc.c and
compiler/tychoc0.ty emit different C dialects with identical semantics — plus
once in the shared runtime (runtime/tycho_rt.c).
Runtime. The block pool is thread-local, so allocation never contends; a
spawned thread flushes its pool before exiting, and wait frees a task's arena
tree on the waiting thread. A task is a thread-per-spawn handle carrying its root
arena, return slot, and a done flag (the double-wait backstop). A channel is a
Vyukov bounded MPMC ring: each cell has an atomic sequence counter and its own
arena; one CAS claims a cell, the deep copy then runs with no lock held, and a
sequence store publishes. Waiting is spin → sched_yield → 1ms timed park, with
a parked-waiter count gating the wake path, so the uncontended path does zero
syscalls and the check-then-park race costs at most one extra retry rather than a
lost wakeup. Generated programs #define _DEFAULT_SOURCE so clock_gettime /
sched_yield / nanosleep are visible.
Transpilers. tychoc interns Task/Channel as type ranges, registers spawn
sites at resolve (the lambda-lift pattern), and emits one args-struct +
trampoline per site; per-element-type send/recv wrappers bracket copy_into
around the runtime claim/commit; the implicit-join finalizers ride a codegen
stack mirroring the scope-arena stack; parallel for lifts its body into a chunk
procedure reusing the spawn machinery. tychoc0 represents the types as strings,
adds ESpawn/SParFor/SSelect AST variants, inlines the channel copies inside
the claim/commit bracket, and emits the finalizers LIFO at each scope exit.
Lineage. Structured spawn/await with no function colouring follows Hylo/Val's
structured-concurrency work (Val's first design required sink-only spawn
environments — exactly Tycho's only mode); Tycho skips the inout-to-disjoint-parts
machinery and gets the expressiveness from chunk-copy + merge at a copy cost that
is the design point. Share-nothing message copying is the Erlang/Pony model, and
the channel core is Vyukov's bounded MPMC queue.
The reader docs here are mirrored from /docs in the repo — the canonical source. · tycho-lang.com · MIT licensed
- Tutorial
- The Tycho thesis: value semantics makes implicit arenas work
- Architecture & project status
- From
mallocto implicit arenas
- Tycho language reference
- Basics
- Types
- Functions and closures
- Structs and tuples
- Enums, options, and
match - Arrays and slices
- Subscripts (user-defined projections)
- Maps
- Generics
- Concurrency
- FFI (calling C)
- Packages
- Builtins
- Memory model in the self-hosted compiler
- Tycho aggregates: arrays & structs under value semantics
- Maps with arbitrary value types
m[k]as a place: in-place map-value mutation- Generics (monomorphized)
- Concurrency in Tycho
- FFI — calling C from Tycho
- Packages & modules
- corelib — Tycho's standard library
- Performance: the self-hosted transpiler vs the C one
- Debugging generated C