Skip to Content
Course content

46: Structured Concurrency Patterns in Rust

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

A few years ago, I was reviewing a PR for a colleague who was building a high-throughput telemetry aggregator. He had used tokio::spawn to fire off several asynchronous requests to different backend services for every single incoming packet. On the surface, it worked beautifully—until it didn't. We noticed that during periods of high network latency, the memory usage of the service would climb steadily until the OOM killer stepped in. The problem? He was using "fire-and-forget" tasks. When a client request timed out and the main handler returned an error, the spawned tasks were still floating around in the background, blindly waiting for responses from services that were already failing. They had no tie to the lifecycle of the request that created them.

This is exactly why we talk about Structured Concurrency. In simple terms, it's the philosophy that if a piece of code starts a concurrent operation, it must also be responsible for ensuring that operation completes—or is explicitly cancelled—before the code itself finishes. It turns concurrency from a "wild west" of orphaned tasks into a predictable tree of ownership.

Moving Beyond the Chaos of tokio::spawn

Most people starting with Rust async default to tokio::spawn because it's easy. But tokio::spawn gives the task a 'static lifetime, meaning it's detached from the current scope. Once you spawn it, you've essentially handed that task over to the runtime's global scheduler. If the function that spawned the task returns, the task keeps running. While this is great for a background logger, it's a nightmare for request-response cycles.

To implement structured concurrency, we need a way to group tasks so that their lifetimes are bound together. If one task fails, or if the parent is cancelled, we want the children to die with it. I usually recommend moving away from raw spawns and toward patterns where you explicitly track your handles or use a collection that manages the group.

Managing Lifecycles with JoinSet

If you're using Tokio, JoinSet is your best friend for this. Unlike a Vec<JoinHandle>, which requires you to manually iterate and await every single handle (often leading to awkward join_all calls that can hang if one task is stuck), a JoinSet lets you manage a collection of tasks as a single unit. When the JoinSet is dropped, all tasks currently inside it are automatically aborted.

use tokio::task::JoinSet;
use tokio::time::{sleep, Duration};

async fn fetch_data(id: u32) -> String {
    sleep(Duration::from_secs(id as u64)).await;
    format!("Data from {}", id)
}

async fn run_aggregator() {
    let mut set = JoinSet::new();

    for i in 1..=5 {
        set.spawn(fetch_data(i));
    }

    // We can pull results as they finish, regardless of start order
    while let Some(res) = set.join_next().await {
        match res {
            Ok(val) => println!("Received: {}", val),
            Err(e) => eprintln!("Task panicked: {:?}", e),
        }
    }
}

The magic here is the implicit cleanup. If run_aggregator were wrapped in a tokio::time::timeout and that timeout expired, the JoinSet would be dropped, and every single pending fetch_data task would be cancelled immediately. No leaked connections, no zombie tasks.

Propagating Failure and Cancellation

Structured concurrency isn't just about cleaning up; it's about how we handle failure. In a truly structured system, if one critical child task fails, you often want to cancel all other siblings immediately because the overall operation is now doomed. This "fail-fast" behavior prevents the system from wasting resources on a request that can no longer be satisfied.

You can achieve this by combining JoinSet with a select! loop or by using a CancellationToken from the tokio_util crate. I've found that using a cancellation token is the most robust way to signal to deeply nested async tasks that they should stop what they're doing and shut down gracefully. It allows you to avoid the "hard kill" of a drop and instead let a task close a file handle or send a "goodbye" packet before exiting.




📋 Practical Task

Build a Concurrent API Guard with Global Timeout

Your task is to implement a "Guard" pattern for a set of simulated API calls. You need to create a function that fetches data from three different sources concurrently, but with a strict requirement: if any single request fails (returns an Err) or if the total operation takes longer than 2 seconds, all other pending requests must be cancelled immediately.

Requirements:

  • Use tokio::task::JoinSet to manage the concurrent requests.
  • Simulate three different API calls using tokio::time::sleep. Make one of them take 3 seconds (which should trigger the timeout) and another to randomly return a Result::Err.
  • Wrap the entire execution in a tokio::time::timeout.
  • Print a message when a task is successfully completed and a specific "Cancellation Triggered" message if the set is dropped due to a timeout or a failure in a sibling task.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.