But the structured concurrency stuff they added is sorta kinda familiar to explicit yields (as long as you set up everything properly). IMO its uglier than async/await but I also deal with main threads a lot.
I'm excited to see if this breeds any interesting UI frameworks or if its mostly just about server bandwidth.
My fear is that we'll end up living with a bolted on async/await style for this stuff but with worse syntax. Still, I'm excited to see how it shakes out.
[0] https://journal.stuffwithstuff.com/2015/02/01/what-color-is-...
If you're starting a new Java codebase from scratch, you can create the structure where the language doesn't using classes, but it'd be nice to have syntactic support for it.
Also, as mentioned by another commenter below - is a file exists operation async? Sync? Why, why not?
(Why? Because it uses internal compiler APIs to change the behaviour of code you write in ways that are not valid: annotation processors are supposed to generate new classes, not mutate existing classes.)
If you want to use something like this today, then you could take a look at Spring 's Async support. It also lets you supply the Executor.
One problem with async/await is that the term "blocking" is under-specified. If you try and nail down what is and isn't blocking, like with a formal way to classify operations as such, you rapidly end up with a lot of contradictions and gray areas. It's a distinction that doesn't really make sense except as a hack to work around expensive threads/callstacks, which is what Loom solves.
For example, is reading a file blocking? On modern computers this could do anything spanning from being purely a set of branches and pointer dereferences as a kernel block cache hit, thus being little different to any other code, or it could be an NVMe flash request (extremely fast, probably not worth switching the thread), or it could be an HDD seek (fairly slow, worth freeing up the thread to do something else), or it could be a network RPC (extremeley slow, definitely worth freeing up the thread). You may not even know ahead of time what it is. So, should the API be blocking? Or marked async?
The right place to figure this out isn't in hand-written code. That'd be a performance hack similar to the old C register keyword. The runtime has much more information on which to make the right call, but for that, the code has to be written in a uniform style.
More generally, type systems ideally aren't encoding performance-specific stuff into them. Type systems help with program organization and correctness, and as a happy side effect can also guide compilers to make better code, but the performance of an operation shouldn't itself be exposed in its prototype. Otherwise optimizations can break code, which wouldn't make sense, being able to optimize modules independently is a big part of why you want dynamic linking.
Within virt threads, does it really matter? Every function takes as much time as it takes - the reason you use virtual threads is to make that efficient, the semantics is clear - your code is blocking, and waits for every previous statement.
Also, it is not quite valid to model it as types - these are more like effects.
If you design your software right, you can use async/await similarly to IO in Haskell—functions that are not async are fast and unlikely to fail, while functions that are async are potentially slow and likely to throw exceptions. This partition can help you reason about and structure your code as you're writing it for solid error handling and minimal duplicate I/O.
If you want a monadic IO on the JVM, Scala offers two ecosystems with extremely capable effect systems, providing highly performant runtimes and good (but not perfect) ergonomics.
I've always found async/await bolted on older, mostly imperative OO languages with exceptions to be pretty disappointing. Even modern designs on languages with proper error handling like Rust get their fair share of criticism.
I get that virtual threads will probably introduce surprising bugs and performance issues in some corner cases. But it also removes a whole lot of craziness in the Java ecosystem, namely reactive programming.
We could use a different name for that concept to make it more clear why these ideas are related, like `IO`, but in practice async=IO in most code.
> in practice async=IO in most code
does not need to be true. We will never avoid the complexity of IO, but we can avoid the complexity of async, and virtual threads provide us that opportunity.
> We will never avoid the complexity of IO, but we can avoid the complexity of async
Because we can never avoid the complexity of IO, having a language construct that makes it obvious when you're using IO is valuable. IO code is different in kind than non-IO code, and having it explicitly marked helps keep the mess that is IO from propagating its problems throughout the rest of your code.
Language designers don't add async to make IO look special, they add it as a concurrent programming model.
That would seem like using the wrong tool for the job. If you want to colour functions according to reliability and speed you should use a different mechanism to colour them than a mechanism designed to colour functions based on how they behave if IO blocks.
This pattern is used a lot when you have things like UI which use a single threaded design, or if you need to bind to do some IPC in a single native thread. It's actually very common.
Most of the time, it is not something one needs to worry about because actually problematic cases are highlighted by default Roslyn analyzers and will give you a warning on build as well.
Async/await is a superior model that is more flexible and solves more tasks (heh) at hand than green threads which have more limited scope of application, higher base overhead and cannot reach parity at efficiency due to stack state having to be always preserved.
Ask yourself why do you want to know what is asynchronous. In a typical server-side app synchronous, blocking, thread-per-task code is easy to write and read. There is only one drawback: you can run out of kernel threads. Asynchronous code solves this problem, but virtual threads solve it in a better way, because you can have three orders of magnitude more virtual threads compared to kernel threads. Ultimately, it's all about the number of threads you are allowed to use.
Both can have similar performance characteristics, the difference is primarily one of ergonomics, and since that's subjective there are people who will argue fiercely for either side.
It’s a different question that async-await may have a few very specialist use-cases, that is not readily available in the preemptive model.
This is precisely why await was introduced in the first place. Promise and callback code was impossible to reason about.
String res = someRestCall();
is.
var s = await someCall();
is way easier to reason about than: someCall().then(…)Straight from JEP 444:
> The scheduler does not currently implement time sharing for virtual threads. Time sharing is the forceful preemption of a thread that has consumed an allotted quantity of CPU time. While time sharing can be effective at reducing the latency of some tasks when there are a relatively small number of platform threads and CPU utilization is at 100%, it is not clear that time sharing would be as effective with a million virtual threads.
https://elizarov.medium.com/how-do-you-color-your-functions-...
Not all IO operations can be done asynchronously on all platforms, and some new interfaces had to be introduced to allow for asynchronous name lookup, and some code needs refactoring because it had implicit assumptions that it would be used by tasks in a small thread pool and so used thread local values as a cache for expensive objects, but in general I think the design has achieved the goal of allowing existing code to be moved to virtual threads without the requirement to rewrite everything or scatter await / async all over the place.
So if you start from scratch, the preemptive style is preferable, but for us the decision was even easier, because we weren't starting from scratch. Java already had threads, and so mutual exclusion was already explicit rather than implicit.
When is cooperative scheduling more appropriate? In a language like JavaScript that already had a lot of existing code with implicit mutual exclusion assumptions (because JS didn't have threads). To add concurrency to such a language with a large existing ecosystem, cooperative scheduling is a better choice.
I don't believe that you proved that in the preceding paragraph. All you said is that cooperative scheduling requires making the scheduling points explicit while preemptive doesn't, but "explicit is better than implicit" is a principle that a substantial portion of the programming community believes, and OP specifically expressed a preference for explicit scheduling points.
For myself, I also want to see scheduling points propagate up the call tree the same way that I want checked exceptions. The lack of composability is a feature, not a bug. I prefer to know which parts of my code are most likely to be problematic and then keep those parts isolated from the rest.
I recognize that this is a subjective opinion and not everyone believes what I do, and for some use cases preemptive is better, but I don't see why you need to pretend that you made the most optimal decision instead of just acknowledging that it's complicated and you made the one that you feel is best for Java.
The problem is that the cooperative style makes mutual exclusion implicit and that's the important correctness assumption that is explicit in the preemptive style. The possible presence of scheduling point is the lack of an assumption, and therefore it shouldn't be explicit.
You're right that the issue is subjective, but it's subjectively determined in the overall design of the language. In Haskell, for example, there is an ever-present default assumption of lack of side effects and that assumption is at the core of the language, but if your language doesn't do that -- and Java doesn't and neither do C# or Python -- then the choice of cooperative scheduling isn't a good fit for the rest of the language. So perhaps I should have said that unless you design your language like Haskell (and unless you don't have the constraints the JS does), preemptive is the better fit for the rest of the language.
Are you saying that there's a mechanism in Loom that somehow forces you to mark mutual exclusion which is absent from cooperatively-scheduled languages?
[0] https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-corout...
Right, but assumptions of mutual exclusion are still implicit, and so you must carefully analyse them any time you add a scheduling point. I.e. code may implicitly rely on mutual exclusion in cooperative scheduling but not in preemptive scheduling.
> Are you saying that there's a mechanism in Loom that somehow forces you to mark mutual exclusion which is absent from cooperatively-scheduled languages?
It's not a mechanism in virtual threads but in Java threads in general. Yes, to mark mutual exclusion you must employ some synchronisation construct explicitly, and that is not the case for cooperative scheduling where mutual exclusion is implicit, and that's what makes cooperative scheduling less composable.
Can we make this concrete? In what way is mutual exclusion implicit in Kotlin coroutines that is not also true in Java Loom?
Here's Kotlin's explanation of how to do mutual exclusion [0], and here's a blog post explaining how mutual exclusion works in Java [1]. The main difference I see is that Java's `synchronized` is a full syntax construct while Kotlin uses its trailing-lambda syntax, but that's not semantically significant.
What do you see as more explicit in the Java version?
[0] https://kotlinlang.org/docs/shared-mutable-state-and-concurr...
An async JS function can contain code like this:
x = 3;
foo();
print(x);
Now suppose that you want to perform some I/O operation in foo, which would require changing the function to: x = 3;
await foo();
print(x);
But is that allowed or does our function depend on x (which is shared among tasks in some way) not being changed between the first and third lines? Does the original function rely on the implicit mutual exclusion for its correct operation or not?In Java, on the other hand, if x is shared among some tasks then, regardless of what foo does, if the method depends on x not changing then the code must be written as:
try (xGuard.lock()) {
x = 3;
foo();
print(x);
} finally {
xGuard.unlock();
}
So adding a scheduling point to foo doesn't matter because if our method has any mutual exclusion assumptions it must express them explicitly (in practice, if x is guarded, then the guarding will often be encapsulated somehow, but the point remains). In JS that's not the case, so the addition of a scheduling point requires a careful analysis of all callers. x = 3;
foo();
print(x);
If not, then I fail to see what the difference is with your JS example. It's still on the developer to keep track of whether x may change (and therefore whether a mutex is required), with the added disadvantage that there's no `async` keyword to clue you in that there might be a problem.You seem to be describing a style guide rule that Java projects should adopt, not a language feature, and there's no particular reason the same style guide couldn't be adopted another language if required.
Why would it prevent it from compiling? Whether it's right or wrong depends on what the programmer has in mind. If they rely on a mutual exclusion constraint, they must specify it.
> It's still on the developer to keep track of whether x may change (and therefore whether a mutex is required), with the added disadvantage that there's no `async` keyword to clue you in that there might be a problem.
No, because in Java, the author of this method has to decide what they want. In JS, when the author of foo decides to change it they need to analyse what the authors of the callers had in mind.
The point is that the presence of a scheduling point or not is not just informative but has an impact on the correctness of the code.
> You seem to be describing a style guide rule that Java projects should adopt, not a language feature
No, it's a language feature. In Java the default is "scheduling point allowed." If you want to exclude it, you have to be explicit. This approach makes composing code easier than the opposite default unless your entire language is built around an even stronger constraint (like Haskell).
No, because they already committed to not having any concurrent code in that method as part of the function contract. Making a function async is a breaking change (the return type changes to Promise<T>). If the author can't avoid the breaking change, then it would indeed force the caller to update their call site, cluing them in that the code may now run in parallel and mutual exclusion should be considered. There's no guesswork at what the callers are doing because you had an explicit contract with them that there would be no concurrency.
If a Java method is passed an object by reference and subsequently decides to modify it in a thread instead of inline, that information is not propagated up to the callers, so they would have either had to guess that the library might change to a concurrent model and make the object thread-safe ahead of time, or they would have to read the release notes and then go find the call sites that need fixing.
Sure, and that is the essence of non-composability. It's what you want in Haskell, but very much not what you want in an imperative language. And the reason it's easy to see it's not what you want is that those languages, even JS, don't generally colour blocking functions. In imperative languages, blocking is an implementation detail. Deviating from that principle goes against the grain of those languages (though not of Haskell's).
> If a Java method is passed an object by reference and subsequently decides to modify it in a thread instead of inline, that information is not propagated up to the callers, so they would have either had to guess that the library might change to a concurrent model and make the object thread-safe ahead of time, or they would have to read the release notes and then go find the call sites that need fixing.
In Java (and in any imperative language that offers threading, including Rust and Kotlin and C#) any interaction with data that may be shared across threads absolutely requires explicit attention to memory visibility. In particular, in your example you don't need to communicate anything to the caller, just ensure that the method that passes the object to be processed by a thread ensures visibility. This is what the Java Memory Model is about, and thread pools, futures etc. do it automatically.
It's true that JS doesn't require attention to memory visibility, but that has absolutely nothing with cooperative or preemptive scheduling. Rather it has to do with multiprocessing. A language that offers preemptive threads over a single processor will not need any more attention to memory visibility than JS. Conversely, cooperative scheduling in Kotlin, whose scheduler supports multi-processing also requires the same kind of attention.
> I'm just arguing that preemptive is not objectively better, it depends on what existing code is already doing and on what you're trying to accomplish with the feature.
Well, I'm arguing that preemptive is objectively better in imperative languages unless there are external concerns, and that's why Erlang and Go have gone down that route. I know of one exception to that, although the paradigm isn't quite classical imperative, and that is synchronous languages like Esterel [1], but note that the model there isn't quite like async/await and isn't quite cooperative, either. I am not aware of (ordinary) imperative languages that chose cooperative scheduling primarily because they truly thought the programming model is better; it's lack of composability with imperative paradigms is fairly obvious (and I know for a fact that C#, Kotlin, C++, Rust, and JS didn't choose that style because of that, but rather because of other constraints).
Removing double negations, you're saying "cooperative style is bad because the runtime can't interrupt user code implicitly"? You are looking at this from the perspective of the runtime implementer (meaning yourself, thank you by the way), not the programmer. You absolutely should look at this from the perspective of the programmer too.
Preemptive multitasking gives the runtime more capabilities because you can just assume the user has to deal with anything that your implicit rescheduling causes. But users don't actually deal with that, even if they should! The havoc that concurrency bugs are causing is evidence for that!
JavaScript's concurrency model is so nice because despite lacking real parallelism, you only have to think about concurrency (race conditions, data races, deadlocks, ...) at scheduling points. Rust has an interesting model with both parallelism and cooperative async-await. Its type system requires strong guarantees to move data between (preemptively scheduled) threads, but much weaker guarantees to use it in a cooperatively scheduled async function. Of course, it comes at a cost, namely function coloring.
After decades of legacy, none of that is feasible in Java of course, but let's not pretend that preemptive scheduling is objectively "preferable".
Because it's the opposite. In the preemptive style, the mutual exclusion assumption -- i.e. the relevant correctness assumption -- is explicit, while in the cooperative style the correctness assumption is implicit and does not compose well. I.e. any addition of a scheduling point requires re-examining the implicit assumptions in all transitive callers.
> Preemptive multitasking gives the runtime more capabilities and makes it easier to implement because you can just assume the user has to deal with anything that your implicit rescheduling causes.
Preemptive multitasking is far, far harder to implement (that's why virtual threads took so long) and it provides superior composability. Cooperative scheduling is very easy to implement (often all it requires is just a change to the frontend compiler, which is why languages that have no support of the backend have to choose it) but it places the burden of examining implicit assumptions on the user.
> After decades of legacy, none of that is feasible in Java of course, but let's not pretend that preemptive scheduling is objectively "preferable".
Java is no different in this case from C# or Rust. They all support threading. But after significant research we've decided to spend years on offering the better approach. It looks like C# has now realised that, while hard, it may be possible for them, too, so they're giving it a go.
If we look at true clean-slate choices, the two languages that are most identified with concurrency as their core -- Erlang and Go -- have also chosen preemptive scheduling. Languages that didn't have either done so due to legacy constraints (JS), lack of control over the backend (Kotlin), complex/expensive allocation in low-level languages (C++ and Rust), or possibly because it's simply much easier (C# I guess).
The "easier to implement" part was a remnant of an older rewording, and I edited it out immediately after posting. You must've seen the comment right after I posted it, sorry about that, that wasn't supposed to be my point.
Either way, we could argue here for days, but the difference in opinions here on this thread AND among programming language maintainers are enough evidence to show that the decision isn't as clear as you make it out to be. It's a little dismissive to pretend that other language designers, eg. those of Python, Kotlin, haven't done their years of research, and made inferior choices due to lack of expertise, when a LOT of developers (eg. myself) like those languages particularly because of those decisions.
Opinions differ. And if you do research on Java developers you'll get different results than if you do research on JS developers. For good reasons. I fully trust you that you made the right decision for Java.
You do, because the problem is not the "await" calls but everything else. The language tells you which are the callers that need to be changed when you a scheduling point, but it doesn't tell you whether that change preserves their correctness or not. You have to analyse every caller individually to know whether there was a reliance on mutual exclusion or not.
> It's a little dismissive to pretend that other language designers, eg. those of Python, Kotlin, haven't done their years of research, and made inferior choices due to lack of expertise,
But I said the opposite: that they chose the best option that was available to them. Kotlin couldn't implement user-mode threads, or at least not efficiently, because they have no control over the platforms they run on. JS also had little choice because adding threads would have broken a lot of existing code. In their place I would have made the same choice. But the point is that in imperative languages the choice of cooperative scheduling is not done because the programming model it offers is attractive but because of other constraints. All else being equal, cooperative scheduling offers worse composition in imperative languages; it's just that in many cases all else is not equal and there are other constraints.
But yes, the important thing is that even though cooperative scheduling would have been so much easier to provide in Java, we decided to take our time and do what offers the best experience to Java developers.
Different features have different levels of depth in languages - as mentioned, where it is only a frontend compiler level change, it definitely didn’t require as much background knowledge as Loom.
Hell, Ron has been working on this exact problem for close to a decade, if I’m not mistaken! Kotlin is barely a decade old!
In languages with async-await syntax, I like that it is explicit what is sync and what is async. I also like how the "bind" points within an async expression make it clear where the syncronization happens. It seems to me that Java 21 makes all of this implicit, which is a step backward in this respect.
Similar to how types enforce valid usage (a foundational idea in Java), I feel that an Async<T> should not be implicitly a T.
Are people concerned about this at all?
Have the Java designers addressed this?