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.
| Who | The 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 languagesand 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 happens | Where it is documented |
|---|---|
A forgotten await | Runs out of order and loses its error; a lint rule, no-floating-promises, exists to catch it. · typescript-eslint |
| An unhandled rejection | Since Node.js 15 it crashes the whole process by default. · Node.js 15 |
await in a loop | Silently 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 fails | It 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 |
| Cancellation | Manual: every API must accept and check an AbortSignal; work that ignores it keeps going. · MDN |
| Stack traces | V8 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 Rust | Drops the losing branches; some operations are not cancellation safe and can lose data. · Tokio |
| .NET deadlocks | Blocking 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.