The following code (playground link) has some unusual behavior in Rust 1.82.0 and Tokio 1.41.0. After receiving 64 values from the channel, the main select loop gets stuck waiting for the 65th. The select future appears to always poll the Ready future after that point. The Tokio documentation for select!states
By default, select! randomly picks a branch to check first. This provides some level of fairness when calling select! in a loop with branches that are always ready.
Of course this example is contrived, but why does the Ready future prevent progress in such a strange way?
After 64 send() operations, the channel will start returning a Pending future rather than a Ready future right away on the next send(), even if it is not full (Rust Playground):
use futures::FutureExt;
#[tokio::main]
async fn main() {
let (send, mut recv) = tokio::sync::mpsc::channel(1);
for i in 0.. {
let res = send.send(i).now_or_never();
println!("send: {res:?}");
if res.is_some() {
println!("recv: {:?}", recv.recv().await);
} else {
break;
}
}
}
(Presumably this has something to do with the underlying implementation.) Then, since the select! sees a ready future (future::ready(())) and two pending futures (recv.recv() and send.send(count)), it will always pick the ready future, even if the pending send() would have become ready very soon. I'm not aware of any good fix for this, if you want to both send() values and poll almost-always-ready futures in the same select! statement.
future::ready() is not useful to poll. "Almost always ready" futures are not the same as "always ready" futures.
Replace the always-ready branch with one that is ready with a probability slightly less than 1 and the channel will continue making progress, albeit slowly.
This is due to Tokio's automatic cooperative task yielding feature. After receiving 64 values, Tokio decides your task has been running for too long and attempts to force it to yield by making all Tokio futures start returning Pending until you yield.
However, in this case that just means that the recv or send branches always return Pending, so they never trigger, and the ready branch triggers every time, resulting in an infinite loop that blocks the thread.
Thanks! The code where I originally noticed this was polling an empty JoinSet instead of Ready, which immediately returns Ready(None). Is that exempt from this task yielding feature?
It's possible that it's missing. You can submit a bug report and we will fix it.
Though you should still make sure to add an , if join_set.is_empty() to just not check the branch when its empty. That will be much more efficient than checking constantly in a loop.