Understanding Async Concurrency vs Multithreading in Rust with Tokio

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
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 runtimeworker_threads = 2
: Creates exactly 2 worker threads to execute tasksThis 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 runtimeThe 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 executingThis 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
?: Theawait
keyword yields control back to the runtimeWhile 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 completeEnsures 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:
Task Submission: When you call
tokio::spawn
, you submit a task to the runtime's queueThread Pool Distribution: The runtime has 2 worker threads constantly checking for tasks
Work Stealing: If one thread is idle and another has queued tasks, work can be redistributed
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 poolSingle 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
tokio::spawn
enables multithreading: Tasks can run on different OS threads in parallelWithout
spawn
, you get concurrency: Tasks share the same thread but still make concurrent progress.await
is the yield point: This is where tasks can switch, enabling concurrencyThread pools distribute work: The runtime manages which thread executes which task
Choose based on needs:
Use
spawn
for CPU-intensive tasks that benefit from parallelismUse 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!
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.