Skip to content

Async & concurrency

E# async has two properties that normally pull against each other — plus a separate concurrency axis:

  • Uncolored by default — suspension carries no async keyword 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.

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.

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 slot
func onClick(e: ClickEvent) -> void { ... } // async-void — an event handler
func quotes(xs: List<string>) -> IAsyncEnumerable<Quote> { ... } // async stream
Declared returnCLR shapeBuilderWhen
bare T / ValueTask<T>ValueTask<T>AsyncValueTaskMethodBuilderdefault — no alloc on sync completion
-> Task<T> / -> TaskTask<T> / TaskAsyncTaskMethodBuildera C# API/interface slot typed Task
-> void (explicit)voidAsyncVoidMethodBuilderevent handlers (fire-and-forget)
-> IAsyncEnumerable<T> + yieldIAsyncEnumerable<T>channel-backedasync 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.

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 foreach:

func quotes(symbols: List<string>) -> IAsyncEnumerable<Quote> {
for s in symbols {
let q = await fetchQuote(s) // suspend
yield q // produce
}
}
func report() {
await foreach 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.

Browse async examples →

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 here
log("got {msft.symbol}")

spawn { ... } runs a block as a job and hands back a Job handle:

let job = spawn {
for event in ch {
process(event)
}
}
job.Wait()

task func is the function-shaped version — calling it is a spawn, returning a Job / Job<T>:

task func produce() -> int { return 42 }
let job = produce()
let n = job.Wait() // 42

Shared state crosses a spawn boundary through channels, not captured vars (capturing a mutable var across the boundary is an error — let captures are fine).

Typed, Go-style channels, backed by System.Threading.Channels:

let ch = chan<int>(4) // bounded, capacity 4
let 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 = 0
for v in ch { // drains until the channel closes
total += v
}

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.

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.