Jaal

It's always the compiler

The loop you meant to write

Here is a task every backend has: call a service once for each item, and collect the answers. Written the way you think about it, it is a loop. In Jaal it is that loop with one mark, @Parallel, on its index.

int n = input.length;
Out[] output = new Out[n];
for (@Parallel int i = 0; i < n; i++) {
    output[i] = svc.invoke(input[i]);
}

In an async language that loop no longer means what it says. In TypeScript, svc.invoke returns a promise, so you must choose. Await inside the loop and the calls run one after another. Run them in parallel and the loop disappears:

Reads like the loop, runs serially

for (let i = 0; i < input.length; i++) {
  output[i] = await svc.invoke(input[i]);
}

Runs in parallel, the loop is gone

const output = await Promise.all(
  input.map((x) => svc.invoke(x))
);

Some version of the second form, a map with Promise.all, futures, a fan-out node or a JSON map state, is what every one of the 20 workflow products we checked asks you to write for parallel work. The code, product by product, is here. It looks harmless. The engineers with the most production years are the ones who say it is not.

Experienced engineers have been saying this for a decade

None of them argue against concurrency. They argue against making the developer mark, split and orchestrate it by hand.

WhoThe argument
Bob Nystrom
Async splits functions into two kinds, and the async kind spreads up through every caller. You’ve still divided your entire world into asynchronous and synchronous halves · What Color is Your Function?
Armin Ronacher
An expert library author found asyncio too complex to use or advise on with confidence; eight years later he called async/await a bad abstraction for most languages and noted that an unresolved promise has no call stack. · I don’t understand Python’s Asyncio, Threads Beat Async/Await
Ron Pressler
Java chose virtual threads so that code keeps loops, try/catch and readable stack traces. The async style, JEP 444 says, gives up the language’s basic sequential composition operators, such as loops · JEP 444, State of Loom
Nathaniel J. Smith
Unscoped background tasks and fire-and-forget futures are the concurrency version of goto: they break abstraction, error propagation and cleanup. Timeouts and cancellation passed by hand break across every layer. · Go statement considered harmful, Timeouts and cancellation for humans
Andrew Kelley
Zig removed the async and await keywords from the language for good and moved asynchrony into a library interface the caller chooses, so a function never has to be marked. · Zig PR #24362, Zig’s New Async I/O
The Go team
Go runs plain blocking functions on many threads inside its runtime. The programmer sees none of this, which is the point. · Go FAQ
Matt Kline
Async Rust’s lifetime and Send demands make massively concurrent code painful in a language built for control. · Async Rust Is A Bad Language

What breaks in production

Each of these is documented by the language or tool itself. Several exist only because the mistake is silent.

What happensWhere it is documented
A forgotten awaitRuns out of order and loses its error; a lint rule, no-floating-promises, exists to catch it. · typescript-eslint
An unhandled rejectionSince Node.js 15 it crashes the whole process by default. · Node.js 15
await in a loopSilently serializes the work; a lint rule, no-await-in-loop, exists to catch it, and lists the cases where you must switch it off. · ESLint
Promise.all failsIt rejects at once but does not cancel the other calls; they keep running and their errors are ignored. · MDN
gather fails (Python)The other tasks are not cancelled and continue to run. · Python docs
A task nobody holds (Python)Can be garbage collected before it finishes; you must keep a reference. · Python docs
CancellationManual: every API must accept and check an AbortSignal; work that ignores it keeps going. · MDN
Stack tracesV8 reconstructs async traces only through await, Promise.all and Promise.any; JEP 444 says that in the async style stack traces give no usable context. · V8, JEP 444
select! in RustDrops the losing branches; some operations are not cancellation safe and can lose data. · Tokio
.NET deadlocksBlocking on async code deadlocks in UI and ASP.NET contexts; exceptions from async void cannot be caught. · Stephen Cleary, MSDN

None of this shows up in a demo. It shows up at 2 a.m., under load, after a deploy. That is why the people with the most production years write these essays.

The fair other side

Async has thoughtful defenders, and they have a point.

  • For some languages there was no alternative. A key designer of Rust’s async argues that, without a runtime or growable stacks, futures compiled to state machines were the only workable design for Rust. · Why async Rust?
  • Futures give control. Racing two operations, selecting over different kinds of work and cancelling inside one task are things explicit futures do well. · Let futures be futures
  • Visible waits can be worth it. Microsoft’s Midori team made every wait point explicit across a whole operating system and judged the friction worth paying, though the team was divided. · Asynchronous Everything
  • The pitfalls are learnable. Even while cataloguing them, Stephen Cleary calls async a great language feature. · MSDN

Notice what both sides share. Nobody is arguing that the developer should decide, call by call, which work may overlap. Even Zig, which removed the keywords, kept futures as library calls. The disagreement is about where the marking lives.

What Jaal does instead

You write the first loop and put @Parallel on its index: you mark the loop once. The compiler reads the data flow: each iteration writes only output[i] and reads only input[i], so the mark is accepted and the iterations run in parallel. A loop marked parallel whose iterations depend on each other is refused by name; left unmarked, it runs in order. Calls outside a loop that do not depend on each other run in parallel with nothing added. You never write await, a promise, a future or Promise.all: there is nothing to await and nothing to forget.

  • What is checked: the parallel versions Jaal derives for its test suite are checked by Rust’s compiler for data races (38 of 39 accepted; the last is being fixed). Checking every program before it runs is next. The claim is no data races, not no bugs. A service you call is a black box, and your logic is yours.
  • What is durable: each call’s reply is recorded. After a crash the work resumes from what was recorded instead of calling again.
  • What you can see: the graph is drawn from the code, and you can edit the graph to make steps run in order; the code is rewritten to match.
  • What this page does not cover: racing two calls and taking the first answer. That deserves its own write-up.

This takes the critics’ argument one step further. Threads, virtual threads and goroutines hide the waiting from you. In Jaal you mark the loop once; there is nothing to await and nothing to forget, and a compiler can check the parallelism you ask for and refuse it when it is unsafe.

Try it in the IDE   See the loop in 20 products