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.

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

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.

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:

wyn
var n = 5
g = (() => n * 2)
println(g())              // 10
f = spawn (() => n * 2)
println(await f)          // 0  <-- wrong, silently

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

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.21.0