Skip to content

feat(tokio/macros): add biased mode to join! and try_join! #7307

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

jlizen
Copy link
Member

@jlizen jlizen commented May 5, 2025

Closes: #7304

Adding a biased; mode to the join! and try_join! macros that always polls the wrapped futures in their order of declaration, rather than rotating each poll.

This parallels the select! API (though is simpler under the hood, since these macros rotate rather than randomize which branch is polled first).

Motivation

This is useful when the contained futures interact in a way where polling order is significant.

My specific case is that I want to join! or try_join! two separate listener futures in an AWS Lambda. Lambdas receive one request at a time. One of my listeners is my primary function handler, which is higher priority. The second is an extension that does post-request 'telemetry flushing'. So, there is never a need for the second future to be polled first.

Here is a rough example: ref.

Solution

I added an optional biased; token to each macro's entrypoint. This resolves to a literal variable, $rotate_poll_order, as either true or false (with no biased; declaration resulting in true).

Then, inside the generated code, we only increment the skip counter used to rotate polling, if $rotate_poll_order is true.

I initially changed the skip order counter from u32 to Option<u32>, but that would have resulted in a bit muddier code. Adding an if clause around the single line incrementing the skip counter, was more readable and kept the footprint of this change more contained.

The performance impact of this change on cases that do NOT use the biased mode is:

  • one additional boolean stored on the stack
  • one extra branch every poll (checking the boolean), which branch prediction should handle cleanly

This perf impact seems very marginal, but we could always make a more complicated macro that generates distinct futures based on bias mode. Probably the external API should stay the same as select! though (ie, just add the biased; token to the existing macros).

@jlizen
Copy link
Member Author

jlizen commented May 5, 2025

Here's the PR related to our discussion yesterday @maminrayej - thanks for the guidance!

Comment on lines 209 to 210
let mut skip_next_time: u32 = 0;
let rotate_skip = $rotate_poll_order;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to avoid having this variable at all when using biased. I don't think it can be optimized out because it gets stored as a field in the closure type.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My newest commit removes this variable, along with skip_next_time, from the biased branch's poll_fn() (so it will now be smaller than the unbiased equivalent).

It's a bit verbose... since each branch of match $rotate_poll_order {} needs to generate its own unnameable poll function. I initially was pulling out common logic into a closure, but I felt it was ultimately less readable.

Glad to take another stab at simplifying if you have any feedback.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I don't think we want the match. It duplicates the entire logic, which isn't nice. One option could be to define two helper types:

#[derive(Default)]
pub struct Rotater<const COUNT: usize> {
    next: usize,
}

impl<const COUNT: usize> Rotater<COUNT> {
    #[inline]
    pub fn num_skip(&mut self) -> usize {
        let mut num_skip = self.next;
        self.next += 1;
        if self.next == COUNT {
            self.next = 0;
        }
        num_skip
    }
}

#[derive(Default)]
pub struct BiasedRotater {}

impl BiasedRotater {
    #[inline]
    pub fn num_skip(&mut self) -> usize {
        0
    }
}

and then have the macro select which type to use:

( biased; $($e:expr),+ $(,)?) => {
    $crate::join!(@{ rotator=$crate::macros::support::BiasedRotator; () (0) } $($e,)*)
};

( $($e:expr),+ $(,)?) => {
    $crate::join!(@{ rotator=$crate::macros::support::Rotator; () (0) } $($e,)*)
};

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that's clever. Thanks for the advice! Will take a crack at it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it working, thanks!

@jlizen
Copy link
Member Author

jlizen commented May 6, 2025

Appreciate the eyes, Darksonn@ - I'll take a stab at implementing the requested feedback.

@jlizen jlizen requested a review from Darksonn May 7, 2025 21:04
@jlizen
Copy link
Member Author

jlizen commented May 8, 2025

@Darksonn latest refactor is up.

I noticed our existing try_join! poll sequencing test was actually using join!, I assume by accident. Updated that as well.

@@ -28,3 +28,34 @@ cfg_macros! {
pub use std::future::{Future, IntoFuture};
pub use std::pin::Pin;
pub use std::task::{Context, Poll};

#[doc(hidden)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire module is #[doc(hidden)] already so you don't need this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, will update.


#[doc(hidden)]
#[derive(Default, Debug)]
pub struct Rotator<const COUNT: u32> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make it clear that these types are here for use with the join! macro? In fact, maybe move them to src/macros/join.rs and re-export them here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, makes sense. Will do.

Comment on lines 137 to 141
/// let res = tokio::try_join!(
/// //do_stuff_async() will always be polled first when woken
/// biased;
/// do_stuff_async(),
/// more_async_work());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's confusing that you have a comment inside the macro before biased;. Also, please format this code properly by moving the ); to its own line.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do on both counts. Sorry, was overly reliant on rustfmt here, will manually clean up.

Comment on lines 217 to 225
loop {
$(
if skip == 0 {
if to_run == 0 {
// Every future has been polled
break;
}
to_run -= 1;
$(
if skip == 0 {
if to_run == 0 {
// Every future has been polled
break;
}
to_run -= 1;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the indentation changed throughout the entire macro?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will tweak, was overly reliant on rustfmt which has trouble with macros I guess.

};

tokio::join!(
tokio::try_join!(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was a mistake in existing test, i think

…emove comment inside macro invocation in example
@jlizen
Copy link
Member Author

jlizen commented May 9, 2025

@Darksonn latest changes are up per your comments, thanks for your time on this PR review.

@jlizen jlizen requested a review from Darksonn May 9, 2025 18:33
Comment on lines 6 to 9
// Used with `join!` and `try_join!`
pub use crate::macros::join::Rotator;
// Used with `join!` and `try_join!`
pub use crate::macros::join::BiasedRotator;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Used with `join!` and `try_join!`
pub use crate::macros::join::Rotator;
// Used with `join!` and `try_join!`
pub use crate::macros::join::BiasedRotator;
// Used with `join!` and `try_join!`
pub use crate::macros::join::{BiasedRotator, Rotator};

Comment on lines 31 to 33


}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary empty lines.

Comment on lines 228 to 230
#[derive(Default, Debug)]
/// Rotates by one every [`Self::num_skip`] call up to COUNT - 1.
pub struct Rotator<const COUNT: u32> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#[derive(Default, Debug)]
/// Rotates by one every [`Self::num_skip`] call up to COUNT - 1.
pub struct Rotator<const COUNT: u32> {
/// Rotates by one every [`Self::num_skip`] call up to COUNT - 1.
#[derive(Default, Debug)]
pub struct Rotator<const COUNT: u32> {

Comment on lines +46 to +52
/// But there is an important caveat to this mode. It becomes your responsibility
/// to ensure that the polling order of your futures is fair. If for example you
/// are joining a stream and a shutdown future, and the stream has a
/// huge volume of messages that takes a long time to finish processing per poll, you should
/// place the shutdown future earlier in the `join!` list to ensure that it is
/// always polled, and will not be delayed due to the stream future taking a long time to return
/// `Poll::Pending`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that a shutdown future is an amazing example here. That makes sense for select!, but you wouldn't really have a shutdown future in join!. Not sure what a better example would be, though.

Copy link
Member Author

@jlizen jlizen May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is a real-world example usage involving a shutdown signal: https://github.com/awslabs/aws-lambda-rust-runtime/blob/main/lambda-runtime/src/lib.rs#L227

The distinction here is that the other task is a server future, not a stream future, meaning it will generally keep running and generating new request futures. So we need to continue driving it. Even if the shutdown future will only ever come up once and then resolve, we still need to check it first every time.

I guess technically the server future will generally be dispatching work to read from sockets rather than processing the messages directly (ie not long polls as a concern), but anyway you want to shut down before dispatching that work.

Anyway I'll tweak wording from stream -> server and finesse it a bit more.

Copy link
Member Author

@jlizen jlizen May 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also renaming the section Fairness -> Poll Ordering, it's not really about fairness anymore in the example given.

Copy link
Member Author

@jlizen jlizen May 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I changed it back. My initial rework was basically restating that deterministic ordering is useful. But it's important for readers to consider the case that one future might take a long time to poll and starve the other, that was the point of the fairness discussion.

Based on the above lambda runtime link, I do think there are cases where you would be joining a shutdown future, so it's probably still relevant? Anyway I think it does a good job of illustrating the fairness risk even if the specific use case isn't the most universal?

Glad to keep thinking about it though if not sold and try to come up with another example.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-tokio Area: The main tokio crate M-macros Module: macros in the main Tokio crate
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Feature request: Add biased support to join! and try_join!
2 participants