Skip to content

Concurrency

The complete programmer-facing surface is three builtin functions — there is no async/await, no explicit locks, threads, or channels, and no ownership annotations:

Builtin Type Behavior
spawn(f) (() -> void) -> void run a zero-parameter closure on another thread
sync() () -> void barrier: join every task spawned so far
with(c, f) (cown, (T) -> U) -> U acquire the shared object c, run f with it, yield f’s result

Spawned tasks are also joined at the end of main. Use sync() before reading values a task mutates; without it the read may run ahead of the task.

The compiler decides how each captured value crosses the spawn boundary; the programmer never writes move/freeze/cown:

  • a captured primitive (number, bool) is copied by value into the task;
  • a captured heap object (record, array, …) that is shared with — and mutated by — concurrent code is promoted to a cown (concurrent owner): a guarded object whose accesses are serialized by its lock;
  • when the spawning function itself keeps using a promoted object, every one of its accesses is auto-guarded by the same lock, and the compiler emits a warning suggesting explicit with(cown, f) acquisition for finer-grained control. The lock is reentrant, so nested guarded accesses (including methods that mutate self) do not deadlock.

with(c, f) acquires c’s lock for the duration of f. The group form used internally acquires multiple cowns in address order, so a consistent lock order is maintained.

A single-cown with scope is also a region: objects stored into the guarded object while the scope runs belong to it, and references reaching into the region from outside (a module global, an object that outlives the scope) are counted. Leaving the scope while such a reference survives is a runtime error — region not closed: a reference escaped a `with` scope — because the escaped reference could later be used without holding the lock. Overwriting the escaping slot (e.g. setting the field back to null) before the scope ends releases the reference, and the scope closes normally.

These are compile errors today:

  • spawn’s argument must be a closure literal, or a local variable directly bound to one — not a function reference, a call result, or a parameter (the ownership pass must see the closure body).
  • The closure must take zero parameters; with’s closure exactly one.
  • A spawned task may not read or write a module global that any code writes; share state through captured objects instead.
  • spawn at the top level (in a module initializer, outside any function) is unsupported.
  • Nested spawns inside an already-spawned closure are analyzed like the outer one; a shape the pass cannot analyze is rejected rather than left unguarded.
type Counter = {
count: int32
}
fun Counter.add(self) {
self.count += 1
}
fun main() {
let counter = Counter { count: 0 }
spawn(() -> {
for i in [0..1000] {
counter.add()
}
})
spawn(() -> {
for i in [0..1000] {
counter.add()
}
})
sync()
with(counter, (c) -> {
println(c.count) // 2000: both increments serialized by the cown lock
})
}