Skip to Content
Course content

149: Select! Macro for Racing Futures

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first encounter the select! macro, it's easy to assume it behaves like a switch statement for asynchronous events. You likely think: "I'll just put my futures inside the select! block, and whenever one finishes, the loop will restart and wait for them all again."

The Myth of the Automatic Pause

Here is the trap: if you define your futures inside the select! macro within a loop, you aren't pausing them. You are destroying them. Every time a different branch of the select! wins, the other futures are dropped. When the loop iterates and hits the select! again, those futures are recreated from scratch.

I've seen this bite a lot of engineers building network proxies. Imagine you're waiting for a TCP stream to provide data, but you also have a timeout timer running. If the timer wins, the loop restarts. If you created the stream.read() future inside that block, you just dropped the future that was potentially halfway through reading a packet. You've effectively reset your progress on that I/O operation.

// DON'T DO THIS in a loop
loop {
    tokio::select! {
        res = socket.read(&mut buf) => { 
            // Handle data 
        }
        _ = tokio::time::sleep(Duration::from_secs(5)) => {
            println!("Timeout! But I just dropped the read future!");
            // The next iteration creates a BRAND NEW read future.
        }
    }
}


Preserving State with Pinning and Fusing

To actually "race" futures without losing your place, you have to move the futures outside the loop. But there's a catch: select! requires futures to be Unpin (because it needs to poll them multiple times) and FusedFuture (so it knows when a future is completely finished and should no longer be polled).

The standard pattern I use is to create the futures first, pin_mut! them to the stack, and use fuse(). This tells Rust, "This future stays right here in memory, and if it returns Poll::Ready once, it's done for good."

Let's look at a real-world scenario: a request that needs to stay alive as long as a "heartbeat" signal is arriving, but should time out if the heartbeat stops.

use tokio::time::{sleep, Duration};
use futures::future::{FutureExt, fuse};
use tokio::pin;

async fn race_with_heartbeat() {
    let request_fut = async {
        sleep(Duration::from_secs(10)).await;
        "Request Finished!"
    }.fuse();

    let heartbeat_fut = async {
        sleep(Duration::from_secs(2)).await;
        "Heartbeat received"
    }.fuse();

    pin!(request_fut);
    pin!(heartbeat_fut);

    let mut heartbeat_count = 0;

    loop {
        tokio::select! {
            res = &mut request_fut => {
                println!("Success: {}", res);
                break;
            }
            _ = &mut heartbeat_fut => {
                heartbeat_count += 1;
                println!("Heartbeat {} received, still waiting for request...", heartbeat_count);
                // We need to "reset" the heartbeat timer for the next race
                heartbeat_fut.set(async {
                    sleep(Duration::from_secs(2)).await;
                    "Heartbeat received"
                }.fuse());
            }
        }
    }
}


Notice the &mut. We aren't giving the future to the macro; we're giving it a mutable reference. This allows the loop to continue polling the same request_fut across multiple iterations. I personally find that using pin! is much cleaner than Box::pin unless you actually need to move the future into another thread, which we aren't doing here.




📋 Practical Task

Build a Concurrent Shutdown Signal Handler

You are building a background worker that processes a stream of simulated jobs. However, the worker must be able to shut down immediately if a "shutdown" signal is received, regardless of whether a job is currently processing.

Requirements:

  • Create a process_job future that takes 3 seconds to complete.
  • Create a shutdown_signal future that resolves after 5 seconds (simulating a SIGINT).
  • Use a loop and tokio::select! to process jobs one by one.
  • Ensure that the process_job future is pinned and fused outside the loop so that if you were to add a secondary timer, you wouldn't lose progress.
  • If the shutdown_signal wins, print "Shutdown received, exiting..." and break the loop.
  • If a job finishes, print "Job completed!", and then replace the process_job future with a new one to start the next job.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.