Async & concurrency
E# async has two properties that normally pull against each other — plus a separate concurrency axis:
- Uncolored by default — suspension carries no
asynckeyword and doesn’t propagate up the call chain. - Semi-colored by choice — the return type lets you pick the async machinery at the function-declaration site, the way C# does — optional, and without touching the call site.
- Go-style concurrency — channels,
select, structured scopes (further down).
All of it sits directly on System.Threading — nothing is hidden from you.
In this guide
Section titled “In this guide”- Calling and awaiting — choose a suspended await, a synchronous force-on-use join, or an explicit CLR task.
- Concurrent bindings — launch several operations with
async letand join each at first use. - Spawned work, channels, and select — own concurrent work and communicate without shared mutable state.
Uncolored by default
Section titled “Uncolored by default”There is no async keyword. If a function uses await, the compiler makes it asynchronous —
presence of await is the only signal. The declared return type is the unwrapped value;
callers write against that value, and the awaitable is generated underneath.
func fetchUser(id: Guid) -> User { let response = await httpClient.GetAsync("/users/{id}") let json = await response.Content.ReadAsStringAsync() return parseUser(json) // returns User, not Task<User>}
func displayName(id: Guid) -> string { let user = await fetchUser(id) // await an E# async fn — unwraps to User return user.name // displayName isn't "colored" by fetchUser being async}This is the half that Bob Nystrom’s “what color is your function?” is about. In C#, async is a
color: you write it at the declaration, every caller must await, and the async-ness
propagates up the whole chain. In E# it doesn’t — any function can await, displayName above is
not marked or typed differently from a synchronous function, and you never thread async/await
annotations up a call tree just because something deep down suspends.
Calling async work from synchronous code
Section titled “Calling async work from synchronous code”You can also start an uncolored async function from ordinary synchronous code. Bind it to a local and E# treats that local as a force-on-use future: it starts immediately, lets the synchronous function continue, then joins only when the value is first needed.
func fetchScore() -> int { return await Task.FromResult(41) }
func render() -> int { // remains synchronous let score = fetchScore() // work starts here updateUiChrome() // overlaps the fetch return score + 1 // joins here, yielding 42}The join is blocking, so this is a good fit for CLI and app code where a clean synchronous signature matters.
For server paths that should release their thread, write await fetchScore() instead. This automatic bridge is
only for E# functions returning an ordinary value (the default uncolored shape); an explicit Task<T>,
ValueTask<T>, or IAsyncEnumerable<T> remains a value you handle explicitly.
Semi-colored by choice
Section titled “Semi-colored by choice”Here’s the other half. The return-type slot does double duty: normally it names the value
(User); name the machinery instead and you’ve made a deliberate, function-site coloring
decision — exactly where C# puts it — without the call site ever changing.
func fetchUser(id: Guid) -> User { ... } // ValueTask<User> (default)func loadConfig() -> Task<Config> { ... } // Task<Config> — for a Task-typed slotfunc onClick(e: ClickEvent) -> void { ... } // async-void — an event handlerfunc quotes(xs: List<string>) -> IAsyncEnumerable<Quote> { ... } // async stream| Declared return | CLR shape | Builder | When |
|---|---|---|---|
bare T / ValueTask<T> | ValueTask<T> | AsyncValueTaskMethodBuilder | default — no alloc on sync completion |
-> Task<T> / -> Task | Task<T> / Task | AsyncTaskMethodBuilder | a C# API/interface slot typed Task |
-> void (explicit) | void | AsyncVoidMethodBuilder | event handlers (fire-and-forget) |
-> IAsyncEnumerable<T> + yield | IAsyncEnumerable<T> | channel-backed | async streams |
The body is identical across the first three — it just awaits and returns the unwrapped
value. Only the emitted wrapper changes, and the call site never does: awaiting any of them
unwraps to T. So suspension stays uncolored, while the shape that crosses the boundary is a
one-token, function-site choice. Omit the machinery and you get the uncolored ValueTask
default; name it when an interop boundary demands a particular shape.
-> void is the one with a sharp edge: async-void is fire-and-forget, so an unobserved exception
escapes to the synchronization context — reach for it only for event handlers.
Async streams
Section titled “Async streams”A function returning IAsyncEnumerable<T> whose body yields is an async stream — it can
interleave yield (produce the next element) and await (suspend), and is consumed with
await for:
func quotes(symbols: List<string>) -> IAsyncEnumerable<Quote> { for s in symbols { let q = await fetchQuote(s) // suspend yield q // produce }}
func report() { await for q in quotes(["AAPL", "MSFT"]) { log("{q.symbol}: {q.price}") }}(The stream is channel-backed — the producer runs one element ahead with backpressure — so it behaves like a pull-iterator without being a pure lazy one.)
The whole BCL async surface is directly callable too — await Task.Delay(1), Task.Run(...),
handing a CancellationToken to an API, consuming any IAsyncEnumerable<T> — no ceremony.
async let — concurrent bindings
Section titled “async let — concurrent bindings”async let starts its initializer concurrently at the declaration; the first time you reference
the binding, it’s awaited. Swift-style structured fan-out, written against the unwrapped type.
async let aapl = fetchQuote("AAPL")async let msft = fetchQuote("MSFT")
log("got {aapl.symbol}") // both fetches ran in parallel; awaits herelog("got {msft.symbol}")spawn & task func
Section titled “spawn & task func”spawn { ... } runs a block as concurrent work and hands back a Spawned handle:
let work = spawn { for event in ch { process(event) }}work.Wait()task func is the function-shaped version — calling it is a spawn, returning a Spawned / Spawned<T>:
task func produce() -> int { return 42 }
let work = produce()let n = work.Wait() // 42Starting a task function is not an obligation to await at that exact line. The returned handle is a joinable
future: store it, pass it to the owner that will join it, or await/Wait/Join later. This is intentional
structured concurrency, not a “forgotten await” warning.
Task functions take ordinary inputs; the handle preserves the declared result type:
task func scale(value: int, factor: int) -> int = value * factorlet pending = scale(6, 7)let answer = pending.Join() // 42Shared state crosses a spawn boundary through channels, not captured vars (capturing a mutable
var across the boundary is an error — let captures are fine).
Channels
Section titled “Channels”Typed, Go-style channels, backed by System.Threading.Channels:
let ch = chan<int>(4) // bounded, capacity 4let unbounded = chan<string>()
let producer = spawn { defer { ch.Close() } // close on the way out, however the block exits ch.Send(1) ch.Send(2) ch.Send(3)}
var total = 0for v in ch { // drains until the channel closes total += v}select
Section titled “select”Wait on several channel operations at once (Go’s select):
select { .recv(msg, requests) { handle(msg) } .send(reply, responses) { /* fires when responses has capacity */ } .timeout(500) { /* nothing was ready within 500ms */ } default { /* non-blocking: taken if nothing else is ready */ }}If any recv/send arm is ready, one fires (with a fairness shuffle); without default, the select
blocks until something completes or a .timeout elapses.
Structured concurrency
Section titled “Structured concurrency”TaskScope owns its child tasks and channels, propagates cancellation in and out, and won’t exit
until every child has finished — so you don’t leak tasks or forget to close a channel:
TaskScope.RunAsync(async scope => { let ch = scope.Chan<int>(8) // auto-completed when the scope exits scope.Spawn(ct => produce(ch, ct)) // a child exception cancels siblings and rethrows scope.Defer(() => cleanup())})Inside a spawn body, the job’s cancellation token is threaded into select and channel
iteration automatically, so a cancelled task unwinds in bounded time instead of hanging.