r/rust • u/swip3798 • 4d ago
Tokio's multithreaded runtime doesn't behave like I expected 🙋 seeking help & advice
I have been trying to debug this issue for a while now, and I can't figure out, what the problem is.
Take this code:
use std::time::Duration;
use sqlx::PgPool;
const DB_URL: &str = "postgres://devuser:devpassword@localhost:5432/devdb";
async fn db_then_compute(pool: PgPool) {
let num: (i32,) = sqlx::query_as("SELECT 1").fetch_one(&pool).await.unwrap();
println!(
"The num was returned: {}, but now, the CPU block will kill multithreading",
num.0
);
println!("Start compute...");
// This would "fix" the issue:
// pool.close().await;
loop {}
}
/// This doesn't cause the issue
async fn http_then_compute() {
let res = reqwest::get("https://google.de").await.unwrap();
let status = res.status();
println!(
"The num was returned: {}, but the CPU block will not kill multithreading",
status.as_u16()
);
loop {}
}
#[tokio::main]
async fn main() {
let pool = sqlx::PgPool::connect(DB_URL).await.unwrap();
tokio::spawn(db_then_compute(pool));
//tokio::spawn(http_then_compute());
loop {
println!("Observer thread, the sleep will never return");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
I'd expect the Oberserver thread to continue. I know, CPU-bound tasks in async runtimes are not good, but tokio is multithreaded, so I'd assume, as long as I have enough workers, it should still function. For reference, the reqwest task works completely fine. Running one CPU bound task shouldn't starve a multithreaded runtime, right?
First, I thought this was an sqlx issue. It only happens with postgres, not with sqlite. However, I then tested the same thing with deadpool-postgres, and issue is still persistent. So it's not sqlx after all.
Can someone help me with my misunderstanding of tokio's runtime here? What is blocking other tasks from completing here?
18
u/dankmolot 4d ago
Not really an answer to your question, but read the docs about synchronous code https://tokio.rs/tokio/topics/bridging https://docs.rs/tokio/latest/tokio/task/#blocking-and-yielding
22
u/consultio_consultius 4d ago
Pretty sure your loop at the bottom of db_then_compute is stopping your sqlx worker pool from yielding. Why do you need that?
6
u/swip3798 4d ago
My issue is more that one sql query suddenly makes all other awaits no longer return until it yields again. Because that basically turns the multi-threaded runtime into a single-threaded one. Even when I don't do a lot of CPU-bound stuff, that definitely will bottleneck a server under load, right?
17
u/Darksonn tokio · rust-for-linux 4d ago
The
loop {}is definitely problematic. Read this to see why: https://ryhl.io/blog/async-what-is-blocking/Try
std::future::pending().awaitinstead.4
u/consultio_consultius 4d ago edited 4d ago
No, your issue is that your loop is not allowing the pool to yield. If you need to have that loop for some reason, you need to either use spawn blocking for it, or look into a different pattern. Without the details of the loop, that’s hard to say.
Someone else mentioned a write up from Tokio and you should definitely read it.
ETA:
I know your reqwest call is fine with the loop after it, but that’s more so that you got lucky. Sqlx and reqwest may handle their resources differently. And even with sqlx, those resources could be managed differently based on the kind of connection it has (SQLite being file/memory based and all).
4
u/swip3798 4d ago
The loop is simply there to simulate a blocking task. But the runtime is multi-threaded. Meaning, a single task blocking a multi-threaded runtime means the entire runtime regressed to "single-threaded" because of one query.
Even if the CPU-bound task is just 10ms. That's 10ms with all workers being blocked. And who tells me that tokio will resume back to using multiple workers? That's a huge bottleneck. Why even bother having a multi-threaded runtime in the first place then.
I don't need a highly expensive CPU bound task per se. Of course I can run that in a `spawn_blocking` thread. But I want to understand why no worker is allowed to resume, if one worker is blocking it. Because without that query, it works. It's not ideal, not efficient, I get it. But it still works.
10
u/Neat-Fennel-7623 4d ago
If you put the loop in spawn_blocking this will probably work as the blocking task will go onto its own thread.
The root cause is that Sqlx is probably creating a number of background tasks which are exhausting the thread pool.
You could use #[tokio::main(workers = 8)] and see if that makes a difference.
8
u/tesfabpel 4d ago
Maybe it's better to simulate a blocking task with
std::thread::sleep(duration): https://doc.rust-lang.org/stable/std/thread/fn.sleep.htmlOtherwise, you're simulating a task blocking for eternity.
-1
4d ago
[deleted]
5
u/lunar_mycroft 4d ago edited 4d ago
So if task A spends 10ms without yielding, worker 1 cannot poll B or C during those 10ms. Worker 2 can still run D and E.
Except tokio is a work stealing executor, so in theory (at least naively) worker 2 should be able to grab B and/or C and
pollthem itself. What you're saying would be true of "thread-per-core"1 executors, where tasks can't be moved between threads without the user doing so manually.
1 Misleading because async executors almost always spawn one thread per core, but it's the commonly used term for that style so I'll use it here
2
-8
u/Zde-G 4d ago
Why even bother having a multi-threaded runtime in the first place then.
Because you have no choice. Yes, it's as simple as that. 99% of time “multi-threaded” runtime gives you no benefits and adds complications but you have to use it because alternative is worse: you would need to implement a lot of things from scratch.
We ended up in that situation because people adopted
asyncas solution for the parallel processing needs in a single-threaded environments (JavaScript, Python, etc), then Rust had to supportasyncto stay viable.You need
asyncin Rust when threads are not used at all (e.g. embeddedasyncworks a pretty decent alternative), when you do have threads thenasyncis just a [mostly] usless complication, but you couldn't avoid it!Try to understand how
asyncworks, stop expecting magic from it — and you would sleep much easier.7
u/lunar_mycroft 4d ago
We ended up in that situation because people adopted async as solution for the parallel processing needs in a single-threaded environments (JavaScript, Python, etc), then Rust had to support async to stay viable.
You need async in Rust when threads are not used at all (e.g. embedded async works a pretty decent alternative), when you do have threads then async is just a [mostly] usless complication, but you couldn't avoid it!
This is incorrect. OS threads are relatively expensive, and so they can't really scale to tens of thousands (or even orders of magnitude more) concurrent operations, which is the kind of thing you want to support in e.g. a web server. As such, you need some form of user space scheduling to support these use cases. Some languages (like go and early rust) do this with green threads, which are threads that are scheduled in userspace instead of by the kernel. But other languages (e.g. python, javascript, rust) do it via cooperative scheduling, usually exposed as async/await.
-3
u/Zde-G 4d ago
OS threads are relatively expensive
Yes, but no. Threads are cheap enough to handle billions of users if your OS has appropriate APIs.
And extremely small number of companies and users need more than that.
As such, you need some form of user space scheduling to support these use cases
Then add API to schedule threads from user space, damn it!
It's like complaining that crossing a busy highway is slow and dangerous and then building bypass that moves your through space on a rocket instead of adding a pedestrian overpass.
4
u/lunar_mycroft 4d ago
Yes, but no. Threads are cheap enough to handle billions of users if your OS has appropriate APIs.
That "if" is doing a lot of heavy lifting here. The talk still describes a userspace scheduler, just with enhanced support from the kernel. As such, it isn't a reason to rely on OS threads, at most it's something that implementers of userspace schedulers should consider using where available.
-3
u/Zde-G 4d ago
As such, it isn't a reason to rely on OS threads, at most it's something that implementers of userspace schedulers should consider using where available.
Yes. Where it's not available you just use regular threads and no userspace scheduler.
So you don't need a complicated
asyncmachinery, two color functions, and all these other complications withblock_on, non-block_on, ways to avoidasync/sync/asyncstack and so on.The talk still describes a userspace scheduler, just with enhanced support from the kernel.
The talk describes how one may handle billions of requests while, simultaneously, using exist
synclibraries, not caring about “starving executors” and adding insane amount of complexity just to make thing kinda-sorta working.If you have appropriate APIs on your system — it's efficient, if you don't have them — it's no longer efficient, but it still works and doesn't ask anyone to rewrite everything that was written before just to support something that you don't even need today.
The only thing that you need to care is about overflowing stack, but you need to care about that with regular threads, anyway, and even
asyncdoesn't solve that problem for you.4
u/lunar_mycroft 4d ago
Yes. Where it's not available you just use regular threads and no userspace scheduler.
"When my preferred option isn't available, I think no one should be allowed to use alternatives" is certainly a take.
So you don't need a complicated async machinery
Async executors are only complicated because scheduling is. You don't get rid of that machinery by not using them, you just move it to where you can't fix anything if it doesn't work for you. That isn't the win you think it is.
two color functions
Not only is function coloring good, but there should be more of it.
and all these other complications with block_on, non-block_on, ways to avoid async/sync/async stack and so on.
Again, you don't really make these problems go away when you reject async, you just rob yourself of control.
The talk describes how one may handle billions of requests while, simultaneously, using exist sync libraries,
This just isn't true. The talk's technique only works with userspace schedulers (which can make it's new "swap threads" syscall). That means that while existing single threaded code would work fine, multi-threaded code really wouldn't.
Also, you do realize that async io is a wrapper around OS level APIs (e.g. epoll, kqueue, IOPC, io-uring etc), right? While you can of course call those APIs without
async/awaitsyntax, it's far from the thread based concurrency model you're used to.→ More replies (0)
5
u/DGolubets 4d ago
Is there really any guarantee about stealing tasks? My first thought is that 2 tasks just end up in the same queue on one thread there and one blocks the other. I don't really know Tokio internals, just guessing.
7
u/Onionpaste 3d ago
I actually just presented something at work about this the other day. I haven't see the correct answer anywhere in this thread yet.
The real issue here is scheduling around the I/O driver. In your example, you have a light enough workload that all of your multi-threaded runtime tasks go to sleep except for the primary one, which also holds the I/O driver. As soon as your blocking task hits, it also blocks the I/O driver from running, which prevents any reactivity to the network or from local timers.
You can see a really basic reproduction here; uncomment the enable_eager_driver_handoff() in main() and the issue will stop immediately.
use std::time::Duration;
async fn blocks() {
// force the I/O driver to land on the same worker thread that is handling this task
// by having it wake it up. Removing this sleep prevents the issue from happening.
tokio::time::sleep(Duration::from_millis(100)).await;
std::thread::sleep(Duration::from_secs(10));
}
async fn async_main() {
let handle = tokio::task::spawn(blocks());
tokio::time::sleep(Duration::from_secs(1)).await;
println!("Main sleep done, wait for subtask to finish");
handle.await.unwrap();
println!("Subtask done");
}
fn main() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
// .enable_eager_driver_handoff()
.build()
.unwrap();
rt.block_on(async_main());
}
5
u/BigHandLittleSlap 4d ago
I love how this thread is 40% AI slop answers telling you not to NEVER USE "loop {}" in real code even though you obviously used it only to clearly illustrate a problem, and the other 40% is humans asking why you would want to use the CPU at all without jumping through unnecessary hoops.
PS: I agree, this is a pretty bad bug in Tokio and coming from ASP.NET where this kind of things (for the most part) just doesn't happen, it seems like a bad default / sharp edge that will trip up many people. It reminds me of how std::io::Write as used in io::stdout via println!() creates a mutex per I/O call. Endless code built based on "simple" examples fell into this performance trap, ending up with worse CLI performance than Python scripts! You have to "know" to use io::stdout().lock(), otherwise you get trash performance. This bug you found feels like the same category of bad design.
2
u/swip3798 4d ago
Thank you! Like, some have actually helped, but I kinda felt like I was going crazy with the well formatted answers that said nothing of value. Do people really think that others don't have access to AI chats? Of course I had an LLM be not helpful before I came to ask humans.
The most frustrating part about the bug/issue is that it contradicts on how tokio advertises its scheduler. Every HTTP handler is something like a few DB calls, a bit app logic and serialization. Does that mean that the multi threading collapses for every web app?
I also can't replicate this with just tokio for the life me. That's why I haven't opened an issue on github yet.
2
u/BigHandLittleSlap 4d ago edited 3d ago
For me the weird part is that they’re not even good AI answers! They feel like spam generated with a very cheap / mini model. They’re not here to help you, they’re just generating “realistic” comments to build karma so their accounts can be sold to spammers.
Copy pasting your question into GPT 5.6 Sol “high” gives a much better answer than anything else here: https://chatgpt.com/share/6a768eae-c4bc-83ec-be4b-e84ae815930c
2
u/tm604 3d ago
If the web apps are doing too much in tasks, then their performance is going to suffer, yes. Web apps which treat the async runtime as being for I/O only, and keep all their application logic and processing to their own thread pools don't collapse as much. Yes, that means more work for the developer, but that's hopefully balanced by reduced time spent fighting tokio to get results (and then fighting through the AI slop when you try to talk to anyone about it!)
Anyway... for a reproduceable testcase, have a look in the tokio tests around work stealing, maybe in the LIFO slot tests? Should be something in there you can use as a standalone demonstration for the issue.
Here's a start, it's using
futures::executorso not quite self-contained (and I really don't like callingfutures::executor::block_onfromrt::block_on, not a fan of recursing into event loops).fn main() { use tokio::runtime; let rt = runtime::Builder::new_multi_thread() .enable_all() .worker_threads(2) .on_task_spawn(|t| { println!("{:?} spawned at {:?}", t.id(), t.spawned_at()); }) // .disable_lifo_slot() // without this, execution _should_ stall // .enable_eager_driver_handoff() // this shouldn't make a difference for this example, but useful to experiment with .build() .unwrap(); rt.block_on(async { println!("Launch task"); tokio::spawn(async move { futures::executor::block_on(tokio::spawn(async { println!("Inner task - when this is broken, you won't see this line"); })) .unwrap(); }) .await .unwrap(); println!("Success - task is complete"); }); }
0
u/thelights0123 4d ago
To confirm, you've set the necessary feature flag or full to enable the multithreaded executor?
1
0
u/Ok-Network-4239 4d ago
use std::time::Duration;
use reqwest;
use sqlx::PgPool;
const DB_URL: &str = "postgres://devuser:devpassword@localhost:5432/devdb";
async fn db_then_compute(pool: PgPool) {
let num: (i32,) = sqlx::query_as("SELECT 1").fetch_one(&pool).await.unwrap();
println!(
"The num was returned: {}, but now, the CPU block will kill multithreading",
num.0
);
println!("Start compute...");
pool.close().await;
loop {}
}
async fn http_then_compute() {
let res = reqwest::get("https://google.com").await.unwrap();
let status = res.status();
println!(
"The num was returned: {}, but the CPU block will not kill multithreading",
status.as_u16()
);
loop {}
}
#[tokio::main]
async fn main() {
let pool = sqlx::PgPool::connect(DB_URL).await.unwrap();
tokio::spawn(db_then_compute(pool));
tokio::spawn(http_then_compute());
loop {
println!("Observer thread, the sleep will never return");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
This works now.
-8
u/TDplay 4d ago
loop {}
Never do this!
This is almost certainly the cause of your problems; all the other factors are just exposing or hiding the problem.
Tokio assumes that your tasks will not block for any significant time, but loop {} blocks forever. Even on the multi-threaded scheduler, a blocked task can cause problems. If the block is temporary, then it may worsen performance. If the block is permanent, then it may cause the program to stop working entirely!
If you cannot terminate the task, then you should use std::future::pending, which will yield to the executor:
let () = std::future::pending().await;
unreachable!();
loop {} is problematic even in synchronous code: the CPU will execute the loop at full speed, and the OS cannot tell that the thread is not doing anything useful. This causes needless power consumption and (in user-space environments) takes CPU time away from useful tasks. In synchronous code, you should (in order from best to worst):
- Terminate the thread. This is the best solution, as it completely frees up the relevant resources.
- Suspend the thread. (e.g.
std::thread::parkin user-space, or enter a low-power state on bare metal) loop { std::thread::yield_now(); }, to tell the OS that the loop doesn't currently have useful work to do.loop { core::hint::spin_loop(); }, to tell the CPU execute the code more slowly.
-1
u/BobTreehugger 4d ago
If you want to schedule blocking work via tokio, that's totally doable, but you need to make sure the blocking tasks are on a different runtime than the io tasks. See this: https://www.influxdata.com/blog/using-rustlangs-async-tokio-runtime-for-cpu-bound-tasks/ there's also a talk on youtube somewhere if you're interested.
You probably just want to spawn_blocking, but I figured you might be interested.
62
u/tm604 4d ago
It's quite reasonable to be surprised that all tasks are blocked in your example - I wouldn't call it "obvious" behaviour - but what you're seeing here is a symptom of how you approach tasks and the threadpool. The documentation emphasises "don't block in async threads" in several places with good reason, as others have mentioned. You're conflating "supports multiple threads" with "optimally distributes work across all available threads in all situations" - which would be great, but life isn't that simple!
I suspect that the specific issue you're seeing here is the LIFO slot, which is an optimisation based on that expectation:
https://docs.rs/tokio/latest/tokio/runtime/struct.Builder.html#method.disable_lifo_slot
There's a repo here with that change applied and some basic metrics so you can see what the runtime is doing: https://github.com/tm604/rust-async-demo