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.
Prefer spawn + await_all for CPU-bound work. parallel { } currently overlaps only two CPU-bound branches at a time: two branches of fib(35) finish in the time of one (34ms), but three or four take two dispatch rounds (62-66ms), where four spawns plus await_all finish in 33ms. parallel { } is fine for I/O waits - eight overlapping Time.sleep(200) branches inside one parallel { } complete in 203ms, identical to spawn + await_all - so the limit is CPU-bound branches specifically. Use spawn + await_all when you need dependable overlap of more than two compute branches. This width limit is filed as a defect, not a design decision.
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.
Do not spawn a closure
A closure called directly works. The same closure spawned returns 0, with no error from wyn check and no build failure:
var n = 5
g = (() => n * 2)
println(g()) // 10
f = spawn (() => n * 2)
println(await f) // 0 <-- wrong, silentlyspawn needs a function pointer and the captured environment is not carried across. Pass the values you need as explicit parameters to a named function and spawn that.
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