Re: 100k Threads? (2002)
lkml.iu.edu
lkml.iu.edu
I'm actually not that old, I didn't use java at the time, but those news were my introduction to the term "thread" so they kind of stayed with me. A tiny part of my brain still files "threads" under "the thing java once didn't use natively" and wow, that part feels old now.
Also, there are many different characteristic of any such system: M:N is not the impressive part at all.
I suppose you could implement these more subtle things with real threads, but it would require a richer interface between the program and the operating system, which would need to be supported in compilers and language runtimes. FreeBSD did something like this with kernel scheduled entities, but it never caught on.
As an aside, i wouldn't call virtual threads cooperatively scheduled. To me, that means that user code has to be written with scheduling in mind, to make sure that it hits yield points where the thread can enter the scheduler. Virtual threads don't require that, because the JVM can ensure the necessary entries to the scheduler, since it controls interpretation and compilation. That said, i believe that in the current implementation of virtual threads on the JVM, it is possible that a tight loop that doesn't call any methods will not be rescheduled; i'm hazy about the details of this, because surely this interacts with safepoints. But maybe it's only very slightly cooperatively scheduled!
Though doing such a function call for every tiny little function is a bit excessive of course, I'm thinking maybe the compiler should track for every function how much stack it will need in the worst case (usage for the function itself + max of usage for the functions it calls). Functions that use memory below some threshold just grow linearly, while those that use above it will go to the trouble of requesting memory. Now this idea assumes that function calls happen in a DAG, it breaks if functions can call themselves or each other in a cycle. But that's actually a good realization maybe. Like a recursive function should perhaps always explicitly request stack space. Security and robustness benefits!
What about libraries? Ideally library functions would be annotated with how much stack space they need (as a side note I have long thought that C-headers are inadequate for specifying how a function should be called. Not annotating stack space requirements one deficiency here!). Absent such annotations, I suppose we need to go with the status quo of making some guess.
Right now virtual threads that don't enter into any blocking code won't be unscheduled by the JVM.
In addition to Java virtual threads, similar systems exist in Go goroutines, Rust async and Haskell green threads for example.
There are upsides and downsides to cooperative userspace threads. An erratic or malicious green thread can hog all the CPU time and starve other threads for example.
An operating system can not trust the userspace to behave, starvation due to misbehaving thread is unacceptable.
So the OS can not implement green threads alone, it needs the userspace for the co-operative switching.
* I managed to block a whole erlang VM one day, spoke with Joe Armstrong on chat, but they won't fix this, as it's pretty niche use case.
I can think of a few ways:
- The VM just doesn’t JIT and can decide to stop executing a thread by just not interpreting the next piece of bytecode and switching to another green thread instead (this would be pretty slow due to the lack of JIT)
- The VM JITs, but inserts a preamble before every function call saying “Before executing this function, should I switch to another green thread first?”, and thus, so long as you call functions frequently enough, you “preempt” yourself. This is how Go does it, and it’s a well known thing in Go that if you never call a function for a while (like just doing a really huge for loop), the current goroutine doesn’t yield execution and hogs the whole OS thread.
I'm not sure of the implementation details, but this hasn't been true for a while in Go. As of Go 1.14, goroutines are asynchronously preemptible, so loops without function calls no longer deadlock the scheduler or GC: https://go.dev/doc/go1.14#runtime
https://medium.com/a-journey-with-go/go-asynchronous-preempt...
Is this significant for virtual thread use cases? OS threads are cheap since there's no virtual memory context switch.
The cost of crossing the userspace/kernel boundary happens on any system call and has nothing to do with context switching.
(Modern OS's typically context switch during system calls for i/o, exactly the same as userspace threads.)
The biggest cost of a context switch, though, is typically that your CPU goes to do something else, which reduces the efficiency of any caches (including branch prediction etc.). This is the same whether you have kernel or userspace context switches, assuming the threads you're switching between touch different code and/or data.
Both OS and "green" threads are meant to switch when doing i/o.
Even with traditional kernel side IO, you might not want to attempt a syscall if the socket is known not to be ready.
Also when implementing purely userspace synchronization primitives, being able to swap to a different context purely in userspace is nice.
Why do we even waste resources on OS when we can write e.g UEFI application and run it "directly" and let it manage resources without OS overhead
Nowadays runtimes like Java's, etc, etc. are doing more and more tasks that previously were managed by OSes
But being able to run a full OS is convenient, and often still faster overall as there are magnitudes more people working at optimizing general purpose OSs than specialized unikernels.
Also “without OS overhead” - things sitting in memory don’t necessary detract from performance, but not having hardware acceleration for lack of drivers certainly can.
The only overhead guaranteed during execution by the OS is context switching, and if you want to avoid that you’re basically writing an RTOS for an embedded system.
To be fair, including the stack frame and any things the kernel needs to track them, it is a lot of RAM... in 20+ years ago sizes of memory. Today, even mid-grade consumer computers come with tens of GB of RAM.
I think that stacks grow using pagefaults, so you might still run out of address space.
I.e., you can't have two programs running where (arbitrarily) one of them consumes (nearly) all of your memory on a system where address space size equals memory size.
Is a huge number of threads as useful today as 2002?
Linear code is far easier to reason about, but that is easy to forget once someone has become accostomed to thinking in async constructs.
The thing is, there are only so many things a person can mentally juggle. Removing the consideration of a whole dimension of issues means the developer can take on more useful complexity.
Threads are for working in parallel, splitting a single compute-intensive tasks into many.
Async is for waiting in parallel for disk I/O results, database queries, network responses, etc.
The former are for doing more work. The latter is for efficiently waiting for others to do their work without using a bunch of excess thread context resources.
You don't reduce complexity by ignoring the difference between two quite distinct use cases. That's false simplicity.
If the threads are cheap, there is no point in making this distinction, which greatly simplifies the language. And they can be, we just need programming languages designed for that.
Instead of accepting this fact and using another language if threads are needed, Python has been doing various async-style things for years and pretending it's good enough, including what can only be described as cooperative multitasking, that horror from the early 90s.
You could argue that Go does this more elegantly with goroutines, but this further highlights the point that threads should not be a silver bullet to development models.
So if a green thread is waiting for I/O, another can use the CPU just fine.
Yes there's context switching but it does scale and code becomes simpler to write and reason about.
For starters, debugging async stacktraces is a nightmare in many languages/runtimes.
There's also the cognitive load that async programming adds to humans.
And there's function coloring. It tends to spread and "infect" the codebase.
Also most languages have to duplicate their APIs to support both linear and async calls.
And then there's driver support. In languages I have seen, async support came with the requirement of specialized or at least adapted drivers.
so 100k threads mean 260 threads per cpu, which is not so much then...