| ▲ | Playground Wisdom: Threads Beat Async/Await(lucumr.pocoo.org) |
| 168 points by samwillis 7 days ago | 97 comments |
| |
|
| ▲ | Const-me 7 days ago | parent | next [-] |
| The article seems specific to JavaScript, C# is different. > you cannot await in a sync function In C# it’s easy to block the current thread waiting for an async task to complete, see Task.Wait method. > since it will never resolve, you can also never await it In C#, awaiting for things which never complete is not that bad, the standard library has Task.WhenAny() method for that. > let's talk about C#. Here the origin story is once again entirely different Originally, NT kernel was designed for SMP from the ground up, supports asynchronous operations on handles like files and sockets, and since NT 3.5 the kernel includes support for thread pool to dispatch IO completions: https://en.wikipedia.org/wiki/Input/output_completion_port Overlapped I/O and especially IOCP are hard to use directly. When Microsoft designed initial version of .NET, they implemented thread pool and IOCP inside the runtime, and exposed higher-level APIs to use them. Stuff like Stream.BeginRead / Stream.EndRead available since .NET 1.1 in 2003, the design pattern is called Asynchronous Programming Model (APM). Async/await language feature introduced in .NET 4.5 in 2012 is a thin layer of sugar on top of these begin/end asynchronous APIs which were always there. BTW, if you have a pair of begin/end methods, converting into async/await takes 1 line of code, see TaskFactory.FromAsync. |
| |
| ▲ | User23 3 days ago | parent | next [-] | | > Originally, NT kernel was designed for SMP from the ground up, supports asynchronous operations on handles like files and sockets, and since NT 3.5 the kernel includes support for thread pool to dispatch IO completions: https://en.wikipedia.org/wiki/Input/output_completion_port Say what you will about Microsoft in that era (and there's a lot to be said), the NT kernel team absolutely crushed it for their customers' use cases. IOCP were years ahead of anything else. I pretty much hated all of the userspace Win32 work I did (MIDL, COM, DCOM, UGGGGGGGGH), but the Kernel interfaces were wonderful to code against. To this day I have fond memories of Jeffrey Richter's book. | | |
| ▲ | wbl 2 days ago | parent [-] | | It's not enough to have a nicish abstraction, how did it work in practice and eek out performance? I've heard Bryan Cantrell say there wasn't much there and would be curious to really know what the truth is and more explanation on both sides. |
| |
| ▲ | the_mitsuhiko 6 days ago | parent | prev | next [-] | | You're probably right that this is leaning in on JavaScript and Python more, but I did try to make a point that the origin story for this feature is quite a bit different between languages. C# is the originator of that feature, but the implications of that feature in C# are quite different than in for instance JavaScript or Python. But when people have a discussion about async/await it often loses these nuances very quickly. > Async/await language feature introduced in .NET 4.5 in 2012 is a thin layer of sugar on top of these begin/end asynchronous APIs which were always there. You are absolutely right. That said, it was a conscious decision to keep the callback model and provide "syntactic sugar" on top of it to make it work. That is not the only model that could have been chosen. | | |
| ▲ | cwills 2 days ago | parent [-] | | Seems like this article conflates threads C# with asynchronous operations a little. The way I see it, threads are for parallel & concurrent execution of CPU-bound workloads, across multiple CPU cores. And typically use Task Parallel Library. Async/await won’t help here. Whereas async/await for IO bound workloads, and freeing up the current CPU thread until the IO operation finishes. As mentioned, syntactic sugar on top of older callback-based asynchronous APIs. | | |
| ▲ | the_mitsuhiko 2 days ago | parent | next [-] | | I would make the argument it does not matter what the intention is, in practice people await CPU bound tasks all the time. In fact, here is what the offical docs[1] say: > You could also have CPU-bound code, such as performing an expensive calculation, which is also a good scenario for writing async code. [1]: https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous... | | |
| ▲ | coldtea 2 days ago | parent [-] | | >You could also have CPU-bound code, such as performing an expensive calculation, which is also a good scenario for writing async code. That's a scenario for a different reason though (to allow sharing the cpu between chunks of the calculation, e.g. to not freeze UI in JS). In that case you might want to async on CPU-bound code. But regarding maximizing utilization, you want async to take more advantage of a core's CPU when you got tasks waiting for IO, and threads to leverage more CPU cores when doing CPU bound tasks. |
| |
| ▲ | zarzavat a day ago | parent | prev [-] | | The difference in philosophy is: who is responsible for scheduling work? Is it the language, or is it the developer? In JS it's the language, for example node sits on top of libuv which is responsible for managing the thread pool and doing async IO. The advantages of this system are that it's very convenient, and the developer gets a safer single-threaded view over the multiple threads in use. The disadvantage is that the developer lacks control and if you actually want to write multithreaded APIs and not just use them then you have to drop down into a lower level language so you can talk to libuv or the OS. In C# there is no lower level language to drop down to. |
|
| |
| ▲ | zamadatix 7 days ago | parent | prev | next [-] | | Task.Wait() is just using the normal "thread" (in the way the author defines it later) blocking logic to do that in said case but I think the author is trying to talk about pure async/await approaches there as an example of why you still want exactly that kind of non-async "thread" blocking to fall back on for differently colored functions. Task.WhenAny() is similar to Promise.any()/Promise.race(). I'm not sure this is where the author is focusing attention on though. Regardless if your execution is able to move on and out of that scope those other promises may still never finish or get cleaned up. | |
| ▲ | throwitaway1123 6 days ago | parent | prev [-] | | > In C#, awaiting for things which never complete is not that bad, the standard library has Task.WhenAny() method for that. It's not that bad in JS either. JS has both Promise.any and Promise.race that can trivially set a timeout to prevent a function from waiting infinitely for a non-resolving promise. And as someone pointed out in the Lobsters thread, runtimes that rely on multi-threading for concurrency are also often prone to deadlocks and infinite loops [1]. import { setTimeout } from 'node:timers/promises'
const neverResolves = new Promise(() => {})
await Promise.any([neverResolves, setTimeout(0)])
await Promise.race([neverResolves, setTimeout(0)])
console.trace()
[1] https://lobste.rs/s/hlz4kt/threads_beat_async_await#c_cf4wa1 | | |
| ▲ | cyberax 3 days ago | parent [-] | | > Promise.race Ding! You now have a memory leak! Collect your $200 and advance two steps. Promise.race will waste memory until _all_ of its promises are resolved. So if a promise never gets resolved, it will stick around forever. It's braindead, but it's the spec: https://github.com/nodejs/node/issues/17469 | | |
| ▲ | throwitaway1123 2 days ago | parent [-] | | This doesn't even really appear to be a flaw in the Promise.race implementation [1], but rather a natural result of the fact that native promises don't have any notion of manual unsubscription. Every time you call the then method on a promise and pass in a callback, the JS engine appends the callback to the list of "reactions" [2]. This isn't too dissimilar to registering a ton of event listeners and never calling `removeEventListener`. Unfortunately, unlike events, promises don't have any manual unsubscription primitive (e.g. a hypothetical `removePromiseListener`), and instead rely on automatic unsubscription when the underlying promise resolves or rejects. You can of course polyfill this missing behavior if you're in the habit of consistently waiting on infinitely non-settling promises, but I would definitely like to see TC39 standardize this [3]. [1] https://issues.chromium.org/issues/42213031#comment5 [2] https://github.com/nodejs/node/issues/17469#issuecomment-349... [3] https://github.com/cefn/watchable/tree/main/packages/unpromi... | | |
| ▲ | kaoD 2 days ago | parent [-] | | This isn't actually about removing the promise (completion) listener, but the fact that promises are not cancelable in JS. Promises in JS always run to completion, whether there's a listener or not registered for it. The event loop will always make any existing promise progress as long as it can. Note that "existing" here does not mean it has a listener, nor even whether you're holding a reference to it. You can create a promise, store its reference somewhere (not await/then-ing it), and it will still progress on its own. You can await/then it later and you might get its result instantly if it had already progressed on its own to completion. Or even not await/then it at all -- it will still progress to completion. You can even not store it anywhere -- it will still run to completion! Note that this means that promises will be held until completion even if userspace code does not have any reference to it. The event loop is the actual owner of the promise -- it just hands a reference to its completion handle to userspace. User code never "owns" a promise. This is in contrast to e.g. Rust promises, which do not run to completion unless someone is actively polling them. In Rust if you `select!` on a bunch of promises (similar to JS's `Promise.race`) as soon as any of them completes the rest stop being polled, are dropped (similar to a destructor) and thus cancelled. JS can't do this because (1) promises are not poll based and (2) it has no destructors so there would be no way for you to specify how cancellation-on-drop happens. Note that this is a design choice. A tradeoff. Cancellation introduces a bunch of problems with promise cancellation safety even under a GC'd language (think e.g. race conditions and inconsistent internal state/IO). You can kinda sorta simulate cancellation in JS by manually introducing some `isCancelled` variable but you still cannot act on it except if you manually check its value between yield (i.e. await) points. But this is just fake cancellation -- you're still running the promise to completion (you're just manually completing early). It's also cumbersome because it forces you to check the cancellation flag between each and every yield point, and you cannot even cancel the inner promises (so the inner promises will still run to completion until it reaches your code) unless you somehow also ensure all inner promises are cancelable and create some infra to cancel them when your outer promise is cancelled (and ensure all inner promises do this recursively until then inner-est promise). There are also cancellation tokens for some promise-enabled APIs (e.g. `AbortController` in `fetch`'s `signal`) but even those are just a special case of the above -- their promise will just reject early with an `AbortError` but will still run to (rejected) completion. This has some huge implications. E.g. if you do this in JS... Promise.race([
deletePost(),
timeout(3000),
]);
...`deletePost` can still (invisibly) succeed in 4000 msecs. You have to manually make sure to cancel `deletePost` if `timeout` completes first. This is somewhat easy to do if `deletePost` can be aborted (via e.g. `AbortController`) even if cumbersome... but more often than not you cannot really cancel inner promises unless they're explicitly abortable, so there's no way to do true userspace promise timeouts in JS.Wow, what a wall of text I just wrote. Hopefully this helps someone's mental model. | | |
| ▲ | throwitaway1123 2 days ago | parent | next [-] | | > This isn't actually about removing the promise (completion) listener, but the fact that promises are not cancelable in JS. You've made an interesting point about promise cancellation but it's ultimately orthogonal to the Github issue I was responding to. The case in question was one in which a memory leak was triggered specifically by racing a long lived promise with another promise — not simply the existence of the promise — but specifically racing that promise against another promise with a shorter lifetime. You shouldn't have to cancel that long lived promise in order to resolve the memory leak. The user who created the issue was creating a promise that resolved whenever the SIGINT signal was received. Why should you have to cancel this promise early in order to tame the memory usage (and only while racing it against another promise)? As the Node contributor discovered the reason is because semantically `Promise.race` operates similarly to this [1]: function race<X, Y>(x: PromiseLike<X>, y: PromiseLike<Y>) {
return new Promise((resolve, reject) => {
x.then(resolve, reject)
y.then(resolve, reject)
})
}
Assuming `x` is our non-settling promise, he was able to resolve the memory leak by monkey patching `x` and replacing its then method with a no-op which ignores the resolve and reject listeners: `x.then = () => {};`. Now of course, ignoring the listeners is obviously not ideal, and if there was a native mechanism for removing the resolve and reject listeners `Promise.race` would've used it (perhaps using `y.finally()`) which would have solved the memory leak.[1] https://github.com/nodejs/node/issues/17469#issuecomment-349... | | |
| ▲ | kaoD 2 days ago | parent | next [-] | | > Why should you have to cancel this promise early in order to tame the memory usage (and only while racing it against another promise)? In the particular case you linked to, the issue is (partially) solved because the promise is short-lived so the `then` makes it live longer, exacerbating the issue. By not then-ing the GC kicks earlier since nothing else holds a reference to its stack frame. But the underlying issue is lack of cancellation, so if you race a long-lived resource-intensive promise against a short-lived promise, the issue would still be there regardless of listener registration (which admittedly makes the problem worse). Note that this is still relevant because it means that the problem can kick in in the "middle" of the async function (if any of the inner promises is long) while the `then` problem (which the "middle of the promise" is a special case of "multiple thens", since each await point is isomorphic to calling `then` with the rest of the function). Without proper cancellation you only solve the particular case if your issue is the latest body of the `then` chain. (Apologies for the unclear explanation, I'm on mobile and on the vet's waiting room, I'm trying my best.) | | |
| ▲ | throwitaway1123 2 days ago | parent [-] | | I don't want to get mired in a theoretical discussion about what promise cancellation would hypothetically look like, and would rather instead look at some concrete code. If you reproduce the memory leak from that original Node Github issue while setting the --max-old-space-size to an extremely low number (to set a hard limit on memory usage) you can empirically observe that the Node process crashes almost instantly with a heap out of memory error: #! /usr/bin/env node --max-old-space-size=5
const interruptPromise = new Promise(resolve =>
process.once('SIGINT', () => resolve('interrupted'))
)
async function run() {
while (true) {
const taskPromise = new Promise(resolve => setImmediate(resolve))
const result = await Promise.race([taskPromise, interruptPromise])
if (result === 'interrupted') break
}
console.log(`SIGINT`)
}
run()
If you run that exact same code but replace `Promise.race` with a call to `Unpromise.race`, the program appears to run indefinitely and memory usage appears to plateau. And if you look at the definition of `Unpromise.race`, the author is saying almost exactly the same thing that I've been saying: "Equivalent to Promise.race but eliminates memory leaks from long-lived promises accumulating .then() and .catch() subscribers" [1], which is exactly the same thing that the Node contributor from the original issue was saying, which is also exactly the same thing the Chromium contributor was saying in the Chromium bug report where he writes "This will also grow the reactions list of `x` to 10e5" [2].[1] https://github.com/cefn/watchable/blob/6a2cd66537c664121671e... [2] https://issues.chromium.org/issues/42213031#comment5 | | |
| ▲ | kaoD a day ago | parent [-] | | Just to clarify because the message might have been lost: I'm not saying you're wrong! I'm saying you're right, and... Quoting a comment from the issue you linked: > This is not specific to Promise.race, but for any callback attached a promise that will never be resolved like this: x = new Promise(() => {});
for (let i = 0; i < 10e5 ; i++) {
x.then(() => {});
}
My point is if you do something like this (see below) instead, the same issue is still there and cannot be resolved just by using `Unpromise.race` because the underlying issue is promise cancellation: // Use this in the `race` instead
// Will also leak memory even with `Unpromise.race`
const interruptPromiseAndLog = () =>
interruptPromise()
.then(() => console.log('SIGINT'))
`Unpromise.race` only helps with its internal `then` so it will only help if the promise you're using has no inner `then` or `await` after the non-progressing point.This is not a theoretical issue. This code happens all the time naturally, including in library code that you have no control over. So you have to proxy this promise too... but again this only partially solves the issue because you'd have to promise every single promise that might ever be created, including those you have no control over (in library code) and therefore cannot proxy yourself. And the ergonomics are terrible. If you do this, you have to proxy and propagate unsubscription to both `then`s: const interruptPromiseAndLog = () =>
interruptPromise()
// How do you unsubscribe this one
.then(() => console.log('SIGINT'))
// ...even if you can easily proxy this one?
.then(() => console.log('REALLY SIGINT'))
Which can easily happen in await points too: const interruptPromiseAndLog = async () => {
console.log('Waiting for SIGINT')
// You have to proxy and somehow propagate unsubscription to this one too... how!?
await interruptPromise()
console.log('SIGINT')
}
Since this is just sugar for: const interruptPromiseAndLog = () => {
console.log('Waiting for SIGINT')
return interruptPromise()
// Needs unsubscription forwarded here
.then(() => console.log('SIGINT'))
}
Which can quickly get out of hand with multiple await points (i.e. many `then`s).Hence why I say the underlying issue is overall promise cancellation and how you actually have no ownership of promises in JS userspace, only of their completion handles (the event loop is the actual promise owner) which do nothing when going out of scope (only the handle is GC'd but the promise stays alive in the event loop). |
|
| |
| ▲ | GoblinSlayer 2 days ago | parent | prev [-] | | For that matter C# has Task.WaitAsync, so waited task continues to the waiter task, and your code subscribes to the waiter task, which unregisters your listener after firing it, so memory leak is limited to the small waiter task that doesn't refer anything after timeout. |
| |
| ▲ | rerdavies 2 days ago | parent | prev [-] | | But if you really truly need cancel-able promises, it's just not that difficult to write one. This seems like A Good Thing, especially since there are several different interpretations of what "cancel-able" might mean (release the completion listeners into the gc, reject based on polling a cancellation token, or both). The javascript promise provides the minimum language implementation upon which more elaborate Promise implementations can be constructed. | | |
| ▲ | kaoD 2 days ago | parent [-] | | Why this isn't possible is implicitly (well, somewhat explicitly) addressed in my comment. const foo = async () => {
... // sync stuff A
await someLibrary.expensiveComputation()
... // sync stuff B
}
No matter what you do it's impossible to cancel this promise unless `someLibary` exposes some way to cancel `expensiveComputation`, and you somehow expose a way to cancel it (and any other await points) and any other promises it uses internally also expose cancellation and they're all plumbed to have the cancellation propagated inward across all their await points.Unsubscribing to the completion listener is never enough. Implementing cancellation in your outer promise is never enough. > The javascript promise provides the minimum language implementation upon which more elaborate Promise implementations can be constructed. I'll reiterate: there is no way to write promise cancellation in JS userspace. It's just not possible (for all the reasons outlined in my long-ass comment above). No matter how elaborate your implementation is, you need collaboration from every single promise that might get called in the call stack. The proposed `unpromise` implementation would not help either. JS would need all promises to expose a sort of `AbortController` that is explicitly connected across all cancellable await points inwards which would introduce cancel-safety issues. So you'd need something like this to make promises actually cancelable: const cancelableFoo = async (signal) => {
if (signal.aborted) {
throw new AbortError()
}
... // sync stuff A
if (signal.aborted) {
// possibly cleanup for sync stuff A
throw new AbortError()
}
await someLibrary.expensiveComputation(signal)
if (signal.aborted) {
// possibly cleanup for sync stuff A
throw new AbortError()
}
... // sync stuff B
if (signal.aborted) {
// possibly cleanup for sync stuff A
// possibly cleanup for sync stuff B
throw new AbortError()
}
}
const controller = new AbortController()
const signal = abortController.signal
Promise.cancelableRace(
controller, // cancelableRace will call controller.abort() if any promise completes
[
cancellableFoo(signal),
deletePost(signal),
timeout(3000, signal),
]
)
And you need all promises to get their `signal` properly propagated (and properly handled) across the whole call stack. |
|
|
|
|
|
|
|
| ▲ | arctek 7 days ago | parent | prev | next [-] |
| I actually think out of any language async/await makes the most sense for javascript. In the first example: there is no such thing as a blocking sleep in javascript. What people use as sleep is just a promise wrapper around a setTimeout call. setTimeout has always created microtasks, so calling a sleep inline would do nothing to halt execution. I do agree that dangling Promises are annoying and Promise.race is especially bad as it doesn't do what you expect: finish the fastest promise and cancel the other. It will actually eventually resolve both but you will only get one result. Realistically in JS you write your long running async functions to take an AbortController wrapper that also provides a sleep function, then in your outer loop you check the signal isn't aborted and the wrapper class also handles calling clearTimeout on wrapped sleep functions to stop sleeping/pending setTimeouts and exit your loop/function. |
| |
| ▲ | tempodox 2 days ago | parent | next [-] | | > async/await makes the most sense for javascript. More like: Has no alternative. There are no threads in JS. | | | |
| ▲ | mst a day ago | parent | prev | next [-] | | I rather like things like the mobx flow action system based on generators - you yield() promises rather than awaiting them and use yield* to splice other functions following the same approach. You can see a nice example of using that approach standalone here: https://axisofeval.blogspot.com/2024/05/delimited-generators... | |
| ▲ | huem0n a day ago | parent | prev [-] | | _ function blockingSleep(amt) {
let start = new Date().getTime()
while (start + amt > new Date().getTime()) {}
}
|
|
|
| ▲ | serbuvlad 7 days ago | parent | prev | next [-] |
| As someone who has only written serious applications in single-threaded, or manually threaded C/C++, and concurrent applications in go using goroutines, channels, and all that fun stuff, I always find the discussion around async/await fascinating. Especially since it seems to be so ubiquitous in modern programming, outside of my sphere. But one thing is: I don't get it. Why can't I await in a normal function? await sounds blocking. If async functions return promises, why can't I launch multiple async functions, then await on each of them, in a non-async function that does not return a promise? I get there are answers to my questions. I get await means "yeald if not ready" and if the function is not async "yeald" is meaningless. But I find it a very strange way of thinking nonetheless. |
| |
| ▲ | tbrownaw 3 days ago | parent | next [-] | | The `await` keyword means "turn the rest of this function into a callback for the when the Task I'm waiting on finishes, and return the resulting Task". Returning a Task only works if your function is declared to return a Task. The `async` keyword flags functions that are allowed to be transformed like that. I assume it could have been made implicit. You can do a blocking wait on a Task or collection of Tasks. But you don't want to do that from a place that might be called from the event loop's thread pool (such as anything called from a Task's completion callback), since it can lock up. | | |
| ▲ | captaincrowbar 2 days ago | parent | next [-] | | "The `await` keyword means "turn the rest of this function into a callback for the when the Task I'm waiting on finishes, and return the resulting Task"." Oh my god thank you. I've been trying to wrap my head around the whole async/await paradigm for years, basically writing code based on a few black magic rules that I only half understand, and you finally made it all clear in once sentence. Why all those other attempts to explain async/await don't just say this I can't imagine. | | |
| ▲ | quectophoton 2 days ago | parent | next [-] | | > Why all those other attempts to explain async/await don't just say this I can't imagine. On one hand, the people who write explanations are usually those who have lived through a language's history and learned things gradually, and also have context of how things were solved (or not) before a feature was introduced. On the other hand, the people who are looking for an explanation, are usually just jumping directly to the latest version of the language, and lack a lot of that context. Then those who do the explaining underestimate how much their explanations are relying on that prior context. That's why I think the ideal person to explain something to you, is someone with an experience as similar as possible to your own, but also knows about the thing that you want to know about. Because this way they will use terms similar to those you would use yourself. Even if those terms are imprecise or technically incorrect, they would still help you in actually understanding, because those explanations would actually be building on top of your current knowledge, or close to it. This is also why all monad explanations are... uh... like that. | |
| ▲ | xiphias2 2 days ago | parent | prev [-] | | It's because implementing this is not that easy: there are differences between the implementation of coroutines and await that makes it tricky (especially waiting for both CPU tasks and network events). For Python I loved this talk by David Beazley: https://www.youtube.com/watch?v=MCs5OvhV9S4&t=2510s He's implementing async/await from coroutines ground up by live coding on the stage |
| |
| ▲ | ivanjermakov 2 days ago | parent | prev [-] | | > I assume it could have been made implicit Not quite. It gets ambiguous whether to wrap return or not. Example: function foo(): Promise<number> {
if (...) { return Promise.resolve(5) }
...
}
but async version is: async function foo(): Promise<number> {
if (...) { return 5 }
...
}
Although you can bake into the language one way or another. |
| |
| ▲ | mrkeen 6 days ago | parent | prev | next [-] | | You can: https://hackage.haskell.org/package/async-2.2.5/docs/Control... As long as you don't mind - what did the article say? - >> transcending to a higher plane and looking down to the folks who are stitching together if statements, for loops, make side effects everywhere, and are doing highly inappropriate things with IO. | | |
| ▲ | do_not_redeem 3 days ago | parent [-] | | [flagged] | | |
| ▲ | binary132 3 days ago | parent | next [-] | | Thank goodness Captain Imperative scampered out of his goto hovel to remind us wizards to feel bad for being better than everyone. ;) | | | |
| ▲ | zeendo 3 days ago | parent | prev [-] | | What is with the unprovoked condescension? I guess I get the article author was trying to be provocative but what are you doing? | | |
| ▲ | mrkeen 2 days ago | parent [-] | | To lay it all out in full: TFA wrote out a bunch of problems with imperative code asked you to take it on faith that imperative is not inferior to functional. Then he added a bunch of snark, implying that functional programming is just a superiority complex (which is what I quoted). I quoted the snark to indicate that I was very aware that the author was mocking FP. serbuvlad asked why you can't just X. I linked to the exact function that does that for you. do_not_redeem then suggested I was unaware that the author was mocking FP. | | |
| ▲ | the_mitsuhiko 2 days ago | parent [-] | | FWIW I (author) didn’t want to mock functional programming. I consider myself an aficionado of functional programming patterns :) |
|
|
|
| |
| ▲ | skybrian 2 days ago | parent | prev | next [-] | | Ultimately, it's because of how JavaScript models time. In a browser, JavaScript has a single event queue, and events are delivered one at a time. Event delivery is supposed to be fast. Conceptually, handling one event can be seen as a single tick of a clock, and practically, it should be less than one animation frame, so the UI doesn't freeze. If something takes time, you need to split it up over multiple events, so that it spans multiple clock ticks. An async function call is one that can return in a different clock tick. A "normal" JavaScript function call always returns in the same clock tick, and if it takes too long, it makes the clock tick take longer, holding everything up until it's done. This is a logical consequence of JavaScript starting out single-threaded and being used to write UI event handlers. In other languages, there isn't this distinction. Assumptions about event handling aren't baked so deeply into the language, so normal function calls in other languages don't make this guarantee about happening atomically. | |
| ▲ | shepherdjerred 3 days ago | parent | prev | next [-] | | At least in JavaScript, you could mark all of your functions as `async`. This would mean that function would have to return a Promise and go back to the event loop which would add overhead. I imagine it'd kill performance since you'd essentially be context switching on every function call. The obvious workaround for this is to say "I want some of my code to run serially without promises", which is essentially is asking for a `sync` keyword (or, `async` which would be the inverse). | |
| ▲ | AlienRobot 3 days ago | parent | prev | next [-] | | In my experience with web browsers, you can't do this because Javascript can NEVER block. For example, if a function takes too long to run, it blocks rendering of the page. If there were ways to make Javascript asynchronously, browsers would have implemented it already, so I assume they can't do it without potential backward incompatibility. One exception is alert(), which blocks and shows a dialog. But I don't think I've ever seen a website use it instead of showing a "normal" popup with CSS. It looks ugly so it's only used to debug that code actually runs. I'm not knowledgeable about low-level interruptions, but I think you would need at least some runtime code to implement blocking the thread. In any case, even if the language provides this, you can't use it because the main thread is normally a GUI thread that can't respond to user interaction if it's blocked by another thread. That's the main point of using (background) threads in the first place: so the main thread never blocks from IO bottlenecks. | |
| ▲ | davnicwil 2 days ago | parent | prev | next [-] | | I get it - you'd like await semantics in a function without having to expose that detail to the caller. You can't get it directly in javascript but you're only one step away. Just not awaiting your function in the caller 'breaks the chain' so to speak, so that at least the caller doesn't have to be async. That way you can avoid tagging your function as async completely. Therefore one syntax workaround while still being able to use await semantics would just be to nest this extra level inside your function -- wrap those await calls in an anonymous inner function which is tagged async, which you just instantly call that without await, so the function itself doesn't have to be (and does not return a promise). | |
| ▲ | binary132 3 days ago | parent | prev | next [-] | | I found it all very confusing until I eventually wrote a little async task scheduler in Lua. Lua has an async / cooperative-coroutine API that is both very simple and easy to express meaningful coroutines with. The API is almost like a sort of system of safer gotos, but in practice it’s very much like Go channel receives, if waiting for a value from a channel was how you passed control to the producer side, and instead of a channel, the producer was just a function call that would return the next value every time you passed it control. What’s interesting is that C++20 coroutines have very nearly the same API and semantics as Lua’s coroutines. Still haven’t taken the time to dive into that, but now that 23 is published and has working ranges, std::generator looks very promising since it’s kind of a lazy bridge between coroutines and ranges. | |
| ▲ | Rohansi 3 days ago | parent | prev | next [-] | | `await` is only logically blocking. Internally the code in an async function is split up between each `await` so that each fragment can be called separately. They are cooperatively scheduled so `await` is sugar for 1) ending a fragment, 2) registering a new fragment to run when X completes, and 3) yielding control back to the scheduler. None of this internal behavior is present for non-async functions - in C# they run directly on bare threads like C++. Go's goroutines are comparable to async/await but everything is transparent. In that case it's managed by the runtime instead of a bit of syntactic sugar + libraries. | | |
| ▲ | neonsunset 2 days ago | parent [-] | | Goroutines are also more limited from UX perspective because each goroutine is a true green thread with its own virtual stack. This makes spawning goroutines much more expensive (asynchronously yielding .NET tasks start at just about 100B of allocated memory), goroutines are also very difficult to compose and they cannot yield a value requiring you to emulate task/promise-like behavior by hand by passing it over a channel or writing it to a specific location, which is more expensive. |
| |
| ▲ | akira2501 3 days ago | parent | prev | next [-] | | > Why can't I await in a normal function? You can. promise.then(callback). If you want the rest of your logic to be "blocking" then the rest of it goes in the callback. the 'then' method itself returns a promise, so you can return that from a non async function, if you like. > why can't I launch multiple async functions, then await on each of them, in a non-async function that does not return a promise? Typically? Exception handling semantics. See the difference between Promise.race, Promise.all and Promise.allSettled. | |
| ▲ | mr_coleman 6 days ago | parent | prev | next [-] | | In C# you can do a collection of Task<T>, start them and then do a Task.WaitAll() on the collection. For example a batch of web requests at the same time and then collect the results once everything is done. I'm not sure how it's done in other languages but I imagine there's something similar. | |
| ▲ | avandekleut 7 days ago | parent | prev | next [-] | | At least in node, its because the runtime is an event loop. | |
| ▲ | MatmaRex 2 days ago | parent | prev | next [-] | | You can await in a normal function in better languages, just not in JavaScript. | | |
| ▲ | egeozcan 2 days ago | parent [-] | | > Why can't I await in a normal function? await sounds blocking > You can await in a normal function in better languages, just not in JavaScript. Await, per common definition, makes you wait asynchronously for a Task/Promise. How on earth are you going to "await" for a Promise which also runs on the same thread on a synchronous function? That function needs to be psuedo-async as in "return myPromise.then(() => { /* all fn code here */ }), or you need to use threads, which brings us to the second point... With the closest thing to threads (workers) in JavaScript and using SharedArrayBuffer and a simple while loop, perhaps (didn't think too much on it), you can implement the same thing with a user defined Promise alternative but then why would you want to block the main thread which usually has GUI/Web-Server code? | | |
| ▲ | MatmaRex 2 days ago | parent [-] | | It seemed to me that the previous poster wanted a way to wait for the result of a promise (in a blocking manner), and I meant that this is available in other languages. You're right that it is not usually spelled "await". |
|
| |
| ▲ | chrisweekly 2 days ago | parent | prev [-] | | yeald -> yield |
|
|
| ▲ | cryptonector 2 days ago | parent | prev | next [-] |
| Threads are definitely not _the_ answer but _an_ answer. You can have as many threads as hardware threads, but in each thread you want continuation passing style (CPS) or async-await (which is a lot like syntactic sugar for CPS). Why? Because threads let you smear program state over a large stack, increasing memory footprint, while CPS / async-await forces you to make all the state explicit and compressed, thus optimizing memory footprint. This is not a small thing. If you have thread-per-client services, each thread will need a sizeable stack, each stack with a guard page -- even with virtual memory that's expensive, both to set up and in terms of total memory footprint. Between memory per client, L1/L2 cache footprint per client, page faults (to grow the stack), and context switching overhead, thread-per-client is much more expensive than NPROC threads doing CPS or async-await. If you compress the program state per client you can fit more clients in the same amount of memory, and the overhead of switching from one client to another is lower, thus you can have more clients. This is the reason that async I/O is the key to solving the "C10K" problem: it forces the programmer to compress per-client program state. But if you don't need to cater to C10K (or C10M) then thread-per-client is definitely simpler. So IMO it's really about trade-offs. Does your service need to be C10K? How much are you paying for the hardware/cloud you're running it on? And so on. Being more efficient will be more costly in developer cycles -- that can be very expensive, and that's the reason that research into async-await is ongoing: hopefully it can make C10K dev cheaper. But remember, rewrites cost even more than doing it right the first time. |
| |
| ▲ | bvrmn 2 days ago | parent [-] | | > Does your service need to be C10K? It's incorrect question. The correct one "Do your downstream services could handle C10K?" For example a service with a database should almost never be bothered with C10K problem unless most of the requests could skip db access. Every time you introduce backpressure handling in C10K-ready app it's a red flag you should simply use threads. | | |
| ▲ | cryptonector 2 days ago | parent [-] | | I think you're saying that a database can't be C10K. Why? You don't say but I imagine that you mean because it's I/O bound, not CPU bound. And that may be true, but it may also not be true. Consider an all in-memory database (no paging): it will not be I/O bound. > Every time you introduce backpressure handling in C10K-ready app it's a red flag you should simply use threads. That's an admission that threads are slower. I don't see why you wouldn't want ways to express backpressure. You need backpressure for when you have impedance mismatches in performance capabilities; making all parts of your system equally slow instead is not an option. | | |
| ▲ | bvrmn a day ago | parent [-] | | > I think you're saying that a database can't be C10K. I did not say this. But indeed most relational databases used for most applications can't handle C10K. > That's an admission that threads are slower. It highly depends from async runtime. Even if it's slower I pick thread based system anytime than async/await or channel spaghetti. My experience is largely with python an go where it's quite easy to miss something and get broken app. > I don't see why you wouldn't want ways to express backpressure. It's an additional code, usually quite messy and fragile. It's hard to maintain systems with backpressure handling in each component. Issues lead to higher investigation times. If you want to keep long connections it's almost always more convenient to split system to a small event-based (epoll, kqueue, io_uring) frontend and multiprocess and/or multithread backend. Frontend even could be written with async/await if nginx is not suitable. |
|
|
|
|
| ▲ | whoisthemachine 3 days ago | parent | prev | next [-] |
| > Your Child Loves Actor Frameworks It turns out, Promises are actors. Very simple actors that can have one and only one message that upon resolution they dispatch to all other subscribed actors [0]. So children might love Promises and async/await then? Personally, I've often thought the resolution to the "color" debate would be for a new language to make all public interfaces between modules "Promises" by default. Then the default assumption is "if I call this public function it could take some time to complete". Everything acting synchronously should be an implementation detail that is nice if it works out. https://en.wikipedia.org/wiki/Futures_and_promises#Semantics... |
| |
| ▲ | marcosdumay 2 days ago | parent | next [-] | | > Personally, I've often thought the resolution to the "color" debate would be for a new language to make all public interfaces between modules "Promises" by default. Works well on Haskell. You can even remove that "public" and "between modules". | |
| ▲ | emadda 3 days ago | parent | prev [-] | | That's a nice mental model for promises. But it is not always true that one promise instance can be awaited in multiple places. In Swift you cannot get the ref to the Promise instance, so you cannot store it or await it at multiple places. Once you start an async fn the compiler forces you to await it where it was started (you can use `await task.value`, but that is a getter fn that creates a new hidden promise ref on every call). | | |
| ▲ | manmal 2 days ago | parent | next [-] | | If I understand the use case correctly, then, in Swift, Tasks provide exactly what you have described. You can use the Task object in multiple places to await the result. | |
| ▲ | whoisthemachine 3 days ago | parent | prev [-] | | I'm not familiar with Swift, but it still sounds like it's describing an actor model, just one with a subset of the functionality. |
|
|
|
| ▲ | mst a day ago | parent | prev | next [-] |
| > Stackless did not have a bright future because the stackless nature meant that you could not have interleaving Python -> C -> Python calls and suspend with them on the stack. perl's Coro.pm (https://p3rl.org/Coro) includes a C-level coroutine implementation so if you call back into perl code from C code, it marks that underlying coroutine as occupied until it returns back through the C code into something that's perl all the way down. (I got rapidly annoyed with it because I kept managing to segfault the damn thing, but I am the sort of programmer who seems to manage to break everything, and a bunch of other people whose abilities I respect have built substantial systems using it without suffering from the same; please either assume for the sake of argument that I was holding it wrong, or at least consider that the concept is extremely neat even if a tad on the tricky side to make fully robust) |
|
| ▲ | RantyDave 7 days ago | parent | prev | next [-] |
| Almost as an aside the article makes an interesting point: memory accesses can block. Presumably if it blocks because it's accessing a piece of hardware the operating system schedules another thread on that core ... but what if it blocks on a 'normal' memory access? Does it stall the core entirely? Can 'hyperthreading' briefly run another thread? Does out of order execution make it suddenly not a problem? Surely it doesn't go all the way down to the OS? |
| |
| ▲ | magicalhippo 3 days ago | parent | next [-] | | > what if it blocks on a 'normal' memory access? If the CPU gotta wait for memory it's gotta wait, and so it just won't make progress. Though we typically say that the CPU has stalled. How long depends on if it's found in one of the caches, they're progressively slower, or main memory. All the fancy techniques like out of order execution, speculative execution and hyperthreads are mainly there to trigger memory reads as soon as possible to reduce how long it is stalled. Some nice detailed SE answer here[1] with some details. [1]: https://electronics.stackexchange.com/a/622912 | |
| ▲ | the_mitsuhiko 6 days ago | parent | prev | next [-] | | > but what if it blocks on a 'normal' memory access? Does it stall the core entirely? You won't be able to suspend a virtual thread, so that OS thread is going to be blocked no matter what. As far as kernel threads are concerned I think in practice when a page fault happens the kernel yields and lets another thread take over. | |
| ▲ | anonymoushn 3 days ago | parent | prev [-] | | Hyperthreading is a feature where a single core can process two unrelated instruction streams (i.e. two threads) which is useful for software that executes few instructions per cycle. |
|
|
| ▲ | exabrial 2 days ago | parent | prev | next [-] |
| > The language that I think actually go this right is modern Java. Project Loom in Java has coroutines and all the bells and whistles under the hood, but what it exposes to the developer is good old threads. There are virtual threads, which are mounted on carrier OS threads, and these virtual threads can travel from thread to thread. If you end up issuing a blocking call on a virtual thread, it yields to the scheduler. I completely agree! They studied a lot of "bad" implementations and moved slow to get it right |
|
| ▲ | manmal 2 days ago | parent | prev | next [-] |
| I view Swift‘s Tasks as a thread-like abstraction that does what the author is asking for. Not every Task is providing structured concurrency in the strict sense, because cancellation has to be managed explicitly for the default Task constructor. But Tasks have a defined runtime, cancellation, and error propagation, if one chooses to use a TaskGroup, async let, or adds some glue code. The tools to achieve this are all there. |
|
| ▲ | NeutralForest 7 days ago | parent | prev | next [-] |
| I thought that was interesting and I definitely get the frustration in some aspect. I'm mostly familiar with Python and the function "coloring" issue is so annoying as it forces you to have two APIs depending on async or not (look at SQLAlchemy for example). The ergonomics are bad in general and I don't really like having to deal with, for example, awaiting for a result that will be needed in a sync function. That being said, some alternatives were mentioned (structured concurrency à la Go) but I'd like to hear about people in BEAM land (Elixir) and what they think about it. Though I understand that for system languages, handling concurrency through a VM is not an option. |
| |
| ▲ | the_mitsuhiko 7 days ago | parent | next [-] | | > structured concurrency à la Go Go does not have structured concurrency. Goroutines as far as I know don't have much of a relationship with each other at all. | | | |
| ▲ | toast0 3 days ago | parent | prev | next [-] | | Sorry, I kind of spaced on reading the article, but from BEAM land, everything is built around concurrent processes with asynchronous messaging. You can send a message to something else and wait for the response immediately if you want to write in a more blocking style. And you can write a function that does bot the sending a message and the waiting, so you don't really need to think about it if you don't want to. All I/O pretty much feels the same way, although you get into back pressure with some I/O where if there's a queue you can opt to fail immediately or block your process until the send fits in the queue. The underlying reality is that your processes don't actually block, BEAM processes are essentialy green threads that are executed by a scheduler (which is an OS thread), so blocking things become yields, and the VM also checks if it should yield at every function call. BEAM is built around functional languages, so it lacks loops and looping is handled by recursive function calls, so a process must make a function call in a finite amount of code, and so BEAM's green threading is effectively pre-emptive. The end result of all this is you can spawn as many processes as you like (i've operated systems with one process per client connection, and millions of client connections per node). And you can write most of your code like normal imperitive blocking code. Sometimes you do want to separate out sending messages and receiving responses, and you can easily do that too. This is way nicer than languages with async/await, IMHO; there's no trickyness where calling a blocking function from a async context breaks scheduling, and calling an async function from a non-async context may not be possible... You do still have the possibility of a function blocking when you didn't expect it to, but it will only block the process that called it and transitively, those processes that are waiting for messages from the now blocked process. Java's Project Loom seems like it will get to a pretty similar place, eventually. But I've seen articles about some hurdles on the way; there's some things that still actually block a thread rather than being (magically) changed to yielding. Again, IMHO, people didn't build async/await because it is good. They built it because threads were unavailable (Javascript) or to work around the inability to run as many threads as would make the code simple. If you could spawn a million OS threads without worrying about resource use, only constrained languages would have async/await. But OS threads are too heavy to spawn so many, and too heavy to regularly spawn and let die for ephemeral tasks. | | |
| ▲ | vacuity 2 days ago | parent [-] | | To offer a different perspective, green threads are far easier to use for programmers (which is a valuable quality; I'm not dismissing that), but async/await is the better abstraction if you know how to use it. I justify this more in [0]. Async/await is great, but pragmatically it should not be pushed as the sole "asynchronous programming" abstraction. It requires more QoL infrastructure to be even somewhat usable as general-purpose, although I think it'll be worthwhile to continue bridging the gap. [0] https://news.ycombinator.com/item?id=41900215 |
| |
| ▲ | lbrindze 7 days ago | parent | prev [-] | | BEAM very much falls into the same camp as the author's description of Scratch does at the beginning of the article. You have a lot more granular control than Scratch, of course, but it also loosely follows the actor model |
|
|
| ▲ | huem0n a day ago | parent | prev | next [-] |
| > The closest equivalent would be a stupid function that calls a very long running sleep I disagree. The equivalent would be a thread that's never joined, like a thread with an infinite loop |
|
| ▲ | nextcaller 2 days ago | parent | prev | next [-] |
| I'm still not sure if function coloring is also a problem in javascript. The problem became very clear in other languages like python or c#. But in javascript i've been writing code only awaiting a function when I need to and haven't ran into issues. I might write some simple experiment to check myself. |
| |
| ▲ | neonsunset 2 days ago | parent [-] | | Why is it not a problem in JavaScript but is one in C#? | | |
| ▲ | nextcaller 2 days ago | parent [-] | | In c#/python you are forced to await the whole chain, the compiler forces you. While in javascript it allows me without a warning. That's why it seems as if things work differently in javascript, if it allows me to (not) use await freely. (I don't remember if c# was just a warning or an error). | | |
| ▲ | neonsunset 2 days ago | parent [-] | | No. It is best to verify assumptions first before presenting them as facts. It is irritating to see this everywhere as the quality of discussion keeps going down. In order to get the result of a promise in JS you have to await it (or chain it with 'then', much like .NET's 'ContinueWith' although it is usually discouraged). Consumption of tasks in .NET follows a similar pattern. Async implementations in .NET and Python have also vastly different performance implications (on top of pre-existing unacceptable performance of Python in general). The analyzer gives you a warning for unawaited tasks where not awaiting task indicates a likely user error. Which is why if you want to fire and forget a task, you write it like '_ = http.PostAsync(url, content);` - it also indicates the intention clearly. | | |
|
|
|
|
| ▲ | FlyingSnake 2 days ago | parent | prev | next [-] |
| I expected to see Swift but seems like most such discussions overlook it. Here’s a great discussion that goes deeper into it: https://forums.swift.org/t/concurrency-structured-concurrenc... |
|
| ▲ | pwdisswordfishz 2 days ago | parent | prev | next [-] |
| > Go, for instance, gets away without most of this, and that does not make it an inferior language! Yes, it does. Among other things. |
|
| ▲ | mark_l_watson 2 days ago | parent | prev | next [-] |
| Nice read, and the article got me to take a look at Java’s project Loom and then Eric Normand’s writeup on Loom and threading options for Clojure. Good stuff. |
|
| ▲ | agentkilo 2 days ago | parent | prev | next [-] |
| People should try Janet (the programming language). Its fiber abstraction got everything right IMO. Functions in Janet don't have "colors", since fiber scheduling is built-in to the runtime in a lower level. You can call "async" functions from anywhere, and Janet's event loop would handle it for you. It's so ergonomic that it almost feels like Erlang. Janet has something akin to Erlang's supervisor pattern too, which, IMO, is a decent implementation of "structured concurrency" mentioned in the article. |
|
| ▲ | andrewstuart 6 days ago | parent | prev | next [-] |
| Feels academic because despite the concerns raised, I only experience async/await as a good thing in real world. |
| |
| ▲ | BugsJustFindMe 5 days ago | parent [-] | | I don't. Now what?
I agree with the author, especially in Python. The core Python developers so lost their minds fleeing from the GIL that they forgot historical lessons about how much more ergonomic preemptive multitasking is vs cooperative. | | |
| ▲ | smitty1e 3 days ago | parent [-] | | > so lost their minds fleeing from the GIL that they forgot historical lessons I just don't agree. `async def` gets the fact that we've departed Kansas, good Toto, right out front. Async moves the coder a step in the direction of the operating system itself. The juice is not worth the squeeze unless the project bears fruit. I hardly do enough networky stuff to make this facility useful to me, but I'm grateful to the hours poured into making it part of Python. Contra the author of The Famous Article, threads seem gnarlier still, and I would likely architect out any async parts of a system into another service and talk to it over a port. | | |
| ▲ | BugsJustFindMe 2 days ago | parent [-] | | > I would likely architect out any async parts of a system into another service and talk to it over a port This doesn't get you any closer to the goal though. Would you talk to the other service with a blocking socket request or a non-blocking one? A non-blocking socket request either invokes a system thread or asyncio. You can't escape that part. |
|
|
|
|
| ▲ | vanderZwan a day ago | parent | prev | next [-] |
| I'm a little annoyed with the repeated claim in the article that "threads" do not exist in JavaScript when the MDN page on web workers[0] starts with: > Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application. Sure, it's not like a unix thread, it's message-passing based instead. Because event-based concurrency came first, and so the natural way to add threads to it was to make them message-passing based. Given that the author expresses their love of the actor model makes it extra surprising they don't mention it, since it seems to be pretty much what they want semantically speaking. If you want to avoid promises you can also have lots of fun with workers and message channels[1] to implement your own concurrency approaches. What I'm even more annoyed by is that everyone always acts like Nathaniel J. Smith invented structured concurrency. There's an entire field of overlooked concurrency paradigms in the family of dataflow programming languages[2], which goes back all the way to the eighties. My personal recent-ish favorite among them being Céu[3][4]. Funny enough it's also yet another language with a completely different take on what "async" and "await" mean. Céu an imperative language centered where the concurrency used both asynchronous events and sychronous ones. The former represents external events. The latter internal ones. Code flow can be split into synchronous concurrent paths by writing out each path directly, similar to if .. else .. branches. Instead of "branches" they're called "trails", and started with par/or and par/and as keywords. par/or do
loop do
await 1s;
_printf("Hello ");
await 1s;
_printf("World!\n");
end
with
await async do
emit 10s;
end
end
Trails are visited in lexical order. If an "await" keyword is encountered the trail is suspended until the event it waits for fires. This is the benefit of synchronous, single-threaded concurrency: have the benefit of allowing the order in which events fire deterministic and expressible in lexical order.As mentioned, synchronous events represent external inputs. From the perspective of a single module of single-threaded code. they're queued up and get resolved one at a time after the current series of synchronous events finishes and all activated trails in the module are suspended (i.e. awaiting another external event to wake one or more of them in lexical order). The "async" keyword exists solely to simulate such an asynchronous external event inside the code. In the above example, the "emit 10s" simulates passing 10 seconds of time (and could also have been written as a simple "await 5s", but then it wouldn't have demonstrated the async keyword). So in the above example, the first trail is entered, starting a loop. Then at each "await 1s" it suspends until one second has passed, after which it resumes from where it awaited. The second trail is entered after the first trails suspends the first time, then suspends for ten seconds. After that the second trail finishes. Because we split into concurrent trails using "par/or", all trails are aborted whenever any trail finishes. This means that we should have printed "Hello World!" five times. However, if the two trails had been written in reverse order: par/or do
await async do
emit 10s;
end
with
loop do
await 1s;
_printf("Hello ");
await 1s;
_printf("World!\n");
end
end
... the first trail would have finished first, aborting the last "await 1s" of the second trail, resulting in one half finished "Hello " without a newline.The neat thing is that all of the synchronous concurrency can be compiled down to a very minimal finite state machine with microscopic memory overhead, making it usable in embedded contexts. (Tangent: the above is the old syntax btw, it is currently being rewritten into a version called Dynamic Céu with more features, and a different syntax whose terminology is also more in line with what the mainstream consensus is for various concurrency terms[5][6]). [0] https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers... [1] https://developer.mozilla.org/en-US/docs/Web/API/MessageChan... [2] https://en.wikipedia.org/wiki/Dataflow_programming [3] https://github.com/ceu-lang/ceu [4] https://ceu-lang.github.io/ceu/out/manual/v0.30/ [5] https://github.com/fsantanna/dceu?tab=readme-ov-file#the-pro... [6] https://github.com/fsantanna/dceu/blob/main/doc/manual-out.m... |
|
| ▲ | unscaled 6 days ago | parent | prev [-] |
| I think most of the arguments in this essay rely on this single premise: "The second thing I want you to take away is that imperative languages are not inferior to functional ones." There is an implied assumption that async/await is a "functional feature" that was pushed into a bunch of innocent imperative languages and polluted them. But there is one giant problem with this assumption: async/await is not a functional feature. If anything, it's the epitome of an imperative flow-control feature. There are many kinds of functional languages out there, but I think the best common denominator for a primarily functional language nowadays is exactly this: in functional languages control flow structures are first class citizens, and they can be customized by the programmer. In fact, most control flow structures are basically just functions, and the one that aren't (e.g. pattern matching in ML-like languages and monadic comprehensions in Haskell-inspired languages) are extremely generic, and their behavior depends on the types you feed into them. There are other emphasis points that you see in particular families of languages such as pattern matching, strict data immutability or lazy computation — but none of these is a core functional concept. The interesting point I want to point out is that no primarily functional language that I know actually has async/await. Some of them have monads and these monads could be used for something like async/await but that's not a very common use, and monad comprehensions can be used for other things. For instance, you could use do expressions in Haskell (or for expressions in Scala) to operate on multiple lists at once. The same behavior is possible with nested for-loops in virtually every modern imperative language, but nobody has blamed Algol for "polluting" the purity of our Fortran gotos and arithmetic ifs with this "fancy functional garbage monad from damned ivory tower Academia". That would be absurd, not only because no programming language with monadic comprehensions existed back then, but also because for loops are a very syntax for a very specific thing that can be done with monadic expression. They turn a very abstract functional concept into a highly specific — and highly imperative — feature. The same is true for await. It's an imperative construct that instructs the runtime to suspend (or the compiler to turn the current function into a state machine). So no, async/await does not have anything to do with functional-language envy and is, in fact, a feature that is quite antithetical to functional programming. If there is any theoretical paradigm behind async/await (vs. just using green threads), it's strong typing and especially the idea of representing effects by types. This is somewhat close to fully-fledged Effect Systems (in languages such as a Koka), but not as powerful. The general idea is that certain functions behave in a way that is "infective" — in other words, if foo() calls bar() which in-turn calls doStuff(), it might be impacted by some side-effect of doStuff(). In order to prevent unpleasant surprises, we want to mark this thing that doStuff does in the function signature (either using an extra argument, a return type wrapper or just an extra modifier like "async"). In a pure language like Haskell, everything from I/O to mutable memory requires specifying an effect and this is usually done through monadic return types. But even the very first version of Java (Ron Pressler's ideal untarnished "imperative" language) has effects (or "colors") which still remain in the language: checked exceptions. They are just as infective as async I/O. If you don't handle exceptions in place, a function marked with "throws IOException" (basically almost any function that deals with I/O) can only be called by another function marked with "throws IOException". What's worse, unlike JavaScript which only has two colors (async and non-async), Java has an infinite number colors! The description above sounds horrible, but it's not. Checked exceptions are widely believed to be a mistake[1], but they don't bother Java developers enough to make the language unusable. You can always just wrap them with another exception and rethrow. The ergonomics could have been made slightly better, but they're decent enough. But the same can be said for async/await. If you take a language with a similar feature that is close to Java (C# or Kotlin), you'll see the asynchronous functions can still run as blocking code from inside synchronous functions, while synchronous functions can be scheduled on another thread from a synchronous function. The ergonomics for doing that are not any harder than wrapping exceptions. In addition to that, the advantages of marking a function that runs asynchronous I/O (just like marking a function that throws an exception) are obvious, even if the move itself is controversial. These functions generally involve potentially slow network I/O and you don't want to call them by mistake. If you think that never happens, here is the standard Java API for constructing an InetAddress object from a string representing an IPv4 or IPv6 address: InetAddress.getByName()[2]. Unfortunately, if your IP address is invalid, this function may block while trying to resolve it as a domain name. That's plain bad API design, but APIs that can block in surprising ways are abundant, so you cannot argue that async/await doesn't introduce additional safety. But let's face it — in most cases choosing async/await vs. green threads for an imperative language is a matter of getting the right trade-off. Async/Await schedulers are easier to implement (they don't need to deal with segmented/relocatable/growable stacks) and do not require runtime support. Async/await also exhibits more efficient memory usage, and arguably better performance in scenarios that do not involve a long call-graph of async functions. Async/await schedulers also integrates more nicely with blocking native code that is used as a library (i.e. C/C++, Objective C or Rust code). With green threads, you just cannot run this code directly from the virtual thread and if the code is blocking, your life becomes even harder (especially if you don't have access to kernel threads). Even with full control of the runtime, you'd usually end up with a certain amount of overhead for native calls[3]. Considering these trade-offs, async/await is perfect in scenarios like below: 1. JavaScript had multiple implementations. Not only were most of them single-threaded, they would also need a major overhaul to support virtual threads even if a thread API was specified. 2. Rust actually tried green threads and abandoned them. The performance was abysmal for a language that seeks zero-cost abstraction and the system programming requirements for Rust made them a deal breaker even if this wasn't the case. Rust just had to support pluggable runtimes and mandating dynamic stacks just won't work inside the Kernel or in soft real-time systems. 3. Swift had to interoperate with a large amount of Objective C called that was already using callbacks for asynchronous I/O (this is what they had). In addition, it is not garbage-collected language, and it still needed to call a lot of C and Objective C APIs, even if that was wrapped by nice Swift classes. 4. C# already had a Promise-like Task mechanism that evolved around wrapping native windows asynchronous I/O. If .Net was redesigned from scratch nowadays, they could have very well went with green threads, but the way .Net developed, this would have just introduced a lot of compatibility issues for almost no gains. 5. Python had the GIL, as the article already mentioned. But even with patching runtime I/O functions (like greenlet — or more accurately, gevent[4] — did), there were many third party libraries relying on native code. Python just went with the more compatible approach. 6. Java did not have any established standard for asynchronous I/O. CompletableFuture was introuced in Java 8, but it wasn't as widely adopted (especially in the standard library) as the C# Task was. Java also had gauranteed full control of the runtime (unlike JavaScript and Rust), it was garbage collected (unlike Rust and Swift) and it had less reliance on native code than Swift, Pre-.NET Core C# or Python. On the other hand, Java had a lot of crucial blocking APIs that haven't been updated to use CompletableFuture, like JDBC and Servlet (Async Servlets were cumbersome and never caught on). Introducing async/await to Java would mean having to rewrite or significantly refactor all existing frameworks in order to support them. That was not a very palatable choice, so again, Java did the correct thing and went with virtual threads. If you look at all of these use cases, you'd see all of these languages seem to have made the right pragmatic choice. Unless you are designing a new language from scratch (and that language is garbage collected and doesn't need to be compatible with another language or deal with a lot of existing native code), you can go with the ideological argument of "I want my function to be colorless" (or, inversely, you can go with the ideological argument of "I want all suspending functions to be marked explicitly"). In all other cases, pragmatism should win. --- [1] Although it mostly comes to bad composability — checked result types work very well in Rust. [2] https://docs.oracle.com/en/java/javase/17/docs/api/java.base... [3] See the article blelow for the overhead in Go. Keep in mind that the Go team has put a lot of effort into optimizing Cgo calls and reducing this overhead, but they still cannot eliminate it entirely. https://shane.ai/posts/cgo-performance-in-go1.21/ [4] https://www.gevent.org/ |
| |
| ▲ | brabel 2 days ago | parent | next [-] | | > What's worse, unlike JavaScript which only has two colors (async and non-async), Java has an infinite number colors! Your comment is great, but I need to point out that the above sentence is misrepresenting Java. You can call any function from a Java function. The fact that you may need to handle an Exception when calling some doesn't make it a "colored" function because you can easily handle the Exception and forget about the color, and if you remember the color problem, it was problematic that colors are infectious, i.e. you just can't get rid of the color, which is not the case in Java. Some claim that's actually bad because it prevents things like structured concurrency (because Java can start a Thread anywhere and there's no way for you to know that a function won't... if there was a "color", or better said, effect, for starting a Thread, you could guarantee that no Thread would be started by a function lacking that effect). | |
| ▲ | solidninja 4 days ago | parent | prev | next [-] | | Thank you for writing this - it is more detailed that I could come up with! I would like to add that I feel like functional approaches are more the "future" of programming than trying to iterate over imperative ones to make them as "nice" to use. So I don't really see the big deal of trying to add-on features to existing languages when you can adopt new ones (or experiment with existing ones e.g. https://github.com/getkyo/kyo for a new take on effects in Scala). | |
| ▲ | the_mitsuhiko 6 days ago | parent | prev | next [-] | | > There is an implied assumption that async/await is a "functional feature" that was pushed into a bunch of innocent imperative languages and polluted them. But there is one giant problem with this assumption: async/await is not a functional feature. If anything, it's the epitome of an imperative flow-control feature. async/await comes from C# and C# got this as an "appoximation" of what was possible with F#. You can go back to 2011 where there are a series of videos on Channel 9 by Anders Hejlsberg where he goes into that. That said, I don't think my post relies on the premise that this is a fight about imperative to functional programming. If anything the core premise is that there is value in being able to yield anywhere, and not just at await points. > If you look at all of these use cases, you'd see all of these languages seem to have made the right pragmatic choice. Potentially, who am I to judge. However that choice was made at a certain point in time and the consequences are here to stay. Other than in JavaScript where it's self evident that this is a great improvement over promise chaining (sans the challenge of unresolved promises), I'm not sure the benefits are all that evident in all languages. I do a fair amount of async programming in JavaScript, Python and Rust and the interplay between threads and async code is very complex and hard to understand, and a lot of the challenges on a day to day would really feel like they are better solved in the scheduler and virtual threads. > Unless you are designing a new language from scratch (and that language is garbage collected and doesn't need to be compatible with another language or deal with a lot of existing native code), you can go with the ideological argument of "I want my function to be colorless" (or, inversely, you can go with the ideological argument of "I want all suspending functions to be marked explicitly"). In all other cases, pragmatism should win. I will make the counter argument: even in some languages with async/await like Python, you could very pragmatically implement virtual threads. At the end of the day in Python for instance, async/await is already implemented on top of coroutines anyways. The "only" thing that this would require, is to come to terms with the idea that the event loop/reactor would have to move closer to the core of the language. I think on a long enough time horizon Python would actually start moving towards that, particularly now that the GIL is going and that the language is quite suffering from the complexities of having two entirely incompatible ecosystems in one place (two sets of future systems, two sets of synchronization directives, two independent ways to spawn real threads etc.). | | |
| ▲ | ikekkdcjkfke 2 days ago | parent [-] | | I always instantly await all my Tasks/promises. The only problem I have is that I have to type it out and wrap everything in Tasks. If the compiler would just automatically asyncify all the things that would be great, no need for green-threading or messing around with the engine, just hide the ugly details like you do with so many other things, dear compiler. |
| |
| ▲ | dfawcus 2 days ago | parent | prev [-] | | On point [3], using the Shane's code, and with an additional one for gccgo, on my laptop I see: $ go-11 test -cpu=1,2,4,8,16 -bench Cgo
goos: linux
goarch: amd64
pkg: github.com/shanemhansen/cgobench
cpu: 13th Gen Intel(R) Core(TM) i5-1340P
BenchmarkCgoCall 6123471 195.8 ns/op
BenchmarkCgoCall-2 11794101 97.74 ns/op
BenchmarkCgoCall-4 22250806 51.30 ns/op
BenchmarkCgoCall-8 33147904 34.16 ns/op
BenchmarkCgoCall-16 53388628 22.41 ns/op
PASS
ok github.com/shanemhansen/cgobench 6.364s
$ go-11 test -cpu=1,2,4,8,16 -bench Gcc
goos: linux
goarch: amd64
pkg: github.com/shanemhansen/cgobench
cpu: 13th Gen Intel(R) Core(TM) i5-1340P
BenchmarkGccCall 414216266 3.037 ns/op
BenchmarkGccCall-2 788898944 1.523 ns/op
BenchmarkGccCall-4 1000000000 0.7670 ns/op
BenchmarkGccCall-8 1000000000 0.4909 ns/op
BenchmarkGccCall-16 1000000000 0.3488 ns/op
PASS
ok github.com/shanemhansen/cgobench 4.806s
$ go-11 test -cpu=1,2,4,8,16 -bench EmptyCall
goos: linux
goarch: amd64
pkg: github.com/shanemhansen/cgobench
cpu: 13th Gen Intel(R) Core(TM) i5-1340P
BenchmarkEmptyCallInlineable 1000000000 0.5483 ns/op
BenchmarkEmptyCallInlineable-2 1000000000 0.2752 ns/op
BenchmarkEmptyCallInlineable-4 1000000000 0.1463 ns/op
BenchmarkEmptyCallInlineable-8 1000000000 0.1295 ns/op
BenchmarkEmptyCallInlineable-16 1000000000 0.1225 ns/op
BenchmarkEmptyCall 499314484 2.401 ns/op
BenchmarkEmptyCall-2 977968472 1.202 ns/op
BenchmarkEmptyCall-4 1000000000 0.6316 ns/op
BenchmarkEmptyCall-8 1000000000 0.4111 ns/op
BenchmarkEmptyCall-16 1000000000 0.2765 ns/op
PASS
ok github.com/shanemhansen/cgobench 5.707s
Hence the GccGo version of calling the C function is in the same ballpark as for a native Go function call. This is as to be expected when using that mechanism.So using various C libraries does not necessarily have to involve the overhead from Cgo. diff --git a/bench.go b/bench.go
index 8852c75..7bfd870 100644
--- a/bench.go
+++ b/bench.go
@@ -15,3 +15,10 @@ func Call() {
func CgoCall() {
C.trivial_add(1,2)
}
+
+//go:linkname c_trivial_add trivial_add
+func c_trivial_add(a int, b int) int
+
+func GccCall() {
+ c_trivial_add(1,2)
+}
diff --git a/bench_test.go b/bench_test.go
index 9523668..c390c63 100644
--- a/bench_test.go
+++ b/bench_test.go
@@ -43,3 +43,6 @@ func BenchmarkEmptyCall(b *testing.B) {
func BenchmarkCgoCall(b *testing.B) {
pbench(b, CgoCall)
}
+func BenchmarkGccCall(b *testing.B) {
+ pbench(b, GccCall)
+}
|
|