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. Fire-and-forget spawn got much faster, 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.

A note on the artifacts ​

The binaries you can download today are not the ones this release first shipped. After tagging, I put v1.20.0 through a deliberately hostile review - reading it the way someone on a link-aggregator front page would - and it found three things I was not willing to ship: a license-compliance violation in every binary (statically linked LGPL libtcc with no notices), the scheduler bug described below that burned a core in every concurrent program, and a data race that returned wrong answers with exit code 0. So the artifacts were rebuilt and replaced in place. Nothing had been downloaded - I checked the counts immediately before replacing them - so there was no v1.20.1 to publish, and no one to strand on a bad build.

The same review found roughly 25 unsupportable performance claims across the site and docs. Nearly every multiplier I had published was fabricated or traced to a baseline that no longer existed, while most of the absolute numbers held up. The worst was "1M string appends in 6ms, 33x faster than Python": it is actually ~11.7s and about 1.25x slower, because naive concatenation is O(n²) in both languages. Every number in this post is one I re-measured. The honest benchmarks section says which machine and which comparison versions, so you can call me on it.

Fire-and-forget spawn got much 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 now takes ~0.67s on an Apple M3 Pro. That is within ~2.7x of Go's goroutines (~0.25s 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 112ms, not ~800ms (await_all over the same eight: 113ms; 50 concurrent 200ms sleeps: 214ms). You get overlap for free, from the same spawn/await you already know.

And while they wait, they wait cheaply. Until this release, idle scheduler workers polled on a 100µs timeout instead of parking, so every program that spawned even one task burned about a core forever after - a waiting program cost as much as a working one. Waiting now costs ~0 CPU: a program sitting on a 3-second task measures single-digit milliseconds of CPU over a 2-second window, and a CI gate holds it there so it cannot regress quietly again. If you profiled Wyn's concurrency before and wrote it off, this is the thing that was wrong.

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. Being honest about it: it is not faster yet. On an Apple M3 Pro the GPU path lost to the CPU at every size measured (10M elements: 197ms GPU vs 134ms CPU; 50M: 679ms vs 584ms). This is an experimental spike to get the plumbing right, not a performance win. 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)41ms48ms958ms
Process startup7.3ms8.7ms42ms
hello-world binary size50KB2,450KB-
wyn run edit-loop (TCC)30ms--
spawn 1,000,000 tasks0.67s0.25s-

fib is a dead heat with Go and ~23x 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.22.0