Tech Wiki

TOPICSSERIES

[Rust Zero to Production 24] Structure Concurrent Work with Tokio Tasks and async/await

Spawning several futures with tokio::spawn is straightforward. The harder questions come afterward. What does each task own? Will every JoinHandle be awaited? Where do work failures and panics get classified? External resources also need a firm concurrency limit.

This article targets Rust 2024 with rustc and Cargo 1.98.1 and Tokio 1.53.1. It builds a small check runner to examine task startup, joining, result order, and Semaphore-based concurrency limits. There is no network access or timing measurement. select!, cancellation, and shutdown belong to the next article.

1. A Tokio task is an independently scheduled future

A Tokio task is a lightweight, non-blocking unit of execution. Pass a future to tokio::spawn, and the runtime schedules it alongside other tasks and returns a JoinHandle. The caller and spawned task may run concurrently as soon as spawn returns, but the contract does not specify which task finishes first.

1.1. The Send + 'static boundary of spawn

tokio::spawn draws the boundary in its signature:

pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
    F: Future + Send + 'static,
    F::Output: Send + 'static,

'static does not mean the task must live until the program exits. It means the spawned future cannot rely on short-lived local references borrowed from the calling function. The runtime may move the task to another thread, so both the future and its output also need Send.

The example gives each task an owned CheckPlan containing its check name and planned result. Inside the loop, async move takes ownership of the plan and cloned shared handles.

for (index, plan) in plans.into_iter().enumerate() {
    let barrier = Arc::clone(&barrier);
    let semaphore = Arc::clone(&semaphore);
    let started_tx = started_tx.clone();
    let active = Arc::clone(&active);
    let peak = Arc::clone(&peak);
    let join_name = plan.name.clone();
    let handle = tokio::spawn(async move {
        started_tx.send(index).await.expect("coordinator is alive");
        barrier.wait().await;
        let _permit = semaphore.acquire_owned().await.expect("semaphore is open");
        // ...
    });
    handles.push((index, join_name, handle));
}

Giving the task an owned String rather than a borrowed &str is not merely a syntactic workaround. It separates the task lifetime from the caller's stack frame at the API boundary.

1.2. Spawning does not guarantee start or completion order

Each example task announces its start through a bounded mpsc channel and then waits at a Barrier. The coordinator releases that barrier only after receiving every start message. This handshake proves that several tasks reached the rendezvous before release without measuring elapsed time or guessing what the scheduler did.

The Barrier remains incomplete until every participating task reaches it. This check does not establish a particular start order or completion order. The channel carries no work result; it is only a deterministic test handshake.

2. A JoinHandle owns the right to await task termination

Awaiting the JoinHandle<T> returned by tokio::spawn waits for the task to terminate and yields its output T. The return type can be nested: an outer Result reports the join outcome, while an inner value can separately model success or failure in the task's actual work.

The example maps a work failure to CheckStatus::CheckFailed and a task panic to CheckStatus::Panicked. It does not deliberately create cancellation.

2.1. Join every handle even when one task fails

Structured concurrent work should not return at the first error if already-spawned tasks remain. Doing so leaves the cleanup boundary unclear. The runner traverses the complete handle list and awaits every task.

for (index, name, handle) in handles {
    joined_count += 1;
    match handle.await {
        Ok((returned_index, thread_id, result)) => {
            worker_threads.insert(thread_id);
            indexed_results.push((returned_index, result));
        }
        Err(error) if error.is_panic() => {
            indexed_results.push((
                index,
                CheckResult {
                    name,
                    status: CheckStatus::Panicked,
                },
            ));
        }
        Err(error) => panic!("unexpected cancelled task: {error}"),
    }
}

Tokio catches a panic in a spawned task and reports it as a JoinError. is_panic() classifies a panic, while is_cancelled() identifies cancellation. Because this example never aborts a task, it treats a non-panic JoinError as outside its scope. A service needs an explicit cancellation and shutdown policy before it can handle that branch correctly.

2.2. Report order is not completion order

Keeping handles in input order does not mean tasks finished in that order. The runner stores every join result with its input index. After all handles have been awaited, it sorts by index to build the report.

indexed_results.sort_by_key(|(index, _)| *index);

Ok(RunReport {
    started_before_release,
    worker_threads,
    results: indexed_results
        .into_iter()
        .map(|(_, result)| result)
        .collect(),
    joined_count,
    peak_active: peak.load(Ordering::SeqCst),
})

The report's deterministic input order is an order deliberately restored by the API, not an observation of scheduler completion order. Production code should not describe those as the same thing.

3. Use a Semaphore to bound active work

Spawning one task per input does not justify letting every task enter an external resource at once. A Semaphore provides asynchronous permit acquisition. Unlike a mutex, it can admit multiple callers concurrently, up to the configured count.

After crossing the barrier, each example task acquires an owned permit. It increments an active counter on entry to the permitted section and decrements it when a guard is dropped.

let _permit = semaphore.acquire_owned().await.expect("semaphore is open");
let now_active = active.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(now_active, Ordering::SeqCst);
let _active_guard = ActiveGuard(active);
tokio::task::yield_now().await;

With a limit of 2, the observed peak_active never exceeds 2. yield_now().await gives another task a chance to run, but it does not guarantee which task runs next or fair alternation. The test checks the permit ceiling, not scheduling order.

3.1. Reject an invalid limit before spawning tasks

Interpreting Semaphore::new(0) as a waiting policy can leave every task waiting forever for a permit. The runner checks limit == 0 first and returns a typed error, even when its input is empty.

pub async fn run_checks(
    plans: Vec<CheckPlan>,
    limit: usize,
) -> Result<RunReport, RunChecksError> {
    if limit == 0 {
        return Err(RunChecksError::ZeroLimit);
    }

    let task_count = plans.len();
    // ...
}

Validation order matters here. The function stops at the configuration boundary instead of creating a channel, semaphore, or tasks before discovering the error.

4. Concurrency and parallelism are different

Concurrency means structuring several operations so they can make progress during the same period. Parallelism means computations actually run at the same instant on different execution resources.

The example's concurrent-start test uses a current_thread runtime. Three tasks can all reach the barrier and coexist in an in-progress state on one OS thread, which establishes concurrency. A one-thread test cannot establish parallel execution.

Tokio's multi-thread runtime may execute tasks across several worker threads. That is a capability, not a guarantee about a given task's thread placement, start order, or completion order. A spawned task may run on the current thread or be sent to another one.

4.1. Adding .await does not make work parallel

An .await is a point where the current future can return control to the runtime when it cannot proceed. It is not an instruction to distribute CPU work across cores. Long synchronous CPU work or a blocking call inside an async task can occupy a runtime thread without yielding.

This runner contains no network I/O, blocking work, or spawn_blocking. Its output says nothing about I/O throughput or CPU parallel performance. The example exists to check task lifecycle and join boundaries.

5. Verify task boundaries with deterministic tests

Run these commands from the project directory.

cd examples/article-24-tokio-tasks-async-await
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

On Rust 1.98.1, Cargo 1.98.1, and Tokio 1.53.1, all five commands should exit with code 0. The suite contains 7 integration tests. Program output is fixed in input order.

joined=3
alpha: passed
beta: check failed: invalid input
gamma: passed

5.1. Contracts established by the tests

The concurrent-start test checks that three tasks on the current-thread runtime reach the barrier before the coordinator releases them. The join test verifies that all three handles are awaited when success, work failure, and panic are mixed. The input-order test checks that the report restores input order, and the semaphore test checks that peak admission stays within the limit. Two more tests reject a zero limit as RunChecksError::ZeroLimit for both empty and non-empty plans. Together with the binary-output test, that makes 7 tests.

The results do not measure scheduler fairness, task completion order, multi-thread parallel speed, cleanup after cancellation, or network backpressure. In particular, mpsc exists here to observe the startup rendezvous deterministically. It is not an example of a long-lived worker design or backpressure policy.

Safe Tokio task structure starts with lifetime boundaries, not the number of calls to spawn. Give tasks owned data, await every created JoinHandle at a defined cleanup point, and place an explicit limit before access to an external resource. Keep report order separate from actual completion order. Once cancellation and shutdown enter the design, they require their own policy; that is the next article's boundary.

Full source code

The complete runnable source for this article is available in the Chapter 24 project on GitHub.

Sources


Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.