TYCHO Search

Search documentation

    ↑ ↓ to move · Enter to open · Esc to close

    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:

    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:

    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)
    

    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:

    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.