Notes on Virtual Threads and Clojure
ales.rocks
ales.rocks
In a program with lots of small OS threads, that's quite a bit of wasted space.
As for why the JVM can do this and you can't with other runtimes, it's because it needs a complex runtime with very tight integration between the compiler, the GC, the threading library and various other subsystems. That's why the moment you make a native call the JVM loses the ability to switch between the virtual threads and has to start up more OS threads to compensate - it didn't create the code that's running on the stack at that point, so it can't work its usual magic.
Fortunately the whole 'pure Java' movement of some decades ago means that actually you can do a heck of a lot of stuff without using native code. It's an ecosystem way less dependent on C extensions than most other languages.
- Since GT are known to a compiler/runtime they can be cooperative, as opposed to preemptive. OS threads cannot introspect into what your code is doing. That means GT may insert suspend and resume points at API boundaries, for example at any kind of IO. While an OS could implement this in theory (switch threads at i/o syscalls) in practice I don't think there are any thread schedulers that do this.
- GT have orders of magnitude faster context switches than OS threads. All they need to do is swap a stack frame pointer.
- GT often have growable stacks that start extremely small, OS threads have larger default stack sizes that don't grow. This makes starting and destroying GT cheaper, and they can use less memory than the equivalent number of OS threads.
As for why OS threads don't just work the same, there are some disadvantages. You can't pin a GT to a single core, or set thread affinity. Context switching an OS thread is more expensive because it's doing more things, like saving the signal mask (which invokes a syscall, hence the cost). The large fixed stack size means that they don't have to perform memory allocation on a function call once the stack is too small. They have to be preemptive since the OS doesn't know anything about what the program is doing, other than a handful of i/o syscalls.
They're essentially two different technologies for two use cases. The OS threads are more robust and closer to the metal, but they are expensive to start up and switch between. However GT are small and cheap.
Another way to look at it is that there are only OS threads, and "virtual" or "green" threads are distinct concurrent tasks scheduled onto a pool of OS threads on the fly. The compiler inserts a bunch of book keeping to make scheduling those tasks easier. The only thing that makes them "thread like" is the API.
Then the "10k Problem" article popularized poll-based architectures as opposed to threaded ones and the rest is history.
Certainly a proactor event loop (uring iocp) is different from a reactor (poll) from an usage point of view, but you can implement one in term of the other.
Scheduler activation is different and it is just a way for the kernel to notify userspace of all rescheduling events (including running out of the scheduling quantum or taking a page fault).
In Go you get non-blocking "for free" so that it looks like blocking I/O but the runtime automatically suspends & resumes virtual threads as I/O buffers fill & drain. One of the big inspirations for Loom was Go jealousy. For all Go's faults, this is a really nice thing to have.
As adding the ability to manipulate call stacks to the JVM will undoubtedly be required, it is also the goal of this project to add an even lighter-weight construct that will allow unwinding the stack to some point and then invoke a method with given arguments (basically, a generalization of efficient tail-calls). We will call that feature unwind-and-invoke, or UAI.
It is not the goal of this project to add an automatic tail-call optimization to the JVM.
[1] https://cr.openjdk.java.net/~rpressler/loom/Loom-Proposal.ht...Wait what? Are we getting TCO or not with Loom?
This is rather important, specially when coming from languages like Scheme that have TCO as part of the language specification compliance.
1. There is no way to rewrite this into loops by just modifying the insides of these functions.
2. The remedy, which is to transform these functions and everything that calls them into a giant loop causes lots of problems:
The code will become utterly unreadable (if you do this transform manually).
Modularity is completely broken and there's no sane way to expose these functions individually to external callers.
Also, even if you don't care about either of the above: your compiler's optimizer will probably choke on it (https://blog.reverberate.org/2021/04/21/musttail-efficient-i...).
Of lesser note, I don't believe I've ever come across mutually tail recursive functions, or the need for them, and although that may reflect my lack of experience in some areas, I guess it's not at all common? Maybe in the haskell world perhaps.
I'll read Steele's paper but introducing complexity only to struggle to delete that complexity is a strange approach
I find that recursive solutions are often easier to understand and change than iterative solutions, especially because you need less incidental state (and you can manage the state you have more cleanly), so I'm not sure what you mean by "introducing complexity only to struggle to delete that complexity". That would more aptly describe my experience with writing iterative versions of naturally recursive algorithms.
(I think you may have mistaken me for someone else; I didn't recommend Steele's paper.)
If goto is easily available in the high level language then creating a state machine is trivial I guess. If it's not then the compiler should be able to do TCO with jumps/gotos in the output asm or transpiled code (IIRC Bison's output uses gotos even though the input yacc rules clearly have none).
I continue to feel I'm missing something vital.
It's still painful, because whichever state you're in probably cares about different data. Some data only needs to exist during some states. Factoring states into separate functions means each gets its own scope, and can explicitly pass only data needed for the next state forward.
> If it's not then the compiler should be able to do TCO with jumps/gotos in the output asm or transpiled code
It's nice to be able to indicate explicitly to the compiler (and other developers!) that you expect tail calls to be optimized, rather than crossing your fingers and hoping that nobody else comes along later and accidentally adds something after the call. Scala optimizes tail recursion by default, but it also has a `tailrec` annotation that causes the compiler to throw an error if it isn't able to respect that intent.
Reminds me of a description of continuations as "gotos with parameters" which seems to be what you sort of want - I'll do some reading. Appreciated.
Well, had you read the link I posted in my reply to your question, you would have ;) Anyway, there are a some useful things that are much harder to do pleasantly and efficiently without it.
The security manager has to be removed first [1]
> Each thread easily uses an additional Megabytes of memory
isn't quite the problem it seems because unless the thread actually _uses_ large amounts of stack, the memory consumed is mostly virtual. With today's 64-bit architectures, there is plenty of virtual address space to be burned.
And every argument that OS threads are good enough is in stark contrast to the length to which people go to avoid using them. I am talking about all the async libraries/patterns/language features people use to avoid using OS-level threads.
Deep stacks are pretty common in modern software especially for Java software.
core.async will still be useful in ClojureScript land, though.
You can just use them like a function. But they block the thread. So if you e.g. naively call it in the REPL you will block it indefinitely. E.g.:
(>!! (chan) "this will block the REPL")
The idea would be to pass a channel to multiple virtual threads which use the blocking versions.All code looks exactly the same otherwise.
https://stackoverflow.com/questions/18779296/clojure-core-as...
I don't understand what the advantages of it are?
Are Virtual Threads resources that need to be explicitly cleaned up? Can't the pool grow and shrink based on demand?
In Go for example, I don't remember having to clean up fibers manually.
For example, how is the structured concurency example different from:
(defn run-concurrently []
(let [f1 (future (identity 2000))
f2 (future (prn "Starting a long running operation"))
f3 (future (Thread/sleep 1000))
f4 (future (prn "Done."))]
(run! deref [f1 f2 f3 f4])
4))
(run-concurrently)
Also as an FYI, the example can be simplified by using `with-open`.Let's for example say one of those 4 tasks would write to a file. With structured concurrency you have a guarantee that once the supertask finishes, the file is no longer in use. Without structured concurrency you won't have that, and a subtask might error on trying to use the file or find garbage data in it. Running the supertask in a loop with structured concurrency would have no risk of running into resource contention, while without it there is that risk.
Plus structured concurrency also helps adding a deterministic bound on the amount of concurrency in the system, while just spawning more background tasks can lead to concurrency grow in an unbounded fashion.
> In Go for example, I don't remember having to clean up fibers manually.
The equivalent in Go would be to wait for subtasks to complete, using the WaitGroup pattern. And to notify the subtasks that they should complete using "Context".
(run! deref [f1 f2 f3 f4])
does.> In Go for example, I don't remember having to clean up fibers manually.
And that is awful if you think about it a bit. There's no composition happening, your go block just goes off somewhere without you having any control of it. It's like goto, only it forks first and never gives back the process handle[2].
[1] https://clojureverse.org/t/missionary-new-release-with-strea... [2] https://vorpus.org/blog/notes-on-structured-concurrency-or-g...
From what I read, Loom structured concurency will not automatically interrupt the other tasks though. And the .close I think will wait for all of them to complete. They said it's because you might not always want other tasks to cancel, so they made it more explicit.
But arguably, maybe if you had a catch in there it would be triggered on the first task to throw. But I can also imagine a similar function to wait for the first future to return or error in Clojure. Though I'm guessing at that point one might say that's just implementing structured concurency?
My question maybe was about the need to close virtual threads. Are they just abusing Closeable so you can use .close as a wait for all to complete even when errors are thrown feature, or do you actually leak resources if you don't clean up Virtual Threads you're done using?
A similar thing has happened at the OS level with support for multiple programs. Computers started out running one program at a time. Then "multitasking" was invented, and along with it all kinds of things to make it safer and easier like users and permissions and protected memory, and it was great, many different users could run different programs on the same machine. Then we moved from running statically linked binary executables to dynamic linking and interpreted languages that load libraries at run time. Managing environments for different programs on one machine got hard. VMs and hypervisors let us segment a machine into virtual machines where the environments were easy to control. But VMs were a heavyweight solution for managing environments, so along came containers, which are kind of virtual virtual machines.
So now we're in the position where it's perfectly normal to run a program with several virtual threads running in a "real" thread in a Java virtual machine running inside a Docker container running on a VM instance in a hypervisor on some giant box somewhere. And if we're all living in a simulation, then its' probably turtles all the way down.
I don't mean this entirely as a "get off my lawn" rant. I recently re-implemented an io-heavy threaded program using asyncio and I found it genuinely better. It was easier to reason about what was happening and to get our task dependencies to execute in the right order. The code is simpler and I'm pretty happy about it. But I fully expect asyncio and virtual threads to go out of fashion in ten years, and some new preemptive, OS-managed thing will come along and become the new hotness.
ps - I found this quote in the description of Virtual Threads[1]
> Virtual threads are preemptive, not cooperative — they do not have an explicit await operation at scheduling (task-switching) points. Rather, they are preempted when they block on I/O or synchronization.
Sorry, that's not preemption, that's yielding at specific safe points. I.e. cooperative multitasking. For example, Classic MacOS's cooperative multitask didn't have an explicit yield, it yielded automatically when you polled for a new event.
[1] - http://cr.openjdk.java.net/~rpressler/loom/loom/sol1_part1.h...