Why does `tokio::select!` evaluate expressions for disabled branches?

It would be so convenient to be able to write code like this:

async fn f1() {
    let t = Option::<u32>::None;
    tokio::select! {
        a = f(t.unwrap()), if t.is_some() => {
            println!("{a}");
        }
        b = g(), if t.is_none() => {
            println!("{b}");
        }
    }
}

But it'll panic because f(t.unwrap()) is evaluated even if t.is_none(). Is there a reason for this behavior?

1 Like

You may want to review the way tokio::select does its job. In particular:

The macro aggregates all <async expression> expressions and runs them concurrently on the current task. Once the first expression completes with a value that matches its <pattern>, the select! macro returns the result of evaluating the completed branch’s <handler> expression.

Additionally, each branch may include an optional if precondition. If the precondition returns false, then the branch is disabled. The provided <async expression> is still evaluated but the resulting future is never polled. This capability is useful when using select! within a loop.

In your case a simple match on the Option<u32> itself is perfectly sufficient.

I've read the documentation. I know how it works. I want to know why it behaves that way. It could just skip the branches when aggregating the futures, right?

In other words, you're asking, why it first evaluates the futures then checks the corresponding guards, not the other way around?

Yes

Futures must be created and polled to start doing their work. Otherwise nothing will happen. If select! didn't create and poll all futures at once, it would be serial like if else.

Unlike JS where a Promise represents only a result of work already happening somewhere, Future is the work to be done.

If you want a serial either/or behavior:

match t {
    Some(t) => f(t).await,
    None => g().await,
}

But what prevents this:

  • All preconditions are evaluated, their results stored.
  • The branches with a precondition returning true have their async expressions evaluated; all others are removed immediately without ever evaluating.
  • Polling processes as it is now.

tokio doesn't do this, but why? The only reason I can think of is that preconditions should be evaluated when the future resolves, so that, i.e., in this playground changes to flag done during sleep are propagated to the select, but they actually doesn't seem to - uncommenting the millisecond sleep is enough for the change to flag to be ignored by select.

2 Likes

For this simple example, yes, there are other ways. But I've run into more complicated examples in real world where not evaluating the futures of disabled branches would've made the life much easier.

edit: sorry, I was wrong about select!'s order of operations.

1 Like

Again, the question was "why always run all futures, without checking the preconditions beforehand?", not "why evaluate futures before running?"

But anyway...

That seems to be the key point here.

EDIT: Now I'm thoroughly confused. I thought the guard let you race the remaining branches?

For the three parts:

  1. Evaluate the Futures
  2. Poll the Futures
  3. Execute a branch arm

^ I thought the confusion was between steps 2-3, now I see it's about 1-2.

I guess I'm learning more about select! by just trying to add my 2 cents to a discussion I didn't fully understand.


ORIGINAL wrong(?) post below:

Imagining the alternate reality version of select! where the conditions are evaluated before starting the futures:

  • You can't use the result of the future (by definition, stated in earlier post)
  • The output binding for the future's result in each branch could reasonably change from a pattern to a identifier (since we're removing filtering of the output)
  • At that point, it's more like "racing" a Vec of futures, where the Vec was filtered by the match arms before starting the race.

That is still an operation that may be useful, but it significantly changes the select operation into something quite different.

The tokio::select! macro as-is let's you check and ignore an output if it doesn't match your criteria (continuing to select among the remaining branches).
The alternate reality version seems less useful, likely implemented using different primitives (I would say steam unordered, but I'm not really familiar)

I want to add a small note to this. You can write pattern matches in select macros, e.g. Some(x) = ch.recv(). This of course can only be determined to match after the future has polled to completion, so conceptually it makes sense for if guards to work the same way.

For OP, there are combinators that help with avoiding the unwrap issue, but it's a matter of taste whether they are cleaner than just making an async block.

The if guard inside select! is designed to operate on the result of the future.

That isn't what the documentation says.

The complete lifecycle of a select! expression is as follows:

1. Evaluate all provided <precondition> expressions. If the precondition returns false, disable the branch for the remainder of the current call to select!. Re-entering select! due to a loop clears the “disabled” state.

Evaluating the if branches is the first thing that happens.

1 Like

We could probably avoid evaluating the expression. But I'm not sure if we can change it as it might be breaking for some users.

1 Like

Isn't constructing the futures the first thing that happens?