Introduction
The tokio::select! macro races multiple async branches and cancels all non-winning branches when one completes. This cancellation is immediate and silent: the cancelled future is simply dropped without running any cleanup code. When branches perform important side effects (writing to a database, sending a message, updating state), cancellation causes incomplete operations and data inconsistencies.
Symptoms
- Database writes partially complete when select! chooses another branch
- Messages sent to channels are lost when the sender branch is cancelled
- File handles not properly closed when async operation is cancelled
Dropimpl not called on types held in cancelled branches- Data inconsistencies after select! races with timeout
Debug cancelled branches:
``rust
tokio::select! {
result = async_op() => {
println!("async_op completed");
}
_ = tokio::time::sleep(Duration::from_secs(5)) => {
println!("timeout won - async_op was CANCELLED");
// async_op is dropped here, any in-progress work is lost
}
}
Common Causes
- Select! cancels branch that was mid-operation when another branch wins
- Timeout branch wins, cancelling important cleanup or finalization code
- Multiple select! in sequence compound cancellation effects
- Biased vs unbiased select changing which branch wins consistently
- JoinHandle from cancelled task not awaited for graceful shutdown
Step-by-Step Fix
- 1.Use tokio::join! instead of select! when all branches must complete:
- 2.```rust
- 3.// WRONG - select! cancels the slower branch
- 4.tokio::select! {
- 5.user = fetch_user(id) => { /* only user fetched */ }
- 6.prefs = fetch_prefs(id) => { /* only prefs fetched */ }
- 7.}
// CORRECT - join! waits for both let (user, prefs) = tokio::join!( fetch_user(id), fetch_prefs(id), ); // Both completed, no cancellation ```
- 1.Make branches cancellation-safe with AbortHandle:
- 2.```rust
- 3.use tokio::task::AbortHandle;
async fn operation_with_cleanup() { let handle = tokio::spawn(async { // Important work that should complete write_to_database().await; send_notification().await; "completed" });
let abort_handle = handle.abort_handle();
tokio::select! { result = handle => { println!("Operation finished: {:?}", result); } _ = tokio::time::sleep(Duration::from_secs(30)) => { println!("Timeout - gracefully aborting"); abort_handle.abort(); // Wait for the task to finish cleanup let _ = handle.await; } } } ```
- 1.Use biased select for predictable branch ordering:
- 2.```rust
- 3.// Unbiased (default) - random branch wins on tie
- 4.tokio::select! {
- 5.msg = rx.recv() => { println!("Got message"); }
- 6._ = ticker.tick() => { println!("Tick"); }
- 7.}
// Biased - first branch has priority, predictable order tokio::select! { biased; // <-- This line makes it biased msg = rx.recv() => { println!("Got message"); } // Checked first _ = ticker.tick() => { println!("Tick"); } }
// Use biased when one branch is more important than others // Use unbiased (default) for fair racing ```
- 1.Handle partial completion with explicit state tracking:
- 2.```rust
- 3.use std::sync::atomic::{AtomicBool, Ordering};
async fn process_with_timeout() -> Result<(), Error> { let db_written = Arc::new(AtomicBool::new(false)); let db_written_clone = db_written.clone();
tokio::select! { result = async { write_to_db().await?; db_written_clone.store(true, Ordering::SeqCst); send_email().await?; Ok::<_, Error>(()) } => result,
_ = tokio::time::sleep(Duration::from_secs(10)) => { // Timeout - check what was completed if db_written.load(Ordering::SeqCst) { // DB write completed but email did not // Schedule email retry tokio::spawn(async { retry_email().await; }); } Err(Error::Timeout) } } } ```
- 1.**Guard critical sections from cancellation":
- 2.```rust
- 3.// Wrap critical operations so they complete even if select cancels
- 4.async fn critical_write(data: &str) -> Result<(), Error> {
- 5.// This runs in its own task and cannot be cancelled by select!
- 6.let handle = tokio::spawn(async move {
- 7.database::write(data).await
- 8.});
// Wait for the task - do NOT use select! here handle.await.map_err(|_| Error::TaskPanic)? }
// Only use select! for the decision of which path to take, // not for cancelling critical operations tokio::select! { _ = critical_write("important data") => { println!("Write completed"); } _ = shutdown_signal() => { println!("Shutdown requested"); // The write task continues running in background } } ```
Prevention
- Prefer
tokio::join!when all branches must complete - Use
tokio::select!only when you genuinely want to cancel the loser - Mark important cleanup code with
tokio::spawnto make it cancellation-resistant - Use
biasedselect when branch priority matters - Track partial completion state for operations that can be interrupted
- Add logging in
Dropimplementations to detect unexpected cancellation