Understanding Async Concurrency vs Multithreading in Rust with Tokio

AshwinAshwin
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!

0
Subscribe to my newsletter

Read articles from Ashwin directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ashwin
Ashwin

I'm a Full Stack Web3 Engineer crafting cutting-edge dApps and DeFi solutions. From writing secure smart contracts to building intuitive Web3 interfaces, I turn complex blockchain concepts into user-friendly experiences. I specialize in building on Ethereum, Sui, and Aptos — blockchain platforms where I’ve developed and deployed production-grade, battle-tested smart contracts. My experience includes working with both Solidity on EVM chains and Move on Sui and Aptos. I'm passionate about decentralization, protocol development, and shaping the infrastructure for Web3's future.