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.
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.
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 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.
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 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() // 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.