Skip to content

Structured work and communication

This section specifies the concurrent-work primitives in Concurrency. These primitives share BCL building blocks, but their ownership and completion rules are part of E# semantics.

AsyncLet = "async" "let" identifier [ ":" Type ] "=" CallExpression .
SpawnExpression = "spawn" Block .
TaskFunction = "task" "func" identifier "(" [ ParameterList ] ")"
[ "->" Type ] FunctionBody .
ChannelCreation = "chan" "<" Type ">" "(" [ Expression ] ")" .
SelectStatement = "select" "{" { SelectArm } "}" .
SelectArm = ".recv" "(" identifier "," Expression ")" Block
| ".send" "(" Expression "," Expression ")" Block
| ".timeout" "(" Expression ")" Block
| "default" Block .

async, task, and yield are contextual keywords in the declaration forms above. spawn, chan, and select are language keywords.

async let x = e evaluates e at the declaration and creates a pending binding. The binding’s source type is the eventual value type. Its first executed use awaits the pending result; subsequent uses read the completed value. The declaration order therefore determines launch order, while the first-use order determines join order.

The initializer shall be a call expression. The compiler uses an already-awaitable call directly. A call to a synchronous E# function is scheduled through Task.Run so that two adjacent concurrent bindings genuinely overlap. A non-call initializer is ill-formed.

func price(n: int) -> int = await Task.FromResult(n * 10)
func total() -> int {
async let first = price(2)
async let second = price(3)
return second + first // joins second before first
}

If the eventual value is Result<T, E>, binding? first completes the pending binding and then applies the ordinary propagation rule. See Errors — propagation for the resulting early return semantics.

spawn { body } evaluates to Spawned; a task func call evaluates to Spawned<T> when its declared body result is T, or Spawned for a void-like body. The invocation starts work immediately. The result is a joinable future, so storing, returning, passing, or joining it later is valid and shall not be diagnosed as an unawaited asynchronous call.

The handle’s shape does not depend on whether the body awaits. A task func or spawn body that suspends is asynchronous in its own right, but the caller still receives Spawned / Spawned<T> — never a task type, and never a handle wrapping one. The handle completes when the body completes, not when the body first suspends, so a fault raised after a suspension point still reaches whoever joins.

No part of the body — not even the prologue before its first await — runs on the calling thread.

OperationSpawnedSpawned<T>
synchronous completion / fault observationWait() or Join()Wait() or Join() yielding T
asynchronous completionWaitAsync()WaitAsync() yielding T
await expressionawait handle yields voidawait handle yields T
cancellation requestCancel()Cancel()
BCL interopAsTask()AsTask() yielding Task<T>
task func scale(value: int, factor: int) -> int = value * factor
func later() -> int {
let pending = scale(6, 7)
writeAuditRecord()
return pending.Join()
}

Arguments to a task function cross the spawn boundary as ordinary call arguments. A function literal inside a spawn or task func body shall not capture a surrounding mutable var; immutable let captures are permitted. This restriction prevents accidental sharing of mutable local state across concurrently executing work.

An awaiting body is an ordinary asynchronous function in its own right. Its awaits belong to it and do not make the enclosing function asynchronous — the enclosing function merely starts the work and holds a handle.

func serve() { // synchronous: it has no await of its own
let p = spawn { await pump() } // the spawned body is the asynchronous one
defer { await p.WaitAsync() } // …though awaiting the drain does make serve() async
}

A cancellation token is an ordinary parameter, passed to the work that should observe it. There is no ambient or implicitly forwarded token: a function that does not take one does not cancel, and reading a call tells you whether it is cancellable.

task func pump(ch: Chan<int>, ct: CancellationToken) {
await ch.ReceiveAsync(ct)
}

Spawned.Cancel() trips the handle’s own token, which reaches the body only where the body observes one. scope.Spawn follows the same shape — its callback takes the scope’s token as a parameter.

By convention, cleanup does not observe a fired token. A defer body runs because something ended, cancellation included, so passing the token that just tripped into the cleanup cancels the cleanup:

defer { await conn.DisposeAsync() } // deliberately no ct

chan<T>() constructs an unbounded Chan<T>; chan<T>(n) constructs a bounded channel when n > 0. Channels have reference identity: copying a channel value copies the reference to the same communication endpoint. Complete() marks the writer complete and is idempotent; already-buffered values remain readable.

FormCompletion behavior
ch.Send(v)blocks a worker until accepted; appropriate inside spawned work
ch.SendAsync(v, ct)returns a ValueTask, suspending on a full bounded channel
ch.TrySend(v)returns false if full or completed
ch.ReceiveAsync(ct)yields the next value or completes according to the channel reader contract
ch.TryReceive(out v)returns false when no value is currently available
for v in ch / await for v in chconsumes buffered and later values until completion

The capacity is a backpressure boundary. A bounded sender cannot outrun its receiver indefinitely; an unbounded channel trades that pressure for buffered memory growth.

select chooses exactly one arm. The implementation first probes receive and send arms in randomized order; therefore no source-order preference is guaranteed. A default arm is considered only after no communication arm is immediately ready. If no arm can proceed and no default exists, selection waits until a receive/send operation or timeout can proceed.

select {
.recv(message, inbound) { handle(message) }
.send(reply, outbound) { recordSent(reply) }
.timeout(500) { reportSlowPeer() }
default { recordIdle() }
}

.recv(name, channel) introduces name only in that arm body. .send(value, channel) evaluates its value for the selected send operation. A cancellation-aware select may terminate without executing an arm when its supervising cancellation token is signalled.

TaskScope has two complementary declaration facets: the static host provides TaskScope.RunAsync, and the instance class owns children and resources. RunAsync creates a scope, invokes its callback, then drains the scope before the returned task completes.

TaskScope.RunAsync(func(scope: TaskScope) -> Task {
let ch = scope.Chan<int>(8)
let producer = scope.Spawn(func(ct: CancellationToken) -> Task {
ch.Send(42)
ch.Complete()
return Task.CompletedTask
})
return producer
})

An instance exposes Token, Cancel(), Spawn(...), Chan<T>(...), Defer(...), and DisposeAsync(). The scope links its token to the parent cancellation token. It records its spawned children, cancels siblings when a child faults, drains children before exit, completes scope-owned channels, and executes deferred cleanups in LIFO order. A plain spawn is not automatically part of a TaskScope.

TaskScope is not the only way to bind a child’s lifetime, and it is not the usual one. Its guarantees — drain before exit, LIFO cleanup, run-on-every-path — are defer’s guarantees, and a spawned handle is an ordinary value, so the lexical form needs no second concept:

func serve(ct: CancellationToken) {
let ch = chan<int>(8)
let p = spawn { pump(ch, ct) }
defer { await p.WaitAsync() } // drains on every exit, fault included
consume(ch, ct)
}

Reach for TaskScope when the child set is dynamic — spawned in a loop, or by a callee — or when the scope’s token must be handed to a third party. Both express the same guarantee; the scope object is what you need when the set of children is not visible in one block.