Concurrency
Jennifer's concurrency surface is small on purpose. A program can launch a computation in the background with spawn, get back a handle of type task of T, and read the result later with the task library.
use io;
use task;
def t as task of int init spawn { return slowComputation(); };
# ... other work happens here, in parallel with the spawn body ...
def n as int init task.wait($t);
io.printf("answer: %d\n", $n);That's the whole story. No channels, no shared memory, no locks, no cancellation tokens, no goroutine-leak gotchas. The four pieces
- the
spawnkeyword, thetask of Ttype, thetasklibrary,
and the exit-time loud-fail contract - are what concurrency ships.
The model
A spawn { ... } block evaluates its body concurrently with the rest of the program. It returns immediately with a task of T, where T is the body's declared return type at the use site. The body runs to its own return EXPR;; that becomes the task's result. When the body raises a runtime error instead, that becomes the task's error.
Reading the result is a separate, explicit step:
| Want | Use |
|---|---|
| The value (block until ready) | task.wait($t) |
| Check whether it's done yet | task.poll($t) |
| Fire and forget | task.discard($t) |
| Wait for a list of tasks | task.waitAll($ts) |
| First-to-finish from a list | task.waitAny($ts) |
| Stop a task | task.cancel($t) + task.discard($t) |
| Wait with a deadline | task.waitTimeout($t, ms) |
See the task library reference for the full surface and worked examples per builtin.
Cancellation is cooperative. task.cancel($t) sets a flag the spawned body observes at its next loop checkpoint, where the runtime raises a catchable "task cancelled" - so a runaway spawn can be stopped (retiring the exit-time hang below), and a body that catches it inside a try / catch exits cleanly with a partial result. task.waitTimeout / task.waitAnyTimeout bound a wait, throwing a catchable "timed out" error. Full detail in the task reference's Cancellation and Timeouts sections.
Channels stream values. Where a task observes one result, a channel of T carries many values between goroutines: channel.make(capacity), channel.send (which copies the value in, so the receiver gets its own copy), channel.recv, channel.close, and a fan-in channel.select. A channel is a shared conduit, but the data through it is copied, so the value-semantics guarantee below still holds. Producer / consumer, worker pools, and pipelines are the usual shapes.
Why value-semantics capture matters
The biggest single design choice is that a spawn block captures its surrounding scope by deep copy, not by reference. Variables visible at the spawn site are copied into the spawn frame at the moment of launch; mutations on either side afterwards are independent.
use io;
use task;
def xs as list of int init [1, 2, 3];
def t as task of int init spawn {
return $xs[0] + $xs[1] + $xs[2]; # sees [1, 2, 3]
};
$xs[] = 99; # parent mutates after spawn
def total as int init task.wait($t); # still 6
io.printf("%d\n", $total);This is the same value-semantics discipline Jennifer applies everywhere - lists, maps, structs, bytes all copy on assignment and on function-parameter binding. Concurrency reuses the rule, so data races are impossible by construction. There is no shared mutable state to race on.
The cost is the obvious one: deep-copying a large structure into a spawn frame is O(N). For a 10 000-element list that's measurable. The benefit is that you never need a lock, a mutex, an atomic, or a channel to coordinate; concurrent code reads like sequential code with spawn markers.
Patterns
Parallel compute
use io;
use task;
func work(n as int) {
# ... CPU-bound computation ...
return $n * $n;
}
def tasks as list of task of int init [];
def i as int init 1;
while ($i <= 4) {
$tasks[] = spawn { return work($i); };
$i = $i + 1;
}
def results as list of int init task.waitAll($tasks);
# results are in list order, regardless of completion order
io.printf("%a\n", $results);task.waitAll returns results in the same order as the input tasks, not the order in which they completed. That's the property most parallel-compute code wants.
Fire and forget
For background work whose result you genuinely don't care about, launch with spawn and mark the handle with task.discard:
use task;
def alive as task of null init spawn {
# ... log a metric, send a notification, prefetch a cache ...
return null;
};
task.discard($alive);task.discard is required even for happy-path background work because of the loud-fail contract (next section). It declares to the runtime that no result is expected, so a later failure won't crash the program.
First-to-finish
task.waitAny returns the index of whichever task finished first; the caller follows up with task.wait($ts[$idx]) to read its value. The other tasks keep running and need explicit observation:
use io;
use task;
def fast as task of int init spawn { return 1; };
def slow as task of int init spawn { return 2; };
def candidates as list of task of int init [$fast, $slow];
def winner as int init task.waitAny($candidates);
def value as int init task.wait($candidates[$winner]);
task.discard($candidates[1 - $winner]);
io.printf("winner=%d val=%d\n", $winner, $value);The loud-fail contract
If a spawn body raises a runtime error and the program never observes the resulting task (never task.waits it, never task.waitAlls it, never task.discards it), Jennifer prints the error to stderr at program exit and exits non-zero.
This is deliberate. The default of every other concurrency model is some flavour of "errors in spawned work get silently dropped unless you go out of your way to handle them." That's a footgun
- bugs hide inside unobserved tasks until something else breaks
much later. Jennifer's contract is the inverse: an unobserved spawn error is always loud. The only way to silence it is to say so explicitly with task.discard.
use task;
def bad as task of int init spawn {
def xs as list of int init [];
return $xs[5]; # error inside the body
};
# the program ends here without touching $bad$ jennifer run example.j
spawn error (unwaited): list index 5 out of bounds (len 0)
$ echo $?
1Three ways to make a spawn quiet:
task.wait($t)- read the result. The error rethrows at the wait site; an enclosingtry/catchcan suppress it.task.waitAll($ts)/task.waitAny($ts)- same idea, withwaitAllobserving every survivor on the way out.task.discard($t)- the explicit fire-and-forget marker.
Doing nothing is not a fourth option.
Beware infinite spawns. The loud-fail scan blocks on every unobserved task to determine whether it produced an error.
spawn { while (true) { ... } }withouttask.discardwill hang the program at exit, since the goroutine never finishes. Long-running or potentially-non-terminating spawns should be paired with an explicittask.discardat the top of the scope.
What's deliberately not in v1
The current surface stops short of several features common to other languages' concurrency stories. Each was considered and deferred, not rejected:
- Channels. Inter-task communication beyond "wait for a return value" is not in v1. The chosen surface (
task of T+wait/waitAll/waitAny) handles the most common cases without the bookkeeping channels require. A channel primitive would arrive in a later milestone. - Cancellation tokens. No way to signal a running task to stop. The spawn body runs to completion or to an unhandled error. Cancellation is an open design question (cooperative flag? hard abort? structured-concurrency tree?) and stays deferred.
- Timeouts. No built-in
task.waitWithTimeout. Build one with a sentinel spawn that returns aftertime.sleepand atask.waitAnyover the pair. A higher-level helper may ship later. - Structured concurrency. No automatic scope-bounded termination of unwaited tasks. The loud-fail contract is Jennifer's lighter-weight answer: not as strong a guarantee as Trio / async-await scopes, but enough to keep spawned errors visible.
- Parallel
for/map. Nofor parallel (...)syntax. If you need it, write the explicitspawn/waitAllpair; that's what a parallel-map combinator would compile to anyway. - Atomics, mutexes, shared mutable state. Value-semantics capture removes the need.
The single most likely follow-on in this area is a time-aware helper for timeouts; channels are second; structured-concurrency scopes a distant third (and might never land, since the loud-fail contract already addresses the main pain point).
See also
tasklibrary reference - the five builtins, error propagation, worked examples.- Control flow -
try/catchworks the same way aroundtask.waitas around any other runtime error. - Methods - calling methods from inside a spawn body works normally; the body inherits the global env the same way any other call frame does.
docs/milestones.md- the concurrency entry has the design rationale (data-race-freedom by construction, the loud-fail decision, what's deferred to later).