C# does not have virtual threads. What C# has is async/await. Which was nice for its time, but at the same time it's an error prone design, as computations should never start async by accident, blocking should be the default. C# also has no useful interruption model. Java's interruption model is error prone, but at least you can work with it.
Async/await also splits the standard library and the ecosystem in 2 (blocking vs non-blocking, or blue vs red), and it can't automatically update the behavior of old code.
The introduction of virtual threads in Java also works well with "structured concurrency", as seen with Kotlin's Coroutines. Kotlin's approach to concurrency is also superior to that of C#, actually. But what's interesting about Java is that its evolution is often one that involves runtime changes, lifting all boats. Java engineers preferred pushing more changes in the runtime, and somewhat ironically, the JVM ended up being the true multi-language runtime.
Java is a good case study of how languages should evolve. It has extreme backwards compatibility, and features being pushed are assessed for how they impact the whole ecosystem, including libraries or languages not in Oracle's control. Project Loom was developed in the open, compared to what Microsoft usually does.
The async/await syntax works with the language's other statements, but for a long time it had gotchas. It doesn't qualify as "structured concurrency", and it has the aforementioned issues — it's (accidentally) error-prone, it splits the ecosystem in two, and has no interruption model.
I am not familiar with Swift, but I think you can hardly beat Kotlin's implementation. This is a good introduction from Kotlin's lead:
What do you mean by "it splits the ecosystem in two"? I never observed such split, certain methods intentionally offer sync and async variants.
Interruption is achieved through cancellation tokens and has to be handled by consuming methods. There is no way around it because interrupting execution at an arbitrary point would lead to all kinds of issues regardless of the language (unless it implements some form of transaction abstraction and rollbacks all uncommitted changes).
Those "cancellation tokens" from C# are a band-aid.
In Java, you don't need to initialize those "tokens" manually, because the interruption signal is baked into Threads. Moreover, a lot of the standard library cooperates with Java's interruption, which is why you see plenty of methods throwing `InterruptedException`; and it's also reflected in types such as `CancellableFuture` or the `Flow.Subscription` (reactive streams). Of course, user-level code that isn't well-behaved, can end up catching InterruptedException, or resetting the interruption signal, without actually interrupting. This makes Java's interruption model somewhat error-prone, but it's workable, and at least it's baked in.
Note that interruptions could also be preemptive, as you don't necessarily need cooperation. If you think of the call-stack, or flatMap/SelectMany in reactive APIs, the compiled code could check the interruption flag automatically and interrupt the call chain.
And resource leaks aren't necessarily a problem, if the interruption protocol is well-thought-out. In Java, try/finally still works in the presence of interruption, since at worst you get an `InterruptedException`. It's not ideal because you can interrupt the interruption process, and in truth the ideal would be for interruption to be its own communication channel, complementing that of exceptions. But it's totally doable, and here I am familiar with several libraries from Scala's ecosystem, namely Cats-Effect, Monix, and ZIO that show it (with limitations imposed by the runtime).
Either way, what C# provides is basically next to nothing. In fairness, some C# libraries tried fixing it, such as Rx.NET, but it's not enough. And the aggregate result in the .NET ecosystem is that interruption is not something people design for. Like what to interrupt a network socket? This ends up being a setting, presented as a timeout in case of inactivity, as a configuration of the connection, instead of a higher-level generic function that can be applied on the consumer side. And the probability for resource leaks goes up actually, because in the presence of concurrent races, you really need interruption.
That’s a choice, but it is unfortunate for the programmer. The truly right way to do it all is like Erlang does - where all processes are cancellable and nothing bad happens.
Async/await also splits the standard library and the ecosystem in 2 (blocking vs non-blocking, or blue vs red), and it can't automatically update the behavior of old code.
The introduction of virtual threads in Java also works well with "structured concurrency", as seen with Kotlin's Coroutines. Kotlin's approach to concurrency is also superior to that of C#, actually. But what's interesting about Java is that its evolution is often one that involves runtime changes, lifting all boats. Java engineers preferred pushing more changes in the runtime, and somewhat ironically, the JVM ended up being the true multi-language runtime.
Java is a good case study of how languages should evolve. It has extreme backwards compatibility, and features being pushed are assessed for how they impact the whole ecosystem, including libraries or languages not in Oracle's control. Project Loom was developed in the open, compared to what Microsoft usually does.