Spawn & Await
spawn runs a function concurrently and returns a future; await retrieves its result. Awaited work runs on an M:N coroutine scheduler (many lightweight coroutines multiplexed across worker threads sized to your CPU count), so cooperative I/O and Time.sleep yield instead of blocking a whole thread. There's no async/await coloring - every function can be spawned, and awaiting from main pumps the scheduler so it never deadlocks.
Performance
Measured on an Apple M3 Pro (6 performance + 6 efficiency cores) with the v1.21.0 release build. Numbers are warm runs.
| Metric | Value |
|---|---|
| Spawn overhead | ~1.5μs per spawn+await (100K in ~159ms) |
| 10K spawn+await | 14.9ms |
CPU scaling (4× fib(35) via spawn + await_all) | near-linear - 4 parallel ≈ 1 sequential wall time (33ms) |
| CPU scaling beyond 4 | throughput, not latency - 6 tasks 65ms, 8 tasks 99ms, 24 tasks 120ms |
| Determinism | 1000-spawn await_all - identical result across runs |
Awaited work uses the coroutine scheduler by default; set WYN_ASYNC_POOL=1 to fall back to the legacy thread pool (equivalent throughput, no cooperative I/O).
Basic Spawn
fn compute(n: int) -> int {
sum = 0
for i in 0..n { sum = sum + i }
return sum
}
fn main() -> int {
f1 = spawn compute(100000)
f2 = spawn compute(200000)
total = await f1 + await f2
print("total = ${total}")
return 0
}spawn starts a function as a coroutine on the scheduler and returns a future. await suspends until the result is ready (pumping the scheduler when called from main).
Await a Whole List: await_all
When you have a list of futures, await_all collects them all at once and returns a list of results in order:
fn square(n: int) -> int {
return n * n
}
fn main() {
tasks = [spawn square(2), spawn square(3), spawn square(4)]
results = await_all(tasks)
print(results) // [4, 9, 16]
}Run a Block Concurrently: parallel { }
When you just want a few statements to run at once and join at the closing brace - no task handles to juggle - use a parallel block:
fn main() {
x = 0
y = 0
parallel {
x = 21 + 21
y = 100 + 100
}
print("${x} ${y}") // 42 200
}The block returns only after every statement inside it has completed.
A branch only runs concurrently if it calls a function. parallel { } dispatches a branch that declares a variable from a call, assigns a call's result to a variable declared outside the block, or uses an explicit spawn:
parallel {
var a = fetch(1) // dispatched
total = compute(2) // dispatched (total declared above the block)
spawn notify(3) // dispatched
}A call to a namespaced builtin written directly in the block - Time.sleep(200), File.read(p) - is dispatched as well, in both the . and the :: spelling:
fn main() {
parallel {
Time.sleep(200) // dispatched
Time.sleep(201)
}
print("both waits done")
}Measured on this machine, eight branches of a 200ms sleep in one parallel { }, against a single 200ms sleep as the baseline:
| one branch | eight branches | |
|---|---|---|
| v1.22.0 | 204ms | 210ms |
| v1.21.0 | 205ms | 1,649ms |
Overlapping waits cost about as much as the longest single wait. Wrapping the call in a one-line user function used to be the only way to get that, and is no longer necessary - it measures the same 210ms either way, so write whichever reads better.
What is still sequential, deliberately, and refused by name rather than silently: a method call on a value (its receiver would be copied, so a builtin taking its address would mutate the copy), a non-scalar argument, a non-word result, and more than eight arguments. await on a spawned builtin yields 0.
For CPU-bound branches parallel { } and spawn + await_all behave the same, and both are bounded by core count rather than by the construct - see the note below.
Before v1.22, a bare
Time.sleep(200)in the block ran inline, one branch after another - the 1,649ms row above. Two of the three dispatched shapes did not overlap either: onlyvar a = f()and an explicitspawnbecame real tasks, so assigning to a variable declared outside the block ran sequentially.Earlier editions of this page also credited
parallel { }with overlapping "two CPU-bound branches but not four". That figure came from a benchmark whose branches all calledfib(35)with the same argument; becausefibis pure, the C compiler collapses identical sibling calls into one, so the block appeared to overlap perfectly while doing nothing of the kind. Give every branch a different argument before believing any parallelism measurement - though note the trap needs sibling calls the compiler can see together: a loop that callsspawn f(x)n times is one call site executed n times, and nothing folds there.
CPU Parallelism
fn fib(n: int) -> int {
if n <= 1 { return n }
return fib(n - 1) + fib(n - 2)
}
fn main() -> int {
// Each fib(38) takes ~150ms
// All 4 complete in ~150ms total = 4x speedup
a = spawn fib(38)
b = spawn fib(38)
c = spawn fib(38)
d = spawn fib(38)
print((await a + await b + await c + await d).to_string())
return 0
}I/O Parallelism
fn fetch(url: string) -> string {
return http_get(url)
}
fn main() -> int {
// All 3 requests run simultaneously
var f1 = spawn fetch("https://api.example.com/users")
var f2 = spawn fetch("https://api.example.com/posts")
var f3 = spawn fetch("https://api.example.com/comments")
print(await f1)
print(await f2)
print(await f3)
return 0
}Sharing Mutable State
Wyn does not make plain collections thread-safe, and it does not pretend to. What it does is refuse to corrupt quietly. As of v1.21.0 all three tiers of shared mutable state agree:
| What you share | What happens |
|---|---|
| A shared array, mutated from two tasks | runtime panic naming the fix |
A shared HashMap or HashSet, mutated from two tasks | runtime panic naming the fix (new in v1.21.0) |
| A shared scalar global, mutated from two tasks | compile-time error naming the fix |
panic: concurrent HashMap mutation detected - HashMap is not thread-safe;
use a channel or Shared to coordinate writersBe precise about the scope: this catches writer-vs-writer. A read concurrent with a write is still unguarded, for arrays and collections alike. Before v1.21.0, two writers to a HashMap could splice the same bucket - losing updates, or corrupting the deferred-free list into a use-after-free at the next read - and it only did so on the unlucky interleaving.
To share mutable state on purpose, use a channel or Shared.
spawn does not take a closure
A closure called directly works. The same closure handed to spawn is refused at check time:
var n = 5
g = (() => n * 2)
println(g()) // 10
f = spawn (() => n * 2) // Error: `spawn` cannot run a closurespawn needs a function pointer, and a closure's captured environment is not carried across the boundary. Pass what you need as explicit parameters to a named function and spawn that:
fn double(n: int) -> int { return n * 2 }
fn main() {
f = spawn double(5)
println(await f) // 10
}Until v1.22 this was worse than an error: spawn on a closure compiled cleanly and returned 0, with nothing from wyn check and no build failure.
Shared Atomic Values
For lock-free shared state between spawns:
fn increment(counter: int) -> int {
Shared.add(counter, 1)
return 0
}
fn main() -> int {
counter = Shared.new(0)
for i in 0..100 {
f = spawn increment(counter)
await f
}
print("counter = ${Shared.get(counter)}") // Always 100
return 0
}Channels
fn producer(ch: int) -> int {
for i in 0..10 {
Task.send(ch, i)
}
Task.close(ch)
return 0
}
fn main() -> int {
ch = Task.channel(10)
spawn producer(ch)
// The producer sends exactly 10 values, so the consumer reads exactly 10.
// (Don't loop on `Task.recv(ch) >= 0` - recv returns 0 for BOTH a sent 0
// and a drained/closed channel, so that condition never terminates.)
for i in 0..10 {
val = Task.recv(ch)
print(val.to_string())
}
return 0
}Prints 0 through 9 and exits. When the number of messages isn't known ahead of time, drain until the channel is closed with Task.select_2 (it returns -1 once every channel it watches is closed and empty):
fn producer(ch: int) -> int {
for i in 0..5 {
Task.send(ch, i * 10)
}
Task.close(ch)
return 0
}
fn main() -> int {
ch = Task.channel(8)
spawn producer(ch)
while true {
ready = Task.select_2(ch, ch) // -1 when ch is closed and drained
if ready == -1 { break }
print(Task.recv(ch).to_string())
}
return 0
}Non-blocking receive: Task.try_recv
To poll a channel without blocking, use Task.try_recv. It returns int? - Some(v) when a value is ready, none when the channel is empty - so you can check for a value and handle both cases explicitly:
fn main() -> int {
ch = Task.channel(4)
Task.send(ch, 42)
match Task.try_recv(ch) {
Some(v) => print("got ${v}"),
none => print("empty"),
}
return 0
}Cancellation
An awaited task can be cancelled cooperatively. Task.cancel(handle) requests cancellation; the spawned task observes it by calling Task.is_cancelled() at a convenient point and returning early. Cancellation is cooperative - a task that never checks keeps running - and leak-on-cancel: a cancelled task abandons the resources it still holds (there is no forced stack unwind).
fn worker() -> int {
i = 0
while i < 1_000_000 {
if Task.is_cancelled() { return -1 } // bail out cleanly
i = i + 1
}
return i
}
fn main() -> int {
h = spawn worker()
Task.cancel(h) // request cancellation
r = await h // returns promptly once the task bails
return 0
}How It Works
spawn f(x) → create coroutine task → enqueue to scheduler → worker resumes → runs f(x)
await f → park the awaiter (or pump the scheduler on the main thread) → resume with the result- Coroutine scheduler: awaited
spawn/await_all/parallelrun as coroutines on an M:N scheduler, so cooperative I/O andTime.sleepengage everywhere. (The legacy thread pool remains as a fallback behindWYN_ASYNC_POOL=1.) - Lock-free futures: slab-allocated, recycled after await - zero malloc per spawn
- Main-thread await: pumps the scheduler itself, so awaiting from
mainnever deadlocks even with no idle worker - Fire-and-forget
spawn f()is drained at program exit so orphan tasks still run
Try It
See Also
- Channels - communicate between tasks
- Closures - pass functions to spawned tasks
- Benchmarks - spawn/await performance numbers