布丁
Why can’t you suspend a CPU bound task?
Why would you want to do that (in C++ context)?
布丁
To not block worker threads? Well
Ludovic 'Archivist'
Why would you want to do that (in C++ context)?
To segment your tasks so that their scheduling is fair irrespective of operating system
Ludovic 'Archivist'
No, you played ostrich to have the pleasure of having the final word like OS threads have nothing to do with the os
布丁
To segment your tasks so that their scheduling is fair irrespective of operating system
When we say suspend, it is something happening within one task. Spawning is not considered suspension.
Ludovic 'Archivist'
If your os doesn’t have a fair scheduling, like windows, your thread pool may block your coroutines threads
Ludovic 'Archivist'
For up to 100ms
Ludovic 'Archivist'
With segmented processing in coroutines, that will never happen
布丁
With segmented processing in coroutines, that will never happen
Simply adopting C++ coroutines will not automagically segment anything
~
Let’s make this easier with an illustration. This is just based on my understanding of async programming in other languages. Suppose we have 8 OS threads prepared to run coroutines, and we create 50 coroutines (representing 50 request connections). The mapping here is m:n, meaning 8 OS threads will execute 50 coroutines. Now, imagine 8 requests come into our server: 1. We spawn 8 coroutines. 2. Our 8 OS threads pick them up and execute them. 3. One of the requests makes a Postgres query. 4. The query code runs → we pause that coroutine → the OS thread is now free and can pick up another coroutine (because the thread is not blocked). 5. After some time, the query result is ready. 6. One of the OS threads resumes the paused coroutine and continues execution until it’s done. So far, everything looks good. But what if one of the requests involves 8 minutes of heavy computation inside its handler? Here’s what happens : A new connection arrives → wrapped in a coroutine → an OS thread picks it up. This coroutine contains pure CPU-bound work. Unlike IO tasks, CPU tasks should not be paused. IO can run in the background while the OS takes care of it, but CPU computation requires an OS thread to keep running continuously. If we try to pause this coroutine, we’re also pausing the computation itself. So: If the computation takes 8 minutes, but we pause it for 1 minute → the total latency becomes 8 + 1 = 9 minutes. Pausing/resuming repeatedly just makes it slower and adds pointless delays. Now, let’s improve this by moving long running CPU tasks to a separate dedicated thread pool: A new connection arrives → wrapped in a coroutine → one of our 8 OS threads picks it up. The coroutine sees a long CPU task → instead of blocking, it delegates the work to the dedicated thread pool: > “Hey bro, give me one OS thread from your pool to handle this heavy task. I’ll go back to serving new requests.” The dedicated thread pool runs the heavy computation until it finishes, then returns the result to the user. Meanwhile, the connection handler thread has already moved on to accept and execute other coroutines. If a coroutine needs to wait for IO → it pauses. If a coroutine needs heavy CPU work → it gets delegated to the dedicated pool. This way: No request handler thread is ever blocked. No CPU computation task is paused (which would only delay results). Request handling remains fast and responsive, because the handler threads are always free to accept new work. IO tasks → can be paused safely, no problem. Long CPU tasks → should not be paused. Instead, offload them to a dedicated thread pool so request handling stays smooth. (And true paralel)
Ludovic 'Archivist'
Simply adopting C++ coroutines will not automagically segment anything
Wow, that exactly what I said in the very first place
布丁
Wow, that exactly what I said in the very first place
Now we have a consensus. Cheers 🍻
Ludovic 'Archivist'
Now we have a consensus. Cheers 🍻
The very first message in this discussion I sent was about how segmenting coroutines is essential
~
The point of coroutine pause is to avoid thread idle, doing nothing
~
Let’s make this easier with an illustration. This is just based on my understanding of async programming in other languages. Suppose we have 8 OS threads prepared to run coroutines, and we create 50 coroutines (representing 50 request connections). The mapping here is m:n, meaning 8 OS threads will execute 50 coroutines. Now, imagine 8 requests come into our server: 1. We spawn 8 coroutines. 2. Our 8 OS threads pick them up and execute them. 3. One of the requests makes a Postgres query. 4. The query code runs → we pause that coroutine → the OS thread is now free and can pick up another coroutine (because the thread is not blocked). 5. After some time, the query result is ready. 6. One of the OS threads resumes the paused coroutine and continues execution until it’s done. So far, everything looks good. But what if one of the requests involves 8 minutes of heavy computation inside its handler? Here’s what happens : A new connection arrives → wrapped in a coroutine → an OS thread picks it up. This coroutine contains pure CPU-bound work. Unlike IO tasks, CPU tasks should not be paused. IO can run in the background while the OS takes care of it, but CPU computation requires an OS thread to keep running continuously. If we try to pause this coroutine, we’re also pausing the computation itself. So: If the computation takes 8 minutes, but we pause it for 1 minute → the total latency becomes 8 + 1 = 9 minutes. Pausing/resuming repeatedly just makes it slower and adds pointless delays. Now, let’s improve this by moving long running CPU tasks to a separate dedicated thread pool: A new connection arrives → wrapped in a coroutine → one of our 8 OS threads picks it up. The coroutine sees a long CPU task → instead of blocking, it delegates the work to the dedicated thread pool: > “Hey bro, give me one OS thread from your pool to handle this heavy task. I’ll go back to serving new requests.” The dedicated thread pool runs the heavy computation until it finishes, then returns the result to the user. Meanwhile, the connection handler thread has already moved on to accept and execute other coroutines. If a coroutine needs to wait for IO → it pauses. If a coroutine needs heavy CPU work → it gets delegated to the dedicated pool. This way: No request handler thread is ever blocked. No CPU computation task is paused (which would only delay results). Request handling remains fast and responsive, because the handler threads are always free to accept new work. IO tasks → can be paused safely, no problem. Long CPU tasks → should not be paused. Instead, offload them to a dedicated thread pool so request handling stays smooth. (And true paralel)
Read this until finish first sir :<
Ludovic 'Archivist'
But hey, at least with C++20 ranges segmenting large loop processing is easy at
布丁
The very first message in this discussion I sent was about how segmenting coroutines is essential
Given a good task segmentation, what stops one from calling a scheduler directly?
Ludovic 'Archivist'
Given a good task segmentation, what stops one from calling a scheduler directly?
Nothing, I used to do that with callbacks and fanin fanout before, it is just made tremendously easier
Ludovic 'Archivist'
Yup. So this has nothing to do with coroutines right.
Coroutines just make it very much easier and make the unsegmented code and segmented code almost identical
Rose
Another one bites the dust...! Banned Jitendra Singh. Reason: spamming ads
Ludovic 'Archivist'
Please illustrate
With coroutines, segmenting just requires a co_yield, with callbacks you must make a state structure yourself to hold the execution state, and make the function reschedule itself
Ludovic 'Archivist'
Coroutines build that structure and the resuming is also handled gracefully within
~
Let’s make this easier with an illustration. This is just based on my understanding of async programming in other languages. Suppose we have 8 OS threads prepared to run coroutines, and we create 50 coroutines (representing 50 request connections). The mapping here is m:n, meaning 8 OS threads will execute 50 coroutines. Now, imagine 8 requests come into our server: 1. We spawn 8 coroutines. 2. Our 8 OS threads pick them up and execute them. 3. One of the requests makes a Postgres query. 4. The query code runs → we pause that coroutine → the OS thread is now free and can pick up another coroutine (because the thread is not blocked). 5. After some time, the query result is ready. 6. One of the OS threads resumes the paused coroutine and continues execution until it’s done. So far, everything looks good. But what if one of the requests involves 8 minutes of heavy computation inside its handler? Here’s what happens : A new connection arrives → wrapped in a coroutine → an OS thread picks it up. This coroutine contains pure CPU-bound work. Unlike IO tasks, CPU tasks should not be paused. IO can run in the background while the OS takes care of it, but CPU computation requires an OS thread to keep running continuously. If we try to pause this coroutine, we’re also pausing the computation itself. So: If the computation takes 8 minutes, but we pause it for 1 minute → the total latency becomes 8 + 1 = 9 minutes. Pausing/resuming repeatedly just makes it slower and adds pointless delays. Now, let’s improve this by moving long running CPU tasks to a separate dedicated thread pool: A new connection arrives → wrapped in a coroutine → one of our 8 OS threads picks it up. The coroutine sees a long CPU task → instead of blocking, it delegates the work to the dedicated thread pool: > “Hey bro, give me one OS thread from your pool to handle this heavy task. I’ll go back to serving new requests.” The dedicated thread pool runs the heavy computation until it finishes, then returns the result to the user. Meanwhile, the connection handler thread has already moved on to accept and execute other coroutines. If a coroutine needs to wait for IO → it pauses. If a coroutine needs heavy CPU work → it gets delegated to the dedicated pool. This way: No request handler thread is ever blocked. No CPU computation task is paused (which would only delay results). Request handling remains fast and responsive, because the handler threads are always free to accept new work. IO tasks → can be paused safely, no problem. Long CPU tasks → should not be paused. Instead, offload them to a dedicated thread pool so request handling stays smooth. (And true paralel)
I just fixed my bad grammar, please reread it, it may add some point
Ludovic 'Archivist'
Of course, parallelisation is not much easier to implement in and of itself, it is very much a case by case basis
Ludovic 'Archivist'
Let’s make this easier with an illustration. This is just based on my understanding of async programming in other languages. Suppose we have 8 OS threads prepared to run coroutines, and we create 50 coroutines (representing 50 request connections). The mapping here is m:n, meaning 8 OS threads will execute 50 coroutines. Now, imagine 8 requests come into our server: 1. We spawn 8 coroutines. 2. Our 8 OS threads pick them up and execute them. 3. One of the requests makes a Postgres query. 4. The query code runs → we pause that coroutine → the OS thread is now free and can pick up another coroutine (because the thread is not blocked). 5. After some time, the query result is ready. 6. One of the OS threads resumes the paused coroutine and continues execution until it’s done. So far, everything looks good. But what if one of the requests involves 8 minutes of heavy computation inside its handler? Here’s what happens : A new connection arrives → wrapped in a coroutine → an OS thread picks it up. This coroutine contains pure CPU-bound work. Unlike IO tasks, CPU tasks should not be paused. IO can run in the background while the OS takes care of it, but CPU computation requires an OS thread to keep running continuously. If we try to pause this coroutine, we’re also pausing the computation itself. So: If the computation takes 8 minutes, but we pause it for 1 minute → the total latency becomes 8 + 1 = 9 minutes. Pausing/resuming repeatedly just makes it slower and adds pointless delays. Now, let’s improve this by moving long running CPU tasks to a separate dedicated thread pool: A new connection arrives → wrapped in a coroutine → one of our 8 OS threads picks it up. The coroutine sees a long CPU task → instead of blocking, it delegates the work to the dedicated thread pool: > “Hey bro, give me one OS thread from your pool to handle this heavy task. I’ll go back to serving new requests.” The dedicated thread pool runs the heavy computation until it finishes, then returns the result to the user. Meanwhile, the connection handler thread has already moved on to accept and execute other coroutines. If a coroutine needs to wait for IO → it pauses. If a coroutine needs heavy CPU work → it gets delegated to the dedicated pool. This way: No request handler thread is ever blocked. No CPU computation task is paused (which would only delay results). Request handling remains fast and responsive, because the handler threads are always free to accept new work. IO tasks → can be paused safely, no problem. Long CPU tasks → should not be paused. Instead, offload them to a dedicated thread pool so request handling stays smooth. (And true paralel)
Your example supposes that only one of the connections required 8 minutes to handle. Suppose that all 50 of them do require processing, all 50 connections will receive their results at the same time, fairly. Similarly, by using segmented coroutines, the other, newer connections will still be handled and be given roughly as much CPU time as the processing, even if the os is not fair in its time distribution.
Ludovic 'Archivist'
Also, it is generally easier to make coroutines cancelable this way than os thread based non segmented tasks
~
Your example supposes that only one of the connections required 8 minutes to handle. Suppose that all 50 of them do require processing, all 50 connections will receive their results at the same time, fairly. Similarly, by using segmented coroutines, the other, newer connections will still be handled and be given roughly as much CPU time as the processing, even if the os is not fair in its time distribution.
I just make i only 1 that does 8 minutes compuation to not repeat typing the explanation, it can be scalled easily to 30 of the connections has 8 minutes computation in it, and it behave the same. No request handler thread is blocked, and does not add unnecerery delay/latency to the computation result
Ludovic 'Archivist'
Out of your 50 connection, it is very likely that one will drop its operation
布丁
To not block worker threads? Well
@QNeko basically you mean this?
Ludovic 'Archivist'
@QNeko basically you mean this?
Yes, but also to have control over how they are executed
Ludovic 'Archivist'
I just make i only 1 that does 8 minutes compuation to not repeat typing the explanation, it can be scalled easily to 30 of the connections has 8 minutes computation in it, and it behave the same. No request handler thread is blocked, and does not add unnecerery delay/latency to the computation result
It does not if and only if your threads are not context switched, your os scheduling is fair, and the results are useful (task not cancelled). All of that goes to the drain if more threads than cpu cores are ever used
~
Also, it is generally easier to make coroutines cancelable this way than os thread based non segmented tasks
My above architecture is like this 8 OS threads (I name it request handler threads) -> for picking up new connection that wrapped in corroutine and execute said coroutine Another 8 OS thread in thread pool -> to back up the request handler threads if they have long running CPU task, they will take over the job until it finish to the user space
Ludovic 'Archivist'
By making your own coroutines scheduler and segmenting your tasks, you only go into os code for io, you have control over scheduling so you can cancel tasks without the task knowing it has been cancelled or priorité tasks if needed
Ludovic 'Archivist'
The question is, why we are looking for fairness in the first place?
Say you have 50 connections number crunching. If you do fifo, and do not check for cancellation (which many do because it is annoying) in a fair system, not everyone will wait the same amount of time, so it is very likely your queue will be tailed by cancelled operations that you will perform anyway, leading to subsequent connections also waiting too long and cancelling etc etc
~
It does not if and only if your threads are not context switched, your os scheduling is fair, and the results are useful (task not cancelled). All of that goes to the drain if more threads than cpu cores are ever used
Having more threads than CPU cores is actually fine. But if you try to context switch a CPU bound task with coroutines, you’re only hurting performance : It slows down request handling because fewer requests can enter the processing stage. It delays the math heavy computation itself, since it keeps getting paused and resumed over and over. That’s why offloading long heavy CPU work to a dedicated thread pool is much better, overall, the whole application runs faster and more smoothly.
Ludovic 'Archivist'
So your whole argument is that os scheduling with poorly segmented or unsegmented tasks is inherently more reliable and has lower latency than a user space scheduler with properly segmented tasks
Ludovic 'Archivist'
When it may not even have a better throughput at that in reality
Ludovic 'Archivist'
Mostly because some os were written by monkeys with typewriters (no offense to any ms dev)
~
That would also be the case with having more threads than os core, except you would have no control over it
The dedicated thread won’t be spawned unless it’s actually needed. When a request handler thread that executes coroutine that wraps the connection encounters a long running computation, it asks for a dedicated thread. That thread is then spawned, takes over the task, and runs it until completion. Once the computation is done, the thread doesn’t get destroyed right away. Instead, it’s kept in the pool for, say, 5 minutes, just in case another long heavy CPU task shows up soon. If no new task arrives within that time, the thread is removed and the pool goes back to empty.
Ludovic 'Archivist'
It competes all the same, just in kernel space instead of user space
~
And in what does that mean your threadpool does not compete with your connection acceptor coroutines threads?
They’re competing for resources, but if no one takes a thread from the pool within 5 minutes, it gets released right away. The good part is, this doesn’t add additional delay to accepting new connections, the OS handles that with context switching not OS context switch + user space context switch On the other hand, if we don’t do it this way, our coroutine could get blocked or experience extra delay, because then we’d be dealing with OS context switching + user-space context switching. In contrast, the first approach only involves OS context switching, which is the total is fewer operation in the total That is my understanding :v
布丁
I thought it's a common sense in [any languages/runtimes with an async equivalent mechanism] not to block the worker threads no?
Still referring to this mindset. We don't want computation to block scheduler that's why we yield. It's not because we can yield and hence use coroutines.
~
Yeah, same with the user space scheduler
I do not think they are the same. Because CPU bound task need OS thread otherwise it will not run, if you add user space context switch in pure CPU task, you just add new delay, because time of OS thread context switch + time of user space context switch
~
The model that I type above is the same that is uses on Go’s goroutines, Java’s virtual threads, or Kotlin’s coroutines. If the blocking is caused by IO, the coroutine (or goroutine/virtual thread) simply gets paused and the underlying thread is freed. But if the blocking comes from a long running CPU task, the scheduler will let it block the thread, and the scheduler will spin up a new OS thread to replace the blocked thread. If the previous thread is done and arrive, it continue the execution until is done and be freed
~
All of these are stackful coroutines and roughly on the scale as an os thread to switch to, minus the system call
How about rust async that is also stackless, and they also use dedicated thread pool in tokio::spawn_blocking?
Ludovic 'Archivist'
I do not think they are the same. Because CPU bound task need OS thread otherwise it will not run, if you add user space context switch in pure CPU task, you just add new delay, because time of OS thread context switch + time of user space context switch
Why would the CPU bound task not run, and why do you consider that the user space scheduler will ba slower than the kernel scheduler when the user space scheduler does not have the following concerns/responsibilities: - process based fairness - memory eviction - interrupt timer - other processes to schedule - permissions - cgroups
Ludovic 'Archivist'
How about rust async that is also stackless, and they also use dedicated thread pool in tokio::spawn_blocking?
Because many people still can't get into their head to segment their tasks and they instead rely on the kernel interrupting their control flow to implement it
布丁
nah tokio also have this https://docs.rs/tokio/latest/tokio/task/fn.yield_now.html
Ludovic 'Archivist'
nah tokio also have this https://docs.rs/tokio/latest/tokio/task/fn.yield_now.html
Which is the equivalent of a C++ co_yield in a task type that returns void, or co_awaiting a no-op. In other words task segmentation
Ludovic 'Archivist'
I thought it's a common sense in [any languages/runtimes with an async equivalent mechanism] not to block the worker threads no?
I think the common sense actually is to not block the worker thread *for an unbounded amount of time*
Ludovic 'Archivist'
The point of segmenting your processing is to bound that amount of time to a reasonable amount that leaves fair time for other processes to progress
Ludovic 'Archivist'
That includes a high prio connection acceptor for example, or time sensitive IO
Ludovic 'Archivist'
Apparently this is IO and that's why we use coroutines. In a purely CPU-bound setup such as one-off offline batch processing, I don't see the need.
Yes, in a batch process with absolutely no IO it is not needed, but pure batch processes are fairly rare
Ludovic 'Archivist'
Actually sometimes coroutine can be used in pure in-memory batch processes to weave cache prefetches into the control flow, but they do not compile to a real scheduled execution, it is just a good old negative cost abstraction
Ludovic 'Archivist'
But I have only seen it once
Ludovic 'Archivist'
Depends on the requirement, say running as a Job in a k8s cluster
Still fairly rare that you have a batch job that does all its thing in memory, most of the time you have database shenanigans or a big folder of files to process in batch jobs
Ludovic 'Archivist'
But those tasks do not require fairness
Nah, that's true, you also would not run them by scheduling them all at once either
Ludovic 'Archivist'
You have control on the tasks at hand there, unlike in the server case (or rt application case)
Ludovic 'Archivist'
But, for example, Krita should not use a thread pool to handle strokes and instead use segmented coroutines because it is paying an insane amount of extra cost in the os by design making editing large files very spiritually taxing
Ludovic 'Archivist'
Going under 30fps should never happen in an art software
~
Why would the CPU bound task not run, and why do you consider that the user space scheduler will ba slower than the kernel scheduler when the user space scheduler does not have the following concerns/responsibilities: - process based fairness - memory eviction - interrupt timer - other processes to schedule - permissions - cgroups
I came again, I just take a bath first :v Because you pause the coroutine sir, so the underlying thread is freed and CPU bound task can not run without our own thread unlike IO bound task I think I should type in better, what I mean is you are adding user space context switch on top of kernel level context switch which is 2 operations
~
You can schedule the CPU bound task on the coroutine thread as a coroutine
No no, is not that will block the threads dedicated for running the coroutines?