Skip to main content

Command Palette

Search for a command to run...

Understanding Async Concurrency vs Multithreading in Rust with Tokio

Updated
5 min read

Introduction

When working with async Rust, developers often confuse concurrency with parallelism/multithreading. This post demonstrates the crucial difference using Tokio, showing how tasks can run concurrently on a single thread versus being distributed across multiple threads.

Source Code: https://github.com/Ashwin-3cS/concurrency-multithreading-tokio

Table of Contents

  1. Core Concepts

  2. Running the Examples

  3. Code Walkthrough: Multithreading with tokio::spawn

  4. Understanding the Runtime

  5. Concurrency Without Spawning

  6. Key Takeaways

Core Concepts

Before diving into the code, let's clarify these terms:

  • Concurrency: Multiple tasks making progress, but not necessarily at the same instant. Tasks can yield control to each other.

  • Parallelism/Multithreading: Multiple tasks executing simultaneously on different CPU cores/threads.

  • tokio::spawn: Creates a new task that can be scheduled on any available thread in the runtime.

  • async/await: Enables cooperative multitasking where tasks can pause and resume.

Running the Examples

Clone the repository and run the examples:

# Clone the repository
git clone https://github.com/Ashwin-3cS/concurrency-multithreading-tokio.git
cd concurrency-multithreading-tokio

# Install dependencies
cargo build

# Run the multithreading example (with spawn)
cargo run --example multithreaded_with_spawn

# Run the concurrent example (without spawn) 
cargo run --example concurrent_without_spawn

# Run the main example (defaults to multithreading demo)
cargo run

# Run with detailed thread information
RUST_LOG=debug cargo run --example multithreaded_with_spawn

Code Walkthrough: Multithreading with tokio::spawn

Let's examine the main code that demonstrates multithreading:

use tokio::time::{Duration, sleep};
use std::thread;

#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() {
    let task_1 = tokio::task::spawn(async { 
        println!("task ONE started on {:?}", thread::current().id());
        sleep(Duration::from_secs(2)).await;
        println!("task ONE finished on {:?}", thread::current().id());
    });

    let t2 = tokio::task::spawn(async {
        println!("task TWO started on {:?}", thread::current().id());
        for i in 1..=5 {
            println!("task TWO step {} on {:?}", i, thread::current().id());
            sleep(Duration::from_millis(500)).await;
        }
        println!("task TWO finished on {:?}", thread::current().id());
    });

    let _ = tokio::join!(task_1, t2);
}

Breaking Down Each Part

1. Runtime Configuration

#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
  • flavor = "multi_thread": Configures Tokio to use a multi-threaded runtime

  • worker_threads = 2: Creates exactly 2 worker threads to execute tasks

  • This sets up a thread pool where spawned tasks can be distributed

2. Task Spawning

let task_1 = tokio::task::spawn(async { 
    // task code
});
  • tokio::task::spawn: Submits the async block to the Tokio runtime

  • The runtime decides which thread will execute this task

  • Returns a JoinHandle that can be awaited to get the task's result

3. Thread ID Tracking

println!("task ONE started on {:?}", thread::current().id());
  • thread::current().id(): Gets the OS thread ID where the code is currently executing

  • This helps visualize which thread is running each task

  • You'll likely see different thread IDs for different tasks

4. Async Sleep

sleep(Duration::from_secs(2)).await;
  • Why .await?: The await keyword yields control back to the runtime

  • While this task sleeps, the thread can execute other tasks

  • This is non-blocking - the thread isn't idle, it can do other work

5. Task Coordination

let _ = tokio::join!(task_1, t2);
  • tokio::join!: Waits for all specified tasks to complete

  • Ensures both tasks finish before the program exits

  • The _ indicates we're not using the return values

Understanding the Runtime

Here's how multithreading works in this example:

  1. Task Submission: When you call tokio::spawn, you submit a task to the runtime's queue

  2. Thread Pool Distribution: The runtime has 2 worker threads constantly checking for tasks

  3. Work Stealing: If one thread is idle and another has queued tasks, work can be redistributed

  4. Concurrent Execution: Both tasks can literally run at the same time on different CPU cores

Expected Output Pattern

task ONE started on ThreadId(2)
task TWO started on ThreadId(3)
task TWO step 1 on ThreadId(3)
task TWO step 2 on ThreadId(3)
task TWO step 3 on ThreadId(3)
task TWO step 4 on ThreadId(3)
task ONE finished on ThreadId(2)
task TWO step 5 on ThreadId(3)
task TWO finished on ThreadId(3)

Notice how tasks run on different threads (ThreadId 2 and 3), enabling true parallelism.

Concurrency Without Spawning

For comparison, here's how to achieve concurrency without spawn:

use tokio::time::{Duration, sleep};
use std::thread;

#[tokio::main(flavor = "current_thread")]
async fn main() {
    // Define async functions
    async fn task_one() {
        println!("task ONE started on {:?}", thread::current().id());
        sleep(Duration::from_secs(2)).await;
        println!("task ONE finished on {:?}", thread::current().id());
    }

    async fn task_two() {
        println!("task TWO started on {:?}", thread::current().id());
        for i in 1..=5 {
            println!("task TWO step {} on {:?}", i, thread::current().id());
            sleep(Duration::from_millis(500)).await;
        }
        println!("task TWO finished on {:?}", thread::current().id());
    }

    // Run concurrently on the same thread
    tokio::join!(task_one(), task_two());
}

Key Differences:

  • No spawn: Tasks are not submitted to a thread pool

  • Single thread: All tasks run on the main thread

  • Cooperative: Tasks yield to each other at await points

  • Still concurrent: Tasks interleave execution, making progress "simultaneously"

Key Takeaways

  1. tokio::spawn enables multithreading: Tasks can run on different OS threads in parallel

  2. Without spawn, you get concurrency: Tasks share the same thread but still make concurrent progress

  3. .await is the yield point: This is where tasks can switch, enabling concurrency

  4. Thread pools distribute work: The runtime manages which thread executes which task

  5. Choose based on needs:

    • Use spawn for CPU-intensive tasks that benefit from parallelism

    • Use concurrent execution for I/O-bound tasks that mostly wait

When to Use Each Approach

Use tokio::spawn (Multithreading) when:

  • You have CPU-intensive computations

  • Tasks are independent and can truly run in parallel

  • You want to utilize multiple CPU cores

  • You need isolation between tasks

Use Concurrent Execution (without spawn) when:

  • Tasks are mostly I/O-bound (network, disk, etc.)

  • You want simpler code without spawn overhead

  • Tasks need to share data without synchronization

  • You're fine with single-threaded execution

Conclusion

Understanding the difference between concurrency and multithreading is crucial for writing efficient async Rust code. While tokio::spawn gives you true parallelism across threads, you can achieve impressive concurrency even on a single thread through async/await. Choose the approach that best fits your specific use case!

More from this blog

Untitled Publication

10 posts