Concurrency
E# concurrency rests on three pillars, each a first-class part of the language rather than a library convention layered on top:
- uncolored async —
awaitalone makes a function asynchronous; asyncness does not propagate up the call chain, and the declared return type — not anasynckeyword — selects the machinery; - CSP-style messaging — typed channels (
chan<T>) and a Go-styleselect, for communicating between concurrent work by passing values rather than sharing memory; - structured concurrency — concurrent work runs under a scope that owns it: a scope does not exit until its children complete, a child’s failure cancels its siblings, and cancellation flows in and out.
spawn, chan, and await are reserved keywords; task and yield are contextual. There is no async
keyword.
In this specification
Section titled “In this specification”- Awaiting and async return shapes specifies the async boundary, the no-implicit-await rule, explicit CLR wrappers, and the force-on-use future rule.
- Structured work and communication specifies
async let, spawned handles, channels,select, and theTaskScopesupervision API.
Grammar
Section titled “Grammar”Await = "await" Unary .AsyncLet = "async" "let" identifier [ ":" Type ] "=" Expr .Yield = "yield" Expr .Spawn = "spawn" Block . // expression → a joinable handleTaskFunc = "task" "func" identifier "(" [ ParamList ] ")" [ ReturnType ] Block .Select = "select" "{" { SelectArm } "}" .SelectArm = ".recv" "(" identifier "," Expr ")" Block | ".send" "(" Expr "," Expr ")" Block | ".timeout" "(" Expr ")" Block | "default" Block .chan<T>(n) is a construction expression yielding a channel; await for … in … consumes an async
stream.
Uncolored async
Section titled “Uncolored async”A function is asynchronous iff its body contains await. There is no async keyword to write, and
asyncness does not color the call chain: a caller of an async function is not itself forced to be async —
it awaits the result like any other awaitable, or composes it however its own return shape allows.
The declared return type is the unwrapped value; the awaitable wrapper is generated. The return type also selects the async builder — the one knob, what makes the model “uncolored save for the return shape”:
| Declared return | CLR return | Builder |
|---|---|---|
bare T (or explicit ValueTask<T>) | ValueTask<T> | AsyncValueTaskMethodBuilder<T> |
omitted (void-like, with await) | ValueTask | AsyncValueTaskMethodBuilder |
-> Task<T> / -> Task | Task<T> / Task | AsyncTaskMethodBuilder<T> / AsyncTaskMethodBuilder |
-> void (explicit) | void (async-void) | AsyncVoidMethodBuilder |
-> IAsyncEnumerable<T> (with yield) | IAsyncEnumerable<T> | channel-backed stream |
func loadAsync(n: int) -> Result<int, string> { // async — body awaits; returns ValueTask<Result<…>> let v = await Task.FromResult(n) if v < 0 { return error("neg") } return ok(v)}The body is identical across the value, Task<T>, and ValueTask<T> forms; only the wrapper differs. The
default (ValueTask<T>) is the allocation-light choice for the common case where the result is awaited once.
Choose -> Task<T> when the result is handed to BCL APIs that expect a Task, or stored/awaited more than
once. Explicit -> void is async-void — for event handlers — and carries the usual unobserved-exception
caveat. An async main is wrapped in a synchronous CLR entry shim that awaits it to completion
(Programs → entry point).
Un-awaited calls: force-on-use futures
Section titled “Un-awaited calls: force-on-use futures”An uncolored async function — one whose declared return is a value or void, rather than an explicit
async wrapper — may be called without await from any caller, synchronous or asynchronous. There is no
implicit await. A plain let/var binding starts the call eagerly and is source-typed as its eventual
value; the compiler joins it at the binding’s first use by blocking on the underlying ValueTask, so work
between the binding and that use overlaps the asynchronous operation. An un-awaited call in any other
position joins immediately where it stands and draws warning ES2240 (sync-over-async).
func fetch() -> int { return await Task.FromResult(41) }
func work() -> int { // no await: remains synchronous let value = fetch() // starts fetch now doOtherWork() // overlaps fetch return value + 1 // first use joins; returns 42}The blocking join holds the caller’s thread only for the unfinished remainder and surfaces an exception at
the first use — even inside an async function, where the join is a bridge, never an await point; write
await to suspend instead. This is deliberately limited to uncolored functions: calls whose declared return
is Task<T>, ValueTask<T>, or IAsyncEnumerable<T> remain explicit async values that the caller must
await or otherwise handle.
await e suspends the function until the awaitable e completes, then yields its result. It is an
expression — it composes inside arithmetic, an if condition, a match arm, an interpolation, or a
return. It operates on Task, Task<T>, ValueTask<T>, and any type exposing the awaitable pattern
(GetAwaiter), so BCL awaitables and E# async functions await uniformly. A ? may propagate through an
async function: the early error return is routed through the builder’s completion path, so unwrapping an
awaited Result short-circuits correctly out of the state machine (Errors → ? under async).
async let
Section titled “async let”async let name = init starts init concurrently at the declaration and awaits it at the first
textual reference to name — Swift-style fan-out, written against the unwrapped type. Several async lets
launched back-to-back overlap; the joins happen where the values are first used, in use order, not
declaration order.
func combine() -> Result<int, string> { async let a = loadAsync(2) // both launch here, concurrently async let b = loadAsync(3) let av = a? // first use of a → await, then unwrap let bv = b? // first use of b → await, then unwrap return ok(av + bv)}It composes with ?: an async let over a Result is awaited and then unwrapped at first use, so a
failing branch short-circuits the enclosing function (async let b = loadAsync(-1) then let bv = b?
returns the error). First use is by reference, not by declaration order — referencing b before a
awaits b first.
The initializer’s shape decides how the work is launched:
- a call to an async user function (or any awaitable expression, e.g.
Task.FromResult(x)) is already in flight, so theasync letsimply records the awaitable to join later; - a call to a synchronous user function is wrapped in
Task.Run<T>(() => …), so the call genuinely runs on the thread pool and the fan-out is real — without the wrap, anasync letover a synchronous function would run inline and not overlap anything; - a non-call initializer is rejected (ES3005) — wrap it in
Task.Run(() => …)explicitly.
The async let rewrite runs before either backend sees the tree, so both lower it identically.
Async streams
Section titled “Async streams”A function declared -> IAsyncEnumerable<T> whose body uses yield is an async stream. It may
interleave yield e (produce a value) and await (suspend), and is consumed with await for:
func nums() -> IAsyncEnumerable<int> { yield 1 let x = await Task.FromResult(40) // suspend mid-stream yield x yield 100}The lowering is channel-backed — a bounded capacity-1 channel plus a producer running one element ahead —
which gives it three properties a naive iterator lacks: backpressure (the producer blocks until the
consumer reads), cancellation (a consumer that stops early unwinds the producer so nothing leaks), and
producer-exception propagation (a fault surfaces to the consumer after the buffered item drains). It
is therefore not a pure lazy pull; it runs at most one element ahead. A yield outside such a function is
ES2131.
spawn / task func
Section titled “spawn / task func”spawn { … } runs a block as concurrent work and yields a handle to it; the handle is joined to wait
for completion and observe the work’s result and any exception it threw. task func f(…) -> T makes the
call site a spawn: each call to f runs its body concurrently and returns the handle, so a
function-shaped spawn reads like an ordinary call.
Joinable-future rule
Section titled “Joinable-future rule”The result of a task-function invocation is a joinable future, not an expression that must be immediately
awaited. task func f() -> T yields Spawned<T>; its Wait(), Join(), and awaiter yield T. A void task
function yields Spawned, whose corresponding operations only observe completion. A program may store, return,
or pass a Spawned value before its owner joins it. Starting such work without an immediate await shall not
produce an unawaited-async diagnostic.
Task-function parameters are passed to the spawned invocation exactly as ordinary function arguments. They are
immutable inputs, while the existing ES2130 rule continues to forbid function literals in the body from capturing
a surrounding mutable var.
task func produce() -> int { return 42 }
task func scale(value: int, factor: int) -> int = value * factor
func consume() -> int { let ch = chan<int>(8) let writer = spawn { // a block spawn ch.Send(1) ch.Send(2) ch.Complete() } writer.Wait() // join — completes, and would surface a thrown exception var total = 0 for v in ch { total += v } // drains until the channel completes return total}Concurrent work is joinable, and an exception it throws cannot be silently lost — joining surfaces it.
A spawned handle is an ordinary value, so its lifetime is bound the way every other scoped resource’s is,
with defer: declare the drain next to the spawn and it runs on every
exit path, including an early return or a fault.
let p = spawn { pump(ch, ct) }defer { await p.WaitAsync() } // the child cannot outlive this scopeTaskScope (below) is the first-class form of the same guarantee,
for the cases that need a supervisor object rather than a lexical one — a dynamic set of children, or a
token handed to a third party. A plain spawn or task func is not automatically part of a
TaskScope.
Cancellation is cooperative and explicit: a token is an ordinary parameter, passed to the work that
should observe it. Well-behaved code that honors the token (including channel iteration and select)
unwinds in bounded time. There is no ambient token — a function that does not take one does not cancel.
A function literal inside a task func body shall not capture a var from the surrounding scope
(ES2130) — shared mutable state across the concurrency boundary is a data
race; thread shared state through chan<T> parameters instead. A let capture is immutable and
permitted.
Channels
Section titled “Channels”chan<T>(n) constructs a typed channel — bounded to n pending values, or unbounded when n is omitted
— backed by System.Threading.Channels. Channels are how concurrent work communicates: one side sends,
the other receives, and the buffer bound provides backpressure.
| Operation | Form | Behavior |
|---|---|---|
| send | ch.Send(v) / ch.SendAsync(v) | enqueue a value (SendAsync suspends on a full bounded channel) |
| try-send | ch.TrySend(v) | non-blocking; false if full or completed |
| receive | ch.ReceiveAsync() | suspend until a value is available or the channel completes |
| try-receive | ch.TryReceive(out v) | non-blocking; false if none currently available |
| drain | for v in ch / await for v in ch | iterate every value until the channel completes |
| complete | ch.Complete() | signal no more values will be sent; idempotent |
for v in ch blocks the iterating worker between values and ends when the channel is completed — so a
producer that finishes by calling ch.Complete() cleanly terminates a consumer’s loop. await for
is the asynchronous drain, suspending rather than blocking. A channel iterated under a scope honors the
scope’s cancellation token, so a forgotten Complete() never hangs the process when the scope unwinds.
The stdlib concurrency types
Section titled “The stdlib concurrency types”The concurrency surface is backed by ordinary E# types in Esharp.Stdlib (CLR mapping →
concurrency), and they double as worked examples of the type and
member forms. Chan<T> is a class (identity — a channel is shared coordination state, never copied)
that conforms to three interfaces at once, so a channel is iterable synchronously (for v in ch),
asynchronously (await for v in ch), and through the non-generic IEnumerable:
pub class Chan<T> : IEnumerable<T>, IEnumerable, IAsyncEnumerable<T> { c: Channel<T>
init(capacity: int = 0) { if capacity > 0 { self.c = Channel.CreateBounded<T>(capacity) } else { self.c = Channel.CreateUnbounded<T>() } }
pub func SendAsync(value: T, ct: CancellationToken = default) -> ValueTask = self.c.Writer.WriteAsync(value, ct)
pub func TrySend(value: T) -> bool = self.c.Writer.TryWrite(value) pub func TryReceive(out value: T) -> bool = self.c.Reader.TryRead(out value) pub func Complete() { self.c.Writer.TryComplete() }
pub func GetEnumerator() -> IEnumerator<T> = self.GetEnumerator(CancellationToken.None) pub func GetAsyncEnumerator(ct: CancellationToken = default) -> IAsyncEnumerator<T> = self.c.Reader.ReadAllAsync(ct).GetAsyncEnumerator(ct)}A companion static ChanOps carries the operations as generic statictions — a static
host class holding only functions, the static-dispatch shape used where a generic-type-rooted method
reference cannot be formed:
pub static ChanOps { pub func Send<T>(ch: Chan<T>, value: T) { ch.Send(value) } pub func TrySend<T>(ch: Chan<T>, value: T) -> bool = ch.TrySend(value) pub func ReceiveAsync<T>(ch: Chan<T>, ct: CancellationToken = default) -> ValueTask<T> = ch.ReceiveAsync(ct) pub func Complete<T>(ch: Chan<T>) { ch.Complete() }}The async-stream machinery is likewise plain E#. AsyncStreamSource<T> implements IAsyncEnumerable<T>
and lazily creates a channel + producer per enumeration; AsyncStreamEnumerator<T> implements
IAsyncEnumerator<T> and exposes its Current as a forwarding computed property — => over an inner
member, no backing field:
pub class AsyncStreamSource<T> : IAsyncEnumerable<T> { producer: Func<Chan<T>, CancellationToken, Task> outerToken: CancellationToken
init(producer: Func<Chan<T>, CancellationToken, Task>, outerToken: CancellationToken) { self.producer = producer self.outerToken = outerToken }
pub func GetAsyncEnumerator(ct: CancellationToken = default) -> IAsyncEnumerator<T> { let cts = CancellationTokenSource.CreateLinkedTokenSource(self.outerToken, ct) let ch = Chan<T>(1) let prod = runProducer<T>(self.producer, ch, cts.Token) return AsyncStreamEnumerator<T>(ch.GetAsyncEnumerator(cts.Token), prod, cts) }}
pub class AsyncStreamEnumerator<T> : IAsyncEnumerator<T> { inner: IAsyncEnumerator<T>
init(inner: IAsyncEnumerator<T>) { self.inner = inner }
pub let Current: T => self.inner.Current // forwarding computed property — satisfies the interface getter
pub func MoveNextAsync() -> ValueTask<bool> = self.inner.MoveNextAsync() pub func DisposeAsync() -> ValueTask = self.inner.DisposeAsync()}IAsyncEnumerator<T> requires a Current getter and the MoveNextAsync / DisposeAsync methods; the
computed Current property satisfies the getter requirement, and the two methods satisfy the rest —
nominal conformance, exactly as Generics → generic interface
conformance specifies.
select
Section titled “select”select waits on several channel operations at once and runs the body of the one that fires —
E#‘s rendezvous primitive, modeled on Go’s select:
func run() -> int { let ch = chan<int>(1) ch.Send(42) var got = 0 select { .recv(v, ch) { got = v } // fires when ch has a value; v binds it default { got = 99 } // fires immediately if nothing else is ready } return got}The four arm forms are .recv(v, ch) (receive, binding the value), .send(expr, ch) (send when the
channel can accept), .timeout(ms) (fire after a delay with nothing else ready), and default.
Semantics:
- Non-blocking pass first. Arms are checked in a randomized fairness order, so no arm is structurally
preferred; the first ready
.recv/.sendfires and its body runs. defaultis held back on that first pass, so a ready.recv/.sendalways wins overdefault. If no.recv/.sendis ready and adefaultarm exists, it fires immediately.- Blocking pass otherwise:
selectwaits until one operation can proceed, or a.timeoutarm elapses, whichever comes first. Under a scope, the scope’s cancellation token can abort the wait so theselectunwinds without any arm firing.
Structured concurrency — TaskScope
Section titled “Structured concurrency — TaskScope”TaskScope is the supervisor that gives the model its structure. A scope owns its child work and
guarantees none of it outlives the scope:
- the scope does not exit until every child has completed — normally or via cancellation;
- a child’s first exception cancels its siblings (their cooperative cancellation trips) and is re-raised when the scope exits; multiple child failures aggregate;
- cancellation flows both ways — an external cancellation of the scope cancels every child, and a child failure cancels the scope;
- channels created through the scope are auto-completed on exit, and deferred cleanups run in LIFO order after all children finish.
TaskScope is explicit: use its RunAsync entry point and scope.Spawn / scope.Chan methods when child
work must be collectively owned and drained. A plain spawn or task func produces an independently
joinable Spawned handle; its owner is responsible for joining, awaiting, or cancelling that handle.