Concurrency
Wrenlift has three ways to run more than one thing at once. A Fiber is a coroutine. A Thread is a task that runs on another OS thread and shares the heap. An Isolate is a separate VM with its own heap. This page explains what each one does, when control moves, what memory it can see, and how they work together. None of this needs a package.
Side by side
Fiber | Thread (module thread) | Isolate (module isolate) | |
|---|---|---|---|
| Runs | one at a time, on the caller's thread | in parallel, on a pool of OS threads | in parallel, each on its own OS thread |
| Control moves | only when you say: call, yield, transfer, sleep, a wait | at any time | never between them |
| Memory | shared, and races cannot happen | shared: the same objects, the same heap | separate heaps; values are copied across |
| Waiting | Fiber.sleep, Fiber.park | Mutex, Lock, Deque | Channel, join |
| A data race is | impossible | possible; you guard against it | impossible |
| Underneath | a coroutine with a stack of its own | a fiber on a worker's scheduler | a VM per thread |
One rule connects them: a Thread is a Fiber that runs on another OS thread.
Every task in the pool is a real Fiber, and Thread.yield() is its yield.
The difference is that a Thread runs at the same time as the code that made it. A plain Fiber never does. That is why it gets its own name.
Fibers and the scheduler
A Fiber is a function with its own stack. You run it by hand. Control moves only where the code says so, which means two fibers can never touch the same object at the same moment.
When you have many fibers waiting on timers or on each other, let the built-in scheduler run them:
| Method | What it does |
|---|---|
Fiber.spawn(fn) | Hand a fiber to the scheduler. It runs a turn at a time. If it aborts, the task ends and the error stays in its .error. Returns the fiber. |
Fiber.tick(ms) | Give every ready task a turn. Keep going while tasks are ready and ms has not passed; 0 means one round. Returns true while tasks remain. |
Fiber.idle(ms) | Block until a task is ready, a timer is due, a wake arrives, or ms passes. |
Fiber.sleep(ms) | Inside a task, park on a timer while the other tasks run. At module top level, run the scheduler until the time is up. |
Fiber.waiter · Fiber.park(token, ms) · Fiber.wake(token) | A wait token you use once. park returns true when woken and false on timeout. wake works from any thread. |
Fiber.live | Tasks not yet finished. |
var log = []
Fiber.spawn {
log.add("a1")
Fiber.sleep(30) // a timer park; the others run meanwhile
log.add("a2")
}
Fiber.spawn {
log.add("b1")
Fiber.yield() // let the other tasks have a turn
log.add("b2")
}
while (Fiber.tick(0)) Fiber.idle(100)
System.print(log) // [a1, b1, b2, a2]
This is the shape a server wants: one task per connection, idle between polls, and no thread per request.
Compiled code can yield from any depth. A fiber's body is compiled like any other function.
Threads: parallel fibers on one heap
import "thread" for Thread, Mutex, Lock, Deque
Thread.create(fn) runs fn as a task on a worker thread and returns its handle. The workers start on the first call, one per hardware thread. Each new task goes to the worker with the least work.
The task shares the heap with the caller. The lists, maps and objects it reaches are the caller's own, not copies. This is the model for CPU-bound work that wants every core.
| Method | What it does |
|---|---|
Thread.create(fn) | Run fn as a task on a worker. Returns the Thread. |
.join() · .join(ms) · .isDone · .error | Wait for the task to end (join(ms) returns false when the time runs out); whether it has; the error it ended with, or null. |
Thread.current | The handle of the task being run, or null outside one. |
Thread.yield() | Let the other tasks of this worker run. |
Thread.count | Worker threads started. |
Mutex.new() · .acquire() · .tryAcquire() · .release() | Mutual exclusion. Not reentrant. When it is released, the task that waited longest takes it. |
Lock.new() · .wait() · .wait(ms) · .release() | A counting lock. release adds one unit. wait takes one, or waits until there is one. wait(ms) returns false when the time runs out. It starts at zero, so it works as a done signal or a semaphore. |
Deque.new() · .add(v) · .push(v) · .pop(block) · .count | A queue that any task can share. add puts at the back, push at the front. pop(true) waits for an item. pop(false) returns null when empty. |
import "thread" for Thread, Deque
class Fib {
static of(n) {
if (n < 2) return n
return of(n - 1) + of(n - 2)
}
}
var results = Deque.new()
var workers = []
for (i in 0...8) {
workers.add(Thread.create { results.add(Fib.of(30)) })
}
for (t in workers) t.join()
var total = 0
while (results.count > 0) total = total + results.pop(false)
System.print(total)
A wait parks the task, not the OS thread. The worker keeps running its other tasks. When any thread calls release, the scheduler wakes the waiter. So a producer and consumer need no polling:
import "thread" for Thread, Lock, Deque
var jobs = Deque.new()
var results = Deque.new()
var finished = Lock.new()
for (w in 0...4) {
Thread.create {
while (true) {
var job = jobs.pop(true) // parks until there is one
if (job == null) break // the stop marker
results.add([job, job * job])
}
finished.release()
}
}
for (n in 1..100) jobs.add(n)
for (w in 0...4) jobs.add(null) // one stop marker per worker
for (w in 0...4) finished.wait()
System.print(results.count) // 100
The rules
Threads share memory, so the rules are the ones Go programmers know:
- Two tasks writing the same list or map at the same time is a race. The runtime does not catch it. Put a
Mutexaround shared structures, or give each task its own and merge at the end. ADequeis safe to share, which is what the examples do. - A fiber runs on the thread that made it. Calling a fiber that another task created is an error. Make fibers inside the task that will drive them.
- When a task aborts, it ends quietly. Nothing is printed; the handle's
errorholds it afterjoin. - At module top level, a wait runs your own scheduler.
done.wait()in the examples runs anyFiber.spawntasks on the main thread while it waits.Fiber.sleepworks the same way there. - Module variables, static fields and object fields are single-word writes. They never tear. Between threads, you see the writes in whatever order they landed.
The garbage collector stops every thread before it runs, wherever they are: in the interpreter, in compiled code, or in a tight loop that never allocates. Threads change nothing about how you write code that allocates a lot.
Isolates: parallel and separate
import "isolate" for Isolate, Channel
An Isolate is a whole VM on its own thread, with its own heap. Nothing is shared. Isolate.spawn(module, arg) runs a module's top level on that thread with a copy of arg. Values come back as copies through a Channel.
Use it when you want parallel work with no chance of a race, or work that must not be able to touch the main program at all: a plugin, a sandbox, or a request whose crash should stay its own.
| Method | What it does |
|---|---|
Isolate.spawn(module) · Isolate.spawn(module, arg) | Run import "module" in a new VM on a new thread, with a copy of arg. Returns the Isolate. |
Isolate.arg · Isolate.cpus | The value this isolate was spawned with, or null on the main one. The number of hardware threads. |
.isDone · .error · .join() · .join(ms) | Whether it has finished. The error it ended with, or null. Wait for it; join(ms) returns false when the time runs out. |
Channel.new() · .send(v) · .receive() · .receive(ms) · .tryReceive() | An unbounded queue of copies. receive parks until a value arrives. It returns null if the channel closes or ms passes first. |
.close() · .isClosed · .count · .drop() | Close the channel: sends fail and parked receivers return null. drop releases it for good. |
// worker.wren
import "isolate" for Isolate
var arg = Isolate.arg // the copy of what spawn was given
var sum = 0
for (i in 0...arg["n"]) sum = sum + i
arg["reply"].send({"who": arg["who"], "sum": sum})
// main.wren
import "isolate" for Isolate, Channel
var reply = Channel.new()
var workers = []
for (k in 0...Isolate.cpus) {
workers.add(Isolate.spawn("worker", {"who": k, "n": 1000000, "reply": reply}))
}
for (k in 0...Isolate.cpus) {
var r = reply.receive() // parks until a message lands
System.print("%(r["who"]) -> %(r["sum"])")
}
for (w in workers) w.join()
These values can cross: null, booleans, numbers, strings, lists and maps of those, and Channel and Isolate handles. Anything else is an error at the send: an instance, a fiber, a function.
Isolate.spawn(module) finds the module the same way import does. Under hatch run the program's own modules are available to it. Under wlift its loader and spec dependencies carry over. An AOT binary does not yet carry a loader for the child module.
Which one
- Waiting on I/O or timers, many small things in flight, a server: use fibers and the scheduler. No parallelism, no locks, no races.
- CPU-bound work over shared data, on every core: use threads. Parallel, one heap, and you guard what is shared.
- Parallel work that must not share, or must not be able to break the main program: use isolates. Copies in, copies out.
How they compose
They work together, and the seams are where you would expect:
- A task can make fibers and
callthem. It can alsoFiber.spawnfibers onto its worker's scheduler. The worker runs those next to its tasks. The task waits for them with aLock, not withFiber.tick, because a task cannot drive the scheduler it is running on. - A fiber on the main thread can wait on a
Lockthat a thread releases, or on aChannelthat an isolate sends to. The main thread runs its other fibers in the meantime. - An isolate is a whole program. Its module can start threads of its own, and those threads can drive fibers.
- A task can
receivefrom an isolate's channel orjoinan isolate. It parks like anything else.
import "thread" for Thread, Lock, Deque
import "isolate" for Isolate, Channel
var done = Lock.new()
// A task that spawns fibers on its worker and waits for them.
Thread.create {
var got = Lock.new()
var acc = []
for (i in 0...3) {
Fiber.spawn {
Fiber.sleep(5 * i)
acc.add(i)
got.release()
}
}
for (i in 0...3) got.wait()
System.print("task fibers %(acc)")
done.release()
}
// A main-thread fiber woken by a thread.
var handoff = Lock.new()
Fiber.spawn {
handoff.wait()
System.print("main fiber woke")
done.release()
}
Thread.create {
Fiber.sleep(10)
handoff.release()
}
// An isolate (the worker module from above), answered from a task.
var reply = Channel.new()
var iso = Isolate.spawn("worker", {"who": 0, "n": 100, "reply": reply})
Thread.create {
System.print("isolate said %(reply.receive()["sum"])")
done.release()
}
for (i in 0...3) done.wait()
iso.join()
In the browser
A wasm module runs on one agent, so the three models change shape but keep their meaning.
A Fiber suspends the same way it does anywhere else.
The scheduler and the thread module run on that one agent: tasks take turns, a wait parks the task, and a release wakes it.
A producer and consumer work as they do natively. Nothing runs at the same time, and Thread.count is 1.
An Isolate is a Worker with memory of its own, and values still cross as copies. That is the part that runs in parallel in the browser.
The browser build has fibers today. The scheduler, threads and isolates follow in that order.