Skip to content

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.

MetricValue
Spawn overhead~1.5μs per spawn+await (100K in ~159ms)
10K spawn+await14.9ms
CPU scaling (4× fib(35) via spawn + await_all)near-linear - 4 parallel ≈ 1 sequential wall time (33ms)
CPU scaling beyond 4throughput, not latency - 6 tasks 65ms, 8 tasks 99ms, 24 tasks 120ms
Determinism1000-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 ​

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

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

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

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

wyn
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 brancheight branches
v1.22.0204ms210ms
v1.21.0205ms1,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: only var a = f() and an explicit spawn became 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 called fib(35) with the same argument; because fib is 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 calls spawn f(x) n times is one call site executed n times, and nothing folds there.

CPU Parallelism ​

wyn
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 ​

wyn
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 shareWhat happens
A shared array, mutated from two tasksruntime panic naming the fix
A shared HashMap or HashSet, mutated from two tasksruntime panic naming the fix (new in v1.21.0)
A shared scalar global, mutated from two taskscompile-time error naming the fix
panic: concurrent HashMap mutation detected - HashMap is not thread-safe;
       use a channel or Shared to coordinate writers

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

wyn
var n = 5
g = (() => n * 2)
println(g())              // 10
f = spawn (() => n * 2)   // Error: `spawn` cannot run a closure

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

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

wyn
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 ​

wyn
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):

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

wyn
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).

wyn
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/parallel run as coroutines on an M:N scheduler, so cooperative I/O and Time.sleep engage everywhere. (The legacy thread pool remains as a fallback behind WYN_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 main never deadlocks even with no idle worker
  • Fire-and-forget spawn f() is drained at program exit so orphan tasks still run

Try It ​

🐉 Playground
Press Run or Ctrl+Enter

See Also ​

MIT License - v1.22.0