4 ms·
async/await require yet another world that's parallel to the "thread" world but requires its own "colour" and set of APIs. So now you have two kinds of threads,
by pron 4y ago
async/await require yet another world that's parallel to the "thread" world but requires its own "colour" and set of APIs. So now you have two kinds of threads, to kinds of respective APIs, and two kinds of the same concept that has to be known by all of your tools (debuggers, profilers, stacktraces).
> This is a bad objection because you also have to do this for virtual threads
No. We had to change a bit of the implementation -- at the very bottom -- but none of the APIs, as there is no viral async colour that requires doubling all the APIs.
You're right that implementing user-mode threads is much more work than async/await, which could be done in the frontend compiler if you don't care about tool support (although we very much do), but the result dominates async/await in languages that already have threads (there are different considerations in JS) as you keep all your APIs and don't need a duplicate set, and a lot of existing code tools just work (with relatively easy changes to accommodate for a very high number of threads).
> The "Structured Concurrency" examples are unergonomical versions of async/await.
They're very similar, actually.
We've made the Java example very explicit, but that code would normally be written as:
Response handle() throws ExecutionException, InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> findUser());
var order = scope.fork(() -> fetchOrder());
scope.join().throwIfFailed();
return new Response(user.resultNow(), order.resultNow());
}
}
But when the operations are homogenous, i.e. all of the same type rather than different types as in the example above (Java is typed), you'll do something like:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var fs = myTasks.stream().map(scope::fork).toList();
scope.join().throwIfFailed();
return fs.map(Future:resultNow).toList();
}
Of course, you can wrap this in a higher level `gather` operation, but we wanted to supply the basic building blocks in the JDK. You're comparing a high-level library to built-in JDK primitives.
Work is underway to simplify the simple cases further so that you can just use the stream API without an explicit scope.
- mgraczyk 4y agoThis makes sense, especially the bit about tooling. I'm unfamiliar with the state of Java tooling besides very simple tasks. On the other hand using things like debuggers and reading stack traces in python/js "just work" for me. Maybe because the tooling and the language have evolved together over a longer period of time. I also feel like the reimplementation of all functions to support async is not a big deal because the actual pattern is generally very simple. You can start by awaiting every async function at the call site. New libraries can be async only.
- pron 4y ago> On the other hand using things like debuggers and reading stack traces in python/js "just work" for me. Maybe because the tooling and the language have evolved together over a longer period of time. Well, Python and JS don't have threads, so async/await are their only concurrency construct, and it's supported by tools. But Java has had tooling that works with threads for a very long time. Adding async/await would have required teaching all of them about this new construct, not to mention the need for duplicate APIs. > I also feel like the reimplementation of all functions to support async is not a big deal because the actual pattern is generally very simple. You can start by awaiting every async function at the call site. First, you'd still need to duplicate existing APIs. Second, the async/await (cooperative) model is inherently inferior to the thread model (non-cooperative) because scheduling points must be statically known. This means that adding a blocking (i.e. async) operation to an existing subroutine requires changing all of its callers, who might be implicitly assuming there can't be a scheduling point. The non-cooperative model is much more composable, because any subroutine can enforce its own assumptions on scheduling: If it requires mutual exclusion, it can use some kind of mutex without affecting any of the subroutines it calls or any that call it. Of course, locks have their own composability issues, but they're not as bad as async/await (which correspond to a single global lock everywhere except around blocking, i.e. async, calls) So when is async/await more useful than threads? When you add it to an existing language that didn't have threads before, and so already had an implicit assumption of no scheduling points anywhere. That is the case of JavaScript. > New libraries can be async only. But why if you already have threads? New libraries get to enjoy high-scale concurrency and old libraries too!
- mgraczyk 4y agoI agree with your point that for CPU bound tasks, the threading model is going to result in better performing code with less work. As for the point about locks, I think this one is also a question of IO-bound vs CPU bound work. For work that is CPU bottlenecked, there is a performance advantage to using threads vs async/await. As for the tooling stuff, I'm still not really convinced. Python has almost always had threads and I've worked on multimillion line codebases that were in the process of migrating from thread based concurrency to async/await. Now JS also has threads (workers). I also use coroutines in C++ where threads have existed for a long time. I've never had a problem debugging async/await code in these languages, even with multiple threads. I guess I just have had good experiences with tooling but It doesn't seem that hard to retrofit a threaded language like C++/Python.