17 — Concurrency
Previous: Equality
日本語版: 17_concurrency.vibe.md
You do not spawn threads in vibe. You open a TaskGroup, spawn work
into it, and join the results — and the group does not outlive the call
that opened it. Everything is scoped, which is what makes the rest
checkable.
The package is @vibe/concurrent.
This chapter is the one unstable surface in the book. Everything here is
ADR-0068, which is still proposed: Nursery, Task, Sender/Receiver,
TaskGroup::run / spawn / spawn_suspend, and the compiler's Send rule.
It is outside the SemVer promise and can change within a Minor release — see
the stable surface §6. What it decides is
settled enough to build on; what is not settled is the Send/region checking
and which backend runs it (today's scheduler is a cooperative
run-to-completion prototype).
The Async effect itself is NOT in that bucket. ADR-0012 is accepted, and
with Async on a row is as stable as any other effect — it appears in shipped
builtin signatures like StdinStream::next. The unstable part is the
concurrency model built on top of it, not the vocabulary.
Spawning and joining
import @vibe/concurrent {
TaskGroup, TaskHandle
}
fn main with Console + Exception {
let answer = TaskGroup::run((n) -> {
let h = TaskGroup::spawn(n, () -> {
21 * 2
})
TaskHandle::join(h)
})
println("answer = \{answer}")
}
answer = 42
TaskGroup::run hands its body a group n. spawn starts work in it
and gives back a handle; join waits for the value. When run returns,
the group is finished — there is nothing left running that you would
have to remember to clean up.
The + Exception on main is not decoration: a spawned task can fail,
and join rethrows that failure as a TaskError, so join's own row
carries Exception[TaskError]. Leave it off main and the compiler
tells you the exact edit — hint: add 'with Console +
Exception[TaskError]' to 'main' (the plain Exception written above
covers it).
What may cross a spawn
Spawned work runs somewhere you do not control, so what it captures has
to be safe to move. That is the Send judgement, and the compiler makes
it structurally — you never write impl Send:
| may cross | may not |
|---|---|
| scalars, tuples | closures |
| Option of Send parts | Array, Bytes |
| structs and enums built from Send parts, with no mut fields | anything with a mut field |
The body you hand to spawn is itself a closure, and that one is fine —
the table is about what that body captures and what you send between
tasks, not about the body.
FrozenArray[T] exists for exactly this: an array you can send. A
persistent Map is not automatically Send.
The group cannot escape
TaskGroup::run opens a region, and the things that belong to it — the
group, handles, senders, receivers — may not be in the value the body
returns. Returning a handle would hand you something whose group has
already finished, so the compiler rejects it instead.
One hole worth knowing: the spawn check fires when TaskGroup::spawn is
written literally. Reaching it through a rename (let spawn =
TaskGroup::spawn) skips the check.
Suspending versus blocking
Two flavours of every waiting operation, and the difference is whether siblings get to run:
| blocks the instance | suspends the task |
|---|---|
| sleep | sleep_wait |
| send / recv | send_wait / recv_wait |
Reach for the _wait forms inside a TaskGroup; the blocking ones stop
everything, not just the caller.
Suspension is carried by an Async effect on this package, declared as
Suspend(Int) -> Int. It is a library effect like any other — not a
keyword, and it shows up in rows the same way.
Shared-nothing
Messages are values, and the intended semantics are a deep copy between
tasks. Today all tasks still share one heap, so send immutable data and
the two agree. When real threads land, the model stays shared-nothing —
TaskGroup plus the Send and region checks already describe that
shape, which is why they are enforced now.
Next: The CLI as an IDE.