Tya v0.42 Specification

This document is the in-progress specification for Tya v0.42. It is updated as each STEP of the v0.42 Tya Concurrency Epic lands.

Theme

v0.42 ships Tya Concurrency: lightweight tasks, structured concurrency, and inter-task communication. The language adds three keywords (spawn, await, scope); the standard library gains two modules (channel and sync).

The runtime extends the v0.41 mark-and-sweep collector for multiple worker threads and adds a task value type. v0.42 ships a 1:1 OS-thread implementation backed by pthread; an M:N scheduler is deferred.

Goals

Non-goals (deferred)

Implementation status

v0.42 is implemented in eight STEPs. Each STEP keeps every existing test green and preserves the self-host fixed point.

STEP 1 — Lexer / parser / AST (in progress; surface only)

STEP 2 — Multi-thread GC extension and task value (landed)

The v0.41 allocator is now mutex-protected. tya_gc_alloc, tya_gc_register_root, tya_gc_collect, tya_gc_maybe_collect, and tya_gc_stats all serialize on a single pthread_mutex_t. A future minor will move to a finer-grained design when an M:N scheduler lands.

A new task value kind (TYA_TASK / TyaTask) is reserved with the following layout: pthread_t, pthread_mutex_t, pthread_cond_t, done, joined, raised, cancelled (atomic), callee (the callable), result, and raise_value. The collector marks callee, result, and raise_value; the sweeper destroys the mutex and condvar before freeing the task. STEP 3 will populate the struct from spawn and read it from await.

kind(t) for a task value returns "task". tya_to_string and tya_print render a task as "[task]". Equality (tya_equal) is identity equality.

Generated programs link with pthread on Linux (-lpthread); on macOS / BSD pthread is in libc.

STEP 3 — spawn / await codegen and runtime (landed)

spawn callable is a unary expression that produces a task value. The spawning thread evaluates the callable expression and the arguments first, then tya_task_new allocates a TyaTask, initializes its mutex / condvar, and starts a pthread that calls callable(args...) once. The arguments are copied into the task’s argv array before the pthread runs. Up to four positional arguments are supported; passing more is a structured error at codegen time.

await task blocks the current thread on pthread_join, then returns either the task’s result or re-raises its raise_value. The raise frame is thread-local (_Thread_local), so a raise inside the spawned body never corrupts the awaiter’s raise stack.

Special forms recognized by codegen:

Both forms return a TyaValue of kind TYA_TASK. Awaiting a task twice is allowed; the first await joins the pthread, subsequent awaits return the cached result (or re-raise the cached raise_value).

STEP 4 — scope block (landed)

scope opens a structured-concurrency block. Codegen wraps the body in a fresh C scope and brackets it with tya_scope_enter / tya_scope_exit. The runtime maintains a thread-local stack of TyaScope records, and tya_task_new registers each new task in the innermost open scope.

When control leaves a scope block normally, tya_scope_exit joins every task spawned inside the block (in spawn order). If any of those tasks raised, the first such raise is re-raised after every sibling has joined.

Open question for a later STEP: a synchronous raise from within the scope body itself bypasses the cleanup (the raise frame walks back without running tya_scope_exit). Cooperative cancel (task.is_cancelled()) is not yet wired. Both will land in a follow-up.

STEP 5 — channel stdlib module (landed)

The new stdlib channel module exposes:

channel.new(capacity)
channel.send(c, value)
channel.receive(c)
channel.close(c)
channel.closed?(c)

Channels are FIFO bounded queues backed by a ring buffer guarded by a pthread_mutex_t plus two condition variables (not_full, not_empty). channel.send blocks while the buffer is full and raises if the channel has been closed. channel.receive blocks while the buffer is empty; once the channel is closed, it drains the remaining elements and then returns nil for every later call. channel.close marks the channel closed and broadcasts both condvars so every waiter wakes up.

capacity = 0 is treated as 1 in v0.42; true rendezvous (synchronous) channels arrive in a later minor.

Two operational fixes ride this STEP:

A new TYA_CHANNEL value kind is reserved with the matching TyaChannel struct. kind(c) returns "channel", tya_to_string and tya_print render a channel as [channel], and equality is identity equality.

STEP 6 — channel.receive_timeout (landed)

channel.receive_timeout(c, seconds) blocks until either a value is available or the wall-clock deadline elapses, then returns the dequeued value (on success) or nil (on timeout). seconds is a non-negative number; 0 means “do not wait” (a single best-effort poll). The implementation uses pthread_cond_timedwait against CLOCK_REALTIME. On macOS / BSD the deadline is computed from gettimeofday; elsewhere it is read from clock_gettime.

channel.select (a multiplexed select-like primitive) is deferred to a follow-up STEP. The minimum-effort substitute today is receive_timeout with a small budget plus polling, or one forwarding task per source channel that funnels into a shared inbox.

STEP 7 — sync stdlib module (landed)

The new stdlib sync module exposes three families:

sync.mutex()
sync.lock(m); sync.unlock(m); sync.with_lock(m, fn)
sync.atomic_integer(initial)
sync.atomic_add(a, n); sync.atomic_load(a); sync.atomic_store(a, n)
sync.atomic_cas(a, expected, new)
sync.wait_group()
sync.wait_group_add(wg, n); sync.wait_group_done(wg); sync.wait_group_wait(wg)

The runtime uses one TyaResource value kind for all three primitives (sub-tagged MUTEX, ATOMIC_INTEGER, WAIT_GROUP), backed by pthread_mutex_t, stdatomic.h atomic_long, and a counter + condvar respectively. kind(r) returns "mutex", "atomic_integer", or "wait_group". Equality is identity equality.

Note on Tya closure semantics. Tya closures cannot write back to outer variables. To share mutable state across spawned tasks, pass a dict / array as an argument and mutate it through indexed assignment:

state = {}
state["count"] = 0
m = sync.mutex()

inc = mref, sref ->
  sync.lock(mref)
  sref["count"] = sref["count"] + 1
  sync.unlock(mref)

t = spawn inc(m, state)
await t

The tests/testdata/v42/sync.txtar testscript covers a mutex-protected dict counter, atomic add / load / cas, and wait_group_wait blocking until every spawned worker has called done.

STEP 8 — Documentation, examples, release (landed)

examples/concurrent/ ships representative end-to-end programs: parallel fetch via scope, a long-lived Counter actor that owns its state and answers requests over a channel, a worker pool co-ordinated through sync.wait_group, and a producer / consumer streaming through a buffered channel. docs/STDLIB.md gains sections for the new channel and sync modules; the v0.42 SPEC above is the canonical surface description.

Observable language behavior summary

The full safety contract (locals are still not roots; collections inside function bodies are not safe in v0.42 either; the existing v0.41 limitations still apply) is documented in docs/v0.41/SPEC.md and inherited by v0.42 unchanged.