Skip to content

Concurrency

E# concurrency rests on three pillars, each a first-class part of the language rather than a library convention layered on top:

  • uncolored asyncawait alone makes a function asynchronous; asyncness does not propagate up the call chain, and the declared return type — not an async keyword — selects the machinery;
  • CSP-style messaging — typed channels (chan<T>) and a Go-style select, 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.

Await = "await" Unary .
AsyncLet = "async" "let" identifier [ ":" Type ] "=" Expr .
Yield = "yield" Expr .
Spawn = "spawn" Block . // expression → a joinable handle
TaskFunc = "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.

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 returnCLR returnBuilder
bare T (or explicit ValueTask<T>)ValueTask<T>AsyncValueTaskMethodBuilder<T>
omitted (void-like, with await)ValueTaskAsyncValueTaskMethodBuilder
-> Task<T> / -> TaskTask<T> / TaskAsyncTaskMethodBuilder<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).

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 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 the async let simply 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, an async let over 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.

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 { … } 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.

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 scope

TaskScope (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.

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.

OperationFormBehavior
sendch.Send(v) / ch.SendAsync(v)enqueue a value (SendAsync suspends on a full bounded channel)
try-sendch.TrySend(v)non-blocking; false if full or completed
receivech.ReceiveAsync()suspend until a value is available or the channel completes
try-receivech.TryReceive(out v)non-blocking; false if none currently available
drainfor v in ch / await for v in chiterate every value until the channel completes
completech.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 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 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 / .send fires and its body runs.
  • default is held back on that first pass, so a ready .recv / .send always wins over default. If no .recv / .send is ready and a default arm exists, it fires immediately.
  • Blocking pass otherwise: select waits until one operation can proceed, or a .timeout arm elapses, whichever comes first. Under a scope, the scope’s cancellation token can abort the wait so the select unwinds without any arm firing.

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.