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.
Grammar
Section titled “Grammar”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.
Concurrent bindings
Section titled “Concurrent bindings”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.
Spawned handles and task functions
Section titled “Spawned handles and task functions”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.
| Operation | Spawned | Spawned<T> |
|---|---|---|
| synchronous completion / fault observation | Wait() or Join() | Wait() or Join() yielding T |
| asynchronous completion | WaitAsync() | WaitAsync() yielding T |
| await expression | await handle yields void | await handle yields T |
| cancellation request | Cancel() | Cancel() |
| BCL interop | AsTask() | 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}Cancellation
Section titled “Cancellation”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 ctChannels
Section titled “Channels”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.
| Form | Completion 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 ch | consumes 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.
Selection
Section titled “Selection”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
Section titled “TaskScope”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.