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.
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.
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:
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.
await_all collects a whole list of tasks at once:
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:
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.
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:
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:
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:
[gpu]
enabled = true
float32 = trueMetal 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:
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:
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:
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:
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
Sharedand 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.
| Benchmark | Wyn | Go | Python |
|---|---|---|---|
| fib(35) | 41ms | 48ms | 958ms |
| Process startup | 7.3ms | 8.7ms | 42ms |
| hello-world binary size | 50KB | 2,450KB | - |
wyn run edit-loop (TCC) | 30ms | - | - |
| spawn 1,000,000 tasks | 0.67s | 0.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
wyn upgrade # from 1.19.x or later
wyn --version # 1.20.0No breaking changes. v1.20.0 is a drop-in upgrade.
Browse the version history or read the concurrency docs.