Skip to content

Wyn v1.20: Concurrency, Fast and Out of the Box

Concurrency has been in Wyn since v1.8, but this release is where it stops being a feature and starts being a reason to reach for the language. spawn is now about 18x faster for fire-and-forget work, awaited tasks overlap cooperatively by default with no thread-pool to configure and no async/await coloring to thread through your call graph, and a livelock that could hang a program under channel backpressure is gone. On top of that: an experimental GPU path for numeric map, richer recursive and nested data, and a memory-safety sweep verified under AddressSanitizer and ThreadSanitizer.

No breaking changes. Upgrade is drop-in.

Fire-and-forget spawn got ~18x faster

Fire-and-forget spawn f() - the kind you use to kick off work you don't immediately wait on - runs on the coroutine scheduler. This release rebuilt that path. Dispatching 1,000,000 tasks dropped from ~11.4s to ~0.66s on an Apple M3 Pro. That is within ~2.3x of Go's goroutines (~0.29s for the same workload) - close, honestly not beating it, but in the same league and no longer the bottleneck it was.

wyn
fn work(n: int) -> int {
    return n * n
}

fn main() {
    // a million lightweight tasks, no pool to size, no runtime to install
    for i in 0..1000000 {
        spawn work(i)
    }
    print("dispatched")
}

Awaited work overlaps by default - no ceremony

Here is the part that changes how programs are written. When you await a spawn, the tasks run cooperatively on the coroutine scheduler by default. There is no executor to pick, no async fn to color, no runtime handle to pass around. Blocking on I/O or Time::sleep inside a spawned task yields to the others instead of stalling them:

wyn
fn fetch(id: int) -> int {
    Time::sleep(100)          // simulate a 100ms I/O wait
    return id * 10
}

fn main() {
    a = spawn fetch(1)
    b = spawn fetch(2)
    c = spawn fetch(3)
    total = await a + await b + await c
    print("total: ${total}")   // 60 - and it finishes in ~100ms, not ~300ms
}

Eight overlapping 100ms sleeps finish in ~116ms, not ~800ms. You get overlap for free, from the same spawn/await you already know.

await_all collects a whole list of tasks at once:

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]
}

And parallel { } runs a block of statements concurrently and joins at the closing brace - no task handles to juggle when you just want two things to happen at once:

wyn
fn main() {
    x = 0
    y = 0
    parallel {
        x = 21 + 21
        y = 100 + 100
    }
    print("${x} ${y}")         // 42 200
}

Channels: bounded, back-pressured, no more livelock

Channels carry values between tasks. A bounded channel applies backpressure - a send into a full buffer waits for a recv - and in a previous release that backpressure could deadlock into a livelock that hung the whole program. That is fixed and regression-tested.

wyn
fn producer(ch: int) -> int {
    for i in 0..5 {
        Task.send(ch, i * 10)   // waits when the buffer is full, then resumes
    }
    return 0
}

fn main() -> int {
    ch = Task.channel(2)        // capacity 2
    spawn producer(ch)
    total = 0
    for i in 0..5 {
        total = total + Task.recv(ch)
    }
    print("sum: ${total}")      // 100
    return 0
}

Non-blocking receive now has an honest type. Task.try_recv returns int? - Some(v) when a value is ready, none when the channel is empty - so you can poll without blocking and without guessing:

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
}

Experimental: GPU dispatch for [float].map

This is the new toy, and I want to be precise about what it is and is not. Wyn can now run [float].map on the GPU. It is experimental, opt-in, and a single operation - not general GPU compute, not automatic. The code you write is exactly the CPU code you'd write anyway:

wyn
fn main() {
    xs = [1.0, 2.0, 3.0, 4.0]
    doubled = xs.map((x) => x * 2.0)
    print(doubled)
}

You turn the GPU path on in wyn.toml:

toml
[gpu]
enabled = true
float32 = true

Metal backs it on macOS, OpenCL on Linux and Windows, and both are loaded at runtime - a binary built with GPU enabled still runs on a machine with no GPU, falling back to the CPU automatically. On repeated maps over 10M+ elements it measured ~2-5x over the CPU on an Apple M3 Pro, an NVIDIA T4, and an A10G. Why opt-in and off by default? It computes in float32, so results can differ from Wyn's default float64 in the low bits. That is a real tradeoff, so you opt into it deliberately rather than having your numerics change under you. Treat it as a preview.

Recursive and nested data

The type system got more comfortable with data that nests into itself. Recursive enums make expression trees and linked lists first-class:

wyn
enum Expr {
    Num(int)
    Add(Expr, Expr)
    Mul(Expr, Expr)
}

fn eval(e: Expr) -> int {
    return match e {
        Expr.Num(n) => n,
        Expr.Add(a, b) => eval(a) + eval(b),
        Expr.Mul(a, b) => eval(a) * eval(b),
    }
}

fn main() {
    // (2 + 3) * 4
    tree = Expr.Mul(Expr.Add(Expr.Num(2), Expr.Num(3)), Expr.Num(4))
    print(eval(tree))          // 20
}

A linked list is the same shape - a variant that holds the rest of itself:

wyn
enum List {
    Nil
    Cons(int, List)
}

fn sum(l: List) -> int {
    return match l {
        List.Nil => 0,
        List.Cons(head, tail) => head + sum(tail),
    }
}

fn main() {
    xs = List.Cons(1, List.Cons(2, List.Cons(3, List.Nil)))
    print(sum(xs))             // 6
}

Result carries a struct payload on success, so a lookup can return the whole record or a reason it failed:

wyn
struct User {
    name: string
    age: int
}

fn lookup(id: int) -> Result<User, string> {
    if id == 1 { return Ok(User{name: "Ada", age: 36}) }
    return Err("not found")
}

fn main() {
    match lookup(1) {
        Ok(u) => print("found ${u.name}"),
        Err(e) => print("error: ${e}"),
    }
}

Dynamic nested arrays and map over arrays of structs work too:

wyn
fn main() {
    grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
    diag = 0
    for i in 0..3 {
        diag = diag + grid[i][i]
    }
    print("diagonal: ${diag}")   // 15
}

A few deeper cases - notably maps keyed to struct values, and Result over a custom error enum - still hit limits; where they do, you get a check-time message, not silent garbage. Those are the next targets.

A memory-safety sweep, verified by sanitizers

Underneath the new features, this cycle hunted memory bugs and proved the fixes under sanitizers rather than by inspection:

  • A string use-after-free on aliased returns (retain-on-return move analysis).
  • Heap corruption in str.repeat() from an integer-overflow out-of-bounds write.
  • A data race in the run queue, caught by ThreadSanitizer.
  • Added bounds guards on Shared and channel operations.

Every fix ships with a regression test, and the suite grew from 210 to 237 tests. The full set runs clean under AddressSanitizer and ThreadSanitizer.

Honest benchmarks

Apple M3 Pro, warm median, Go 1.26, Python 3.14. These are the numbers I actually measured, wins and losses both.

BenchmarkWynGoPython
fib(35)50ms54ms913ms
Process startup8.4ms--
hello-world binary size50KB2,450KB-
wyn run edit-loop (TCC)27ms--
spawn 1,000,000 tasks0.66s0.29s-

fib is a dead heat with Go and ~18x faster than Python; binaries are ~50x smaller than Go's; the edit loop is near-instant via TCC. Spawn is the honest one: much faster than it was, close to Go, not ahead of it.

Upgrading

bash
wyn upgrade          # from 1.19.x or later
wyn --version        # 1.20.0

No breaking changes. v1.20.0 is a drop-in upgrade.


Browse the version history or read the concurrency docs.

MIT License - v1.19.1