This test is useful because it allows us to separate the allocation of the future from the running, to see which is the cost. What does the graph look like in that case?
async fn run_test(n: isize) {
let fut = chain_many(n);
let start = Instant::now();
fut.await;
println!("{}", (Instant::now() - start).as_micros());
}
And no, no work is done until .await
is called. Rust futures are "lazy" in that they don't do any work until you start polling (.await
) them, unlike JS's (which act like threads; start running when created, and await
ing is just waiting for it to finish).
I suspect that this is the actual cause of the discrepancy. tokio::task::yield_now().await
will always jump back to the tokio event loop for a turn. You say that then
causes a yield to the event loop in JS, but I don't know that for sure, and it's quite possible that the JIT sees through your shenanigans and is making (parts of) your test straightline and not yield.
Calling and immediately await
ing a no-await
async function in JS is (allowed to be) implemented as straightline code that doesn't yield to the executor. My gut says the JS runtime is "cheating" in a similar way here. But even if it isn't, do note that JS executors have been heavily tuned to JS promises' semantics, and tokio is still quite young in comparison. The quadratic blowup could be a tokio issue, not a Rust issue. I'm not sure -- it sure seems like this should be linear -- but I'm more willing to blame Tokio or the allocator than that this is a fundamental limitation to Rust's Future
semantics.
Actually, no, you're fully right. Because of the boxed indirection at every step, the optimizer can't strip out the calls to each subfuture's Future::poll
method.
In the common case, you aren't boxing each future, so the step of recursing down the poll
tree is optimized to just be a jump table. But every indirection point inhibits this.
A more fair comparison is to actually do what the JS runtime is doing (before JIT shenanigans). Instead of encapsulating another boxed future, spawn it on the executor directly -- Rust gives you the choice!
Going back to the original recursion:
async fn recurse(n: isize) -> isize {
tokio::task::yield_now().await;
if n > 0 {
1 + tokio::spawn(recurse(n - 1)).await;
} else {
0
}
}
It's quite unfortunate that this footgun exists, and that there's no exexutor-agnostic way of not stumbling into the footgun, but this should be linear instead of quadratic.
This is exactly due to the eager/lazy semantics. The solution is to be eager and spawn the recursion onto the executor rather than encapsulating it as a BoxFuture
member.
EDIT: ew that's an ugly error as written. You should get my semantic point, though: use tokio::spawn
for deeply nested boxed futures.
Execution
Close
Standard Error
Compiling playground v0.0.1 (/playground)
warning: unused import: `BoxFuture`
--> src/main.rs:1:23
|
1 | use futures::future::{BoxFuture, FutureExt};
| ^^^^^^^^^
|
= note: `#[warn(unused_imports)]` on by default
error[E0391]: cycle detected when computing type of `recurse::{opaque#0}`
--> src/main.rs:4:31
|
4 | async fn recurse(n: isize) -> isize {
| ^^^^^
|
note: ...which requires borrow-checking `recurse`...
--> src/main.rs:4:1
|
4 | async fn recurse(n: isize) -> isize {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: ...which requires processing `recurse`...
--> src/main.rs:4:1
|
4 | async fn recurse(n: isize) -> isize {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: ...which requires processing MIR for `recurse`...
--> src/main.rs:4:1
|
4 | async fn recurse(n: isize) -> isize {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: ...which requires unsafety-checking `recurse`...
--> src/main.rs:4:1
|
4 | async fn recurse(n: isize) -> isize {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: ...which requires building MIR for `recurse`...
--> src/main.rs:4:1
|
4 | async fn recurse(n: isize) -> isize {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
note: ...which requires type-checking `recurse`...
--> src/main.rs:4:1
|
4 | async fn recurse(n: isize) -> isize {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
= note: ...which requires evaluating trait selection obligation `impl futures::Future: std::marker::Send`...
= note: ...which again requires computing type of `recurse::{opaque#0}`, completing the cycle
note: cycle used when checking item types in top-level module
Here's a version that works:
fn recurse(n: isize) -> BoxFuture<'static, isize> {
async move {
tokio::task::yield_now().await;
if n > 0 {
1 + tokio::spawn(recurse(n - 1)).await.unwrap()
} else {
0
}
}
.boxed()
}