Concurrency
Structured concurrency with the Task effect, cooperative cancellation, timeouts, retries, budgets, and deterministic scheduling in Sakura Lang.
Pre-alpha
The source-built compiler runs the core language and much of M3 on JavaScript: contracts, typed effects and failures, std handlers, permissions, LSP and MCP. M3 is still in progress; no release or stable compatibility contract exists. See the roadmap for implemented features and remaining gates.
Sakura Lang has no async/await keywords and no Async effect. Every Sakura Lang function may suspend, and nothing in application code can observe whether a call did. Suspension is a property of handlers: Clock.sleep suspends under the host clock and returns immediately under Clock.fixed (design §5.6).
Running work concurrently is a different matter. That uses the Task effect from std/task, which is in the prelude.
The Task effect
// std/task
pub type Scope // external, scoped
pub type Fiber<T> // external, scoped, affine
pub effect Task {
fn scope<T, e>(body: fn(Scope) -> T ! e) -> T ! e
fn spawn<T, e>(s: Scope, body: fn() -> T ! e) -> Fiber<T> ! e
fn join<T>(f: Fiber<T>) -> T
fn all<T, e>(bodies: List<fn() -> T ! e>) -> List<T> ! e
fn race<T, e>(bodies: List<fn() -> T ! e>) -> T ! e
fn yield_now() -> Unit
}
pub fn host() -> Handler<Task>
pub fn deterministic(seed: Int) -> Handler<Task>
pub fn par_map<A, B, e>(xs: List<A>, f: fn(A) -> B ! e) -> List<B> ! {Task | e}Scope and Fiber<T> are external types: the host runtime owns their representation and user code cannot construct them. A function that uses these operations has Task in its row. The child bodies' rows flow into the caller's row through e.
Scopes
Task.scope opens a scope. Task.spawn starts a child in it and returns a Fiber<T>. Task.join waits for the child's result.
import std/http.{Http, HttpError, RateLimited, Response}
import std/url.{Url}
fn load_both(a: Url, b: Url) -> (Response, Response) ! {Http, Task, Fail<HttpError | RateLimited>} {
Task.scope(fn(s) {
let fa = Task.spawn(s, fn() { Http.get(a) })
let fb = Task.spawn(s, fn() { Http.get(b) })
(Task.join(fa), Task.join(fb))
})
}Children never outlive their Scope. This gives three rules:
- Failure propagates. If a child fails or defects, the scope cancels its siblings, joins them, and propagates the first failure. Other failures are attached as
suppressedin the outcome. - Defects are observed, not caught.
Task.scopeobserves a child's defect asOutcome.Defect(info).trycannot catch it (design §5.4). - Resources are released. A
Fiber<T>is a resource: affine and bound to its scope (design §8.4).
Task.all runs a list of thunks and returns their results in order. Task.race returns the result of the first to finish. Task.par_map, a function of the std/task module, maps in parallel:
fn fetch_all(urls: List<Url>) -> List<Response> ! {Http, Task, Fail<HttpError | RateLimited>} {
Task.par_map(urls, fn(u) { Http.get(u) })
}Cancellation is cooperative
A cancelled task stops at its next effect operation or at Task.yield_now(). Pure loops are not interruptible. A long pure computation that should respond to cancellation calls Task.yield_now() now and then:
fn total_size(chunks: List<List<Int>>) -> Int ! {Task} {
var total = 0
for chunk in chunks {
Task.yield_now()
total = total + List.sum(chunk)
}
total
}Handlers in child tasks
Children inherit the handler environment at spawn time. A handler's var state is accessed serially, so two children performing the same effect go through the same instance one operation at a time (design §5.5).
No data races
Values have value semantics and closures capture by value (design §8.1). There is no shared mutable memory, so data races cannot occur. Tasks communicate through their results (join, all, race) and through handler state. There is no channel type in v0; bounded channels may be added as Task operations when a program needs streaming between fibers (design §16.13).
Logical races with the outside world, such as two tasks reading and then writing the same database row, are still possible. The type system does not prove their absence.
Detached work
There is no fire-and-forget spawn and no detached work in v0: every fiber belongs to a scope. A supervisor for long-running work is deferred (design §16.14). A capability, or a handler that captured one, may not flow into a Task.spawn closure whose scope outlives the with that produced it (V6002).
Timeouts, retries, and budgets
All three are ordinary std functions over thunks (design §5.9):
// std/retry
type Policy<E> = { retry_on: fn(E) -> Bool, max_attempts: Int, backoff: Duration, jitter: Bool, deadline: Option<Duration> }
fn policy<E>(retry_on: fn(E) -> Bool, max_attempts: Int) -> Policy<E> requires max_attempts >= 1
fn retry<T, E, e>(p: Policy<E>, action: fn() -> T ! {Fail<E> | e}) -> T ! {Clock, Random, Fail<E> | e}
// std/task
error Timeout(limit: Duration)
fn timeout<T, e>(limit: Duration, action: fn() -> T ! e) -> T ! {Clock, Task, Fail<Timeout> | e}
// std/budget: a handler, not a wrapper function
type Limits = { tokens: Option<Int>, usd: Option<Float>, calls: Option<Int> } // None = unlimited
fn limits(l: Limits) -> Handler<Budget>retrystarts a new invocation per attempt. It never resumes a failed one.retry_onis a pure predicate over the action's error unionE, which is whyPolicytakes the error type as a parameter.retry.policy(retry_on, max_attempts)builds a policy with default backoff; the record fields can also be set directly. The first retry waitsbackoff, each later wait doubles,jitterrandomizes the wait, anddeadlinebounds the total time. When attempts run out, the last error propagates; no extra "exhausted" tag is added. The std HTTP client refuses to retry non-idempotent methods without an idempotency key.timeoutcancels the action's task at its next effect operation, joins it, then fails withTimeout { limit }. Finalizers run.std/task.Timeoutis the only timeout tag in std;LlmErrorreuses it.Budgetis an effect withreserveandsettle.LlmandHttphandlers consume it when it is installed. Exceeding it fails withBudgetExceeded, whosekindisTokens,Usd,Calls,Bytes, orTime. Child tasks inherit the budget, split atomically.
Together, from design §15.10:
import std/http.{Http, HttpError, RateLimited, Response, Any}
import std/url.{Url}
import std/retry.{Policy, retry}
import std/task.{Timeout, timeout}
fn fetch_both(a: Url, b: Url) -> (String, String) ! {Http, Task, Clock, Random, Fail<HttpError | RateLimited | Timeout>} {
let policy = Policy { retry_on: fn(e) { match e { RateLimited { .. } => true, _ => false } },
max_attempts: 3, backoff: Duration.millis(200), jitter: true, deadline: None }
timeout(Duration.seconds(5), fn() {
Task.scope(fn(s) {
let fa = Task.spawn(s, fn() { retry(policy, fn() { Response.text(Http.get(a), 1_000_000).trust(@trust("display only")) }) })
let fb = Task.spawn(s, fn() { retry(policy, fn() { Response.text(Http.get(b), 1_000_000).trust(@trust("display only")) }) })
(Task.join(fa), Task.join(fb))
})
})
}A budget is installed as a handler and configured with a Limits record. From main in design §15.7:
let limits = Limits { tokens: Some(20_000), usd: Some(0.50), calls: None }
with Llm.anthropic(key, Model.of("claude-sonnet-5-5")), Budget.limits(limits) {
let t = extract_ticket(Untrusted.of("Login page 500s since 9am, blocking all customers"))
Console.print("{t.priority}: {t.title}")
}Determinism
Evaluation is strict and left to right, arguments before the call. Map and Set iterate in insertion order on every backend. Clock, Random, and Task are effects, so a test can replace all three (design §8.7):
test "deterministic under virtual time" {
with Http.scripted([(Any, Response.status(200, "x"))]), Clock.virtual(), Random.seeded(1), Task.deterministic(1) {
expect_eq(fetch_both(Url.from("https://a"), Url.from("https://b")), ("x", "x"))
}
}Under Task.deterministic(seed) scheduling is reproducible. Clock.virtual() advances only when told to. This is how with doubles as Sakura Lang's deterministic-simulation mechanism. See Testing.
Runtime model
The first backend compiles to JavaScript with a CPS trampoline and is single-threaded. A live host handler, such as Clock.sleep or Http.send, suspends by returning a step the driver awaits (design §13.3). The native backend (M5) will run tasks on worker threads and promote values sent across threads to atomic reference counts.
Not in v0
- An
Asynceffect,async/await, or colored functions (design §5.6). - Durable workflows. They are a later official package, not part of the language (design §1.1, §11.3).
- Actors.
spawnandactorare reserved keywords (Task.spawnis legal because a reserved word is accepted after.). - Channels, and detached or supervised tasks.
- Shared mutable state or locks in user code.
What the compiler checks
- Code that uses
Taskoperations declaresTaskin its row (V3001otherwise). - Child rows flow into the parent's row through the
evariables ofTask's operations. - A capability or capturing handler does not escape into a task that outlives its scope (
V6002). - A
Fiber<T>does not outlive its scope.
Next
That is the end of the tour. Continue with For Agents to see how agents work with the toolchain, or the Standard library.