Comments (7)
Hi @beorn! This is a great question that gets asked quite a bit, and having a clear story around how Effection integrates with async/await code is very important. While Effection code does integrate externally very well with async/await (for example every task is a Promise), it does not use the syntax internally, and operations written in Effection do not use it. The short answer about why is that it is impossible to provide the correctness guaranteed by structured concurrency with promises. This is just a fact which cannot be gotten around no matter how much we wish it were so (and believe me we do). In the same way that it is impossible to guarantee the memory safety of programs using GOTO statements, it is impossible to guarantee effect safety with async/await. We have explored this space extensively and unfortunately Promises are both eager, and uninterruptible which is the kiss of death when it comes to providing safety from leaking effects.
We delve into this problem in this blog post explaining some of the pitfalls of async/await. If you don't wand to read the whole thing, I think this diagram it contains showing a common way in which async function leak effects says it quite succinctly.
Vexing bugs like this case of unhandled promise rejections that Jake Archibald tracked down are hard to detect, harder still to diagnose, and even more difficult to solve are another symptom of lacking structured concurrency.
The good news is that once we accepted this, we found that programming with generators made thinking about our programs so much simpler that it far outweighed the cost of acclimating ourselves to it. Not only this, but Effection constructs hew very closely to native JavaScript; a trend which we're doubling down on with the upcoming version 3. Let me unpack what I think are the key points that frame how we think about this.
The first is generators are "Just JavaScript". They are a built in language feature that has been natively available in every major runtime for a number of years. They do not require transpilation, they are never becoming deprecated or going away, and while they are not as widely used as async functions, they are every bit as reliable and allow you to write code that "feels" like idiomatic JavaScript, except for swapping out a few keywords and symbols. As a core feature of the language, they enjoy all the benefits such as being well understood by TypeScript, and language servers that integrate with things like VSCode. The other option to guarantee safe effects is to use an API based on plain functions as you would with systems like RxJS or fp-ts. While this avoids using function*
, it ends up feeling much less idiomatic and much more esoteric when considered in the whole.
Generators can be manually resumed. This lies at the heart of the safety that Effection can guarantee that async/await cannot. It means that we can always make a generator run to completion and critically, run any cleanup code. By contrast, any time you await
a promise, you are never moving past that point in your code until that promise resolves or rejects: full stop. The only ways to mitigate this are either through a transpilation step, or by manually passing around abort signals; neither of which was a satisfactory solution for us. A compiler which transforms async/await
code into generators under the hood is the approach taken by both Ember Concurrency and effectsjs, but this not only makes a build step required for anybody to participate in the ecosystem (only feasible for highly controlled frameworks like Ember), it also changes and potentially breaks the canonical semantics of async/await
in subtle and unexpected ways. In our estimation, it's better to be able to rely on async/await
behaving a certain way in all contexts rather than having to hold in your head if you're using async/await in "native" or "enhanced" mode. Passing around abort signals is a more "just JavaScript" solution, but your apis become polluted by an extra "signal" argument to everything. It also requires the entire ecosystem of libraries to both use and pass around abort signals for any operation they might undertake which is not feasible at scale.
Generators are fundamentally simpler. Another way to think about them (which is the way Effection thinks about them) is that a JavaScript generator function is another name for a delimited continuation, and a delimited continuation is the fundamental building block upon which any flow control construct can be built. In fact, async/await is just a limited subset of delimited continuations applied to promise handling, and early prototypes of it were built on generators! Effection just lets you tap into that power safely and directly. For example, Effection v3 does not just use generators for async operations, it also uses it for managing context. If you've ever use hooks in React, it allows you do things like:
let credentials = yield* useAuth();
The difference with between this and React is that it does not have any of the caveats of a hook (manually specifying dependencies, disallowing within if statements, etc...) This hypothetical useAuth
operation can be either synchronous or asynchronous; it doesn't matter. What's important is that we stop, fetch something from the context, and then resume where we left off. What's not important is the timing of when that happens. In fact, have you noticed that there is a schism in JavaScript between async apis and sync apis? Symbol.iterator
vs Symbol.asyncIterator
, or Symbol.dispose
and Symbol.asyncDispose
? Generator vs AsyncGenerator. This dichotomy has a nasty way of working its tendrils into everywhere and fractalizing APIs. By contrast, all you need with a delimited continuation is a single construct, and having to work around this split begins to feel cumbersome after awhile.
Effection strives to be idiomatic JavaScript. One of our explicit goals is to feel as close to "normal" JavaScript as possible; an approach we're doubling down on with the upcoming release of version 3. For every piece of async code there is a very straightforward translation into Effection that reads the same, but will not leak effects. Unlike other effect libraries, our goal is not to provide a new way of doing I/O or functional composition, or higher order programming. Rather, we want to make working with JavaScript fundamentally safe and easy. For example, here is our async/effection Rosetta Stone:
Async | Effection |
---|---|
Promise |
Operation |
new Promise() |
action() |
await |
yield* |
async function |
function* |
AsyncIterable |
Stream |
AsyncIterator |
Subscription |
for await |
for yield* each |
As a result our core API is quite small, and actually getting smaller as we trim the fat.
All that said, we want Effection to be as easy as possible to integrate with existing async code.
- Every
Task
is aPromise
and can be awaited.
let task = run(function*() {
yield* sleep(100);
});
await task;
- Any
Promise
can be consumed directly by any operation. In fact, inv2
a promise is an operation. - Async Iterables can be consumed directly by any operation using the
stream
helper.
import { run, stream, each } from "effection";
async function* hello() {
yield "hello";
yield "world";
}
await run(function*() {
for (let message of yield* each(stream(hello())) {
console.log(message);
yield* each.next;
}
})
- any
Stream
coupled with aScope
can be consumed from async code as an async iterable.
In summary, Structured Concurrency is real, it's either coming fast or already arrived it many other languages like Swift, Java, Go, Kotlin, Rust, etc... In a way, as the pioneer of the async/await
syntax, JavaScript suffers from the early-mover's disadvantage insofar as it's primitives are not up to the task of providing structured concurrency guarantees since it pre-dates structural concurrency as a concept. Our goal is to make the most universally applicable way to provide those guarantees and the safety they deliver that feels the most comfortable to as many JavaScript developers as possible.
Does that answer your question?
from effection.
In answer to your question of why async/await at all, I don't think there was an appreciation of the need for a general "effect" mechanism in a language. Remember all of this API design predates the dawn of the FP epoch of JavaScript, and at that time the notion that a language needed to have a general "effects" mechanism wasn't really on the radar. It certainly wasn't on mine until around 2015 or so, well after these language features had cooled into stone. I don't think, for example, that I would be satisfied with just async/await that didn't let me also read things from the context. Using the full power of delimited continuations, implementing an Algebraic Effect system was a snap https://discord.com/channels/700803887132704931/1133802900620070912/1134147067627982988
Why have a separate abstraction for that? But again, algebraic effects weren't really on most people's radar until React hooks came out in what, 2018?
The biggest problem I have with putting promises at the center of the solution is that the big idea around structured concurrency is the concept of lexical scope, and enforcing the lifetimes of processes based on it. Cancellation is the way to enforce that, but it is not something that you should really ever do explicitly. So for example, Effection tasks have a halt()
method, but I find myself calling it very seldom, and whenever I do, I think "How could I have leaned on lexical scope to have solved this problem?" So solutions that focus on cancellation as an end and not a means are not optimally framed (in my book). As for the specific promise implementations go, they need you to express all your asynchrony programmatically in order to benefit because whenever you use an async function, the runtime is going to create a non-cancelable promise of unknown provenance. I don't see how they would compose with something like say a 3rd party library that used plain native async functions.
So what would I do with a magic TC39 wand? I guess it would be to make an async function behave more like a delimited continuation. First my biggest beefs with async functions:
- Every resolution must happen on the next tick of the run loop which means that other pieces of code can run. This
- then/catch/finally callbacks drop context and so you have to have checks like "isDestroyed" etc...
So what would I do if I could wave my magic wand? Well this is a really rough sketch, but here goes.
new Promise()
can only be called inside an async function, and whenever that async function exits, for whatever reason, the promise'sfinally
handlers are invoked, but anythen
orcatch
handlers are silently dropped if they have not been invoked already.- An async function can neither resolve or reject, or throw until all promises created in such a fashion have had all of their finally handlers run to completion (and because finally handlers can themselves return promises, those etc..).
- async functions return a
Task
object which extendsPromise
but also has acancel()
method. In addition to this, TheTask
object is an async iterable which returns the value of eachawait
point, and by callingnext
, you can directly provide the value of theawait
expression. - Another alternative to the previous mechanism is to have an "Await" middleware that handles
await
points where every async function inherits the await middleware of its caller, but there would be some Programatic API to get and set theawait
handler, likeAwait.set()
. - Promises are allowed to continue an async function from an
await
point in the same tick of the run loop. Otherwise, you have difficult things to track down like https://stackoverflow.com/questions/27081390/javascript-promises-and-race-conditions ( Example is in CoffeeScript, but the same principle applies.)
from effection.
Hmm.. you might be able to cobble together a structured concurrency solution that was based on async/await. Rather than using generator functions to express your program, you could use async generator functions to express you programs, and then wrap anything you await in a special function to make sure you can get yourself "unstuck" from the await
expression.
import { awaitable } from "structured concurrency";
let task = run(async function * () {
await awaitable(new Promise(resolve => setTimeout(1000, resolve)));
});
awaitable()
would read the implicit abort signal off the context, and then race the promise you're awaiting against a promise that resolves with that signal.
from effection.
Charles, thank you for the thorough response! I completely agree structured concurrency is worthwhile, and Effection is a very practical way to do that. I also agree that it's really unfortunate that supporting async seems to cause a lot of accidental complexity in the API designs.
While your approach is practical, I'm not sure I agree it's the most idiomatic possible — or otherwise why do we even have async/await when generators are enough? As you say, other languages have better support for structured concurrency, so it's really a shame that Javascript falls short.
I was wondering if you had any thoughts on existing cancelable promises frameworks? See comparison table for CPromise, Bluebird.js, p-cancelable — they claim to:
- Make Promises cancelable without extra instrumentation — so presumably the lifetime issue you mention above can be resolved?
- Support cleanup handlers — is this the only reason you want/need manual resumability?
Ideally there would be a TC39 proposal to better support (at least the primitives required) for structured concurrency, but even that would probably best be supported by some prior work or a proof of concept of sorts. If you had a magic wand and controlled TC39, what would you do? 😀
from effection.
It's really cool that Effection provides primitives for both structured concurrency and effects, and I was perhaps purely focusing on the structured concurrency aspect. To be honest, while I have done a little bit of FP, I wasn't planning to go pure-FP with my ts/js code, and so didn't have the ambition to look for an effects mechanisms/system for Javascript. But I guess it's possible to look at the structured concurrency aspect in isolation?
My experience with this goes back to Trio/Curio (Python) — this video by Nat Smith was en eye-opener — but I've been looking at languages like Swift and Kotlin with envy as the languages started to support structured concurrency natively.
I was looking for a pragmatic way to improve the promises-hell that is the current way of JS programming, and I thought having taskgroups/nurseries and the like would be a good way to tame that. I realize it won't be a zero-intervention solution since, as you say, async function
will create a regular Promise, but I was hoping there would be a way to do structured concurrency with async/await.
Given the dynamic nature of Javascript, wouldn't this be possible to manually wrap async functions to create Tasks (even use decorators if/when those become available for regular functions), and provide a taskgroup primitive — and would that be enough? Likewise, of the items in your magic wand wishlist, how many could be done with helpers/adapters and conventions (or linter rules/ typescript plugins) as a proof-of-concept — and if they prove the value, why not pitch it to TC39? :)
from effection.
Related Issues (20)
- Add `AbortSignal` resource HOT 6
- Confusion in docs on setting function name as label HOT 2
- Update @effection/fetch to depend on `cross-fetch ^3.1.5`
- Future<T> type improvements HOT 2
- Use `name` label in effection stack trace
- Typo in collections documentation HOT 2
- How production-ready Effection is? HOT 2
- Include a portion of process output in a failed `process.expect()` HOT 2
- Exception has occurred: Error: halted HOT 5
- Adding Effection backtrace to `DOMException` results in an error that cannot be read HOT 1
- Revamp `Subscription`, `Stream` APIs to make combinators easy HOT 4
- Add `iterate()` helper to drive stream iteration that is fully integrated with program structure. HOT 1
- Composable stream combinators HOT 1
- Update v3 docs to have feature parity with v2 docs
- implement `ensure()` for v3 HOT 1
- Power Scopes
- Rename `Channel.input` -> `Channel.port`, `Channel.output` -> `Channel.stream` HOT 1
- Scope simplification
- Add `createSignal` function for dispatching events from outside effection HOT 1
Recommend Projects
-
React
A declarative, efficient, and flexible JavaScript library for building user interfaces.
-
Vue.js
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
-
Typescript
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
-
TensorFlow
An Open Source Machine Learning Framework for Everyone
-
Django
The Web framework for perfectionists with deadlines.
-
Laravel
A PHP framework for web artisans
-
D3
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
-
Recommend Topics
-
javascript
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
-
web
Some thing interesting about web. New door for the world.
-
server
A server is a program made to process requests and deliver data to clients.
-
Machine learning
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
-
Visualization
Some thing interesting about visualization, use data art
-
Game
Some thing interesting about game, make everyone happy.
Recommend Org
-
Facebook
We are working to build community through open source technology. NB: members must have two-factor auth.
-
Microsoft
Open source projects and samples from Microsoft.
-
Google
Google ❤️ Open Source for everyone.
-
Alibaba
Alibaba Open Source for everyone
-
D3
Data-Driven Documents codes.
-
Tencent
China tencent open source team.
from effection.