5/5
## Understanding Concurrency in Rust: Threads, Async/Await, and Tokio Concurrency is the art of making a program do multiple things seemingly at the same time. This is crucial for building responsive and efficient applications, especially in web3 where handling numerous network requests or independent tasks is common. In Rust, two primary approaches to achieve concurrency are native OS threads and the `async`/`await` syntax with runtimes like Tokio. **Key Concepts:** 1. **Concurrency:** The ability of a system to execute multiple tasks or parts of a program in overlapping time periods. It doesn't necessarily mean true parallelism (doing things at the exact same instant), but rather managing many tasks at once, switching between them as needed. 2. **Native OS Threads:** These are threads managed directly by your operating system. Each thread gets its own stack (a region of memory for local variables and function calls) and can be scheduled by the OS to run truly in parallel on multi-core processors. Creating and managing OS threads has some overhead. 3. **`async`/`await` (Futures):** This is Rust's modern approach to asynchronous programming. * An `async` function, when called, doesn't execute its body immediately. Instead, it returns a "future." A future is a value that represents a computation that might not have completed yet. Think of it as a promise that a value will be available later. * The `await` keyword is used inside an `async` function to pause its execution until the awaited future completes. Critically, while one `async` task is `await`ing, the system can switch to run other tasks, rather than blocking an entire OS thread. 4. **Tokio:** Tokio is a popular asynchronous runtime for Rust. It provides the necessary infrastructure to execute `async` code, including an "executor" that manages a pool of threads and schedules `async` tasks (futures) onto them. It also offers utilities for asynchronous networking, timers, and inter-task communication. 5. **CPU-bound vs. I/O-bound tasks:** Understanding the nature of your tasks is key to choosing the right concurrency model. * **CPU-bound tasks:** These tasks spend most of their time performing intensive calculations, fully utilizing the CPU (e.g., complex mathematical algorithms, data processing, cryptography). * **I/O-bound tasks:** These tasks spend most of their time waiting for external operations to complete. This includes waiting for network requests, reading from or writing to a disk, or waiting for timers. During these waits, the CPU is often idle for that specific task. This lesson will compare native OS threads with `async`/`await` to help you decide when to use each. ## The Pitfalls of Native Threads: Why Spawning Too Many Can Crash Your Program A common question is: why not just use native OS threads for everything? While threads offer true parallelism, they come with limitations, especially when dealing with a very large number of concurrent operations. The primary problem with native threads is that spawning an excessive number can lead to program crashes. This is due to two main reasons: 1. **OS Thread Limits:** Operating systems impose a maximum limit on the number of threads a single process can create. Exceeding this limit will typically result in an error or crash. 2. **Memory Limits:** Each OS thread consumes system resources, most notably its own stack memory. Even if the OS limit isn't hit, creating thousands or tens of thousands of threads can exhaust the available system memory, leading to a crash. Let's illustrate this with a code example. Imagine we want to simulate making one million hamburgers, where each "making" process involves a short wait. **Code Example 1: Demonstrating Thread Crash** The following Rust code attempts to spawn one million native OS threads. Each thread will simulate "making a hamburger" by pausing for 100 milliseconds. ```rust use std::thread; use std::time::Duration; fn main() { // Spawning too many threads can crash this program (OS thread and memory limits) let mut handles = vec![]; // To store thread join handles for i in 0..1_000_000 { // Loop to spawn 1 million threads handles.push(std::thread::spawn(move || { // Spawn a new OS thread std::thread::sleep(Duration::from_millis(100)); // Simulate work (I/O wait) println!("Thread: {} 🍔 is ready", i); // Print when done })); } // Wait for all spawned threads to complete for h in handles { h.join().unwrap(); // Main thread waits for each spawned thread } } ``` **Explanation:** * **`std::thread::spawn(move || { ... })`**: This function creates and starts a new OS thread. The `move` keyword transfers ownership of any captured variables (like `i`) into the new thread's closure. It returns a `JoinHandle`. * **`handles` vector**: We store each `JoinHandle` in this vector. A `JoinHandle` allows us to wait for the corresponding thread to finish. * **`std::thread::sleep(Duration::from_millis(100))`**: This simulates an I/O-bound operation by pausing the current thread for 100 milliseconds. * **`h.join().unwrap()`**: In the second loop, the main thread calls `join()` on each `JoinHandle`. This blocks the main thread until that specific spawned thread completes its execution. `unwrap()` is used here for simplicity to panic if a thread panics. **Running Code Example 1:** If you compile and run this code (e.g., `cargo run`), you'll observe it starts printing messages like "Thread: X 🍔 is ready". However, it will very quickly crash. The terminal output will likely show an error message similar to "thread caused non-unwinding panic. aborting." or an out-of-memory error, demonstrating that the system couldn't handle the creation of so many native threads. This experiment clearly shows the limitations of naively spawning a thread for every concurrent task, especially when the number of tasks is very large. ## Scaling Concurrency with `async`/`await` and Tokio Now, let's refactor the "one million hamburgers" example to use Rust's `async`/`await` feature along with the Tokio runtime. This approach is designed to handle a large number of concurrent I/O-bound tasks much more efficiently. **Code Example 2: Successfully Handling Many Tasks with `async`/`await`** We'll modify the previous code to use `async` blocks and `tokio::task::spawn`. Note that to run this, you'll need to add Tokio as a dependency to your `Cargo.toml` (e.g., `tokio = { version = "1", features = ["full"] }`) and use `#[tokio::main]` for your `main` function. ```rust use tokio::time::{sleep, Duration}; // Use tokio's sleep // Add Tokio as a dependency in Cargo.toml: // tokio = { version = "1", features = ["full"] } // And use the tokio::main macro for your main function. #[tokio::main] async fn main() { let mut handles = vec![]; // To store Tokio task JoinHandles for i in 0..1_000_000 { // Loop to spawn 1 million async tasks // Create an async block (a future) let fut = async move { sleep(Duration::from_millis(100)).await; // Asynchronous sleep println!("Async: {} 🍔 is ready", i); }; // Spawn the future as a Tokio task on the runtime let handler = tokio::task::spawn(fut); handles.push(handler); } // Wait for all spawned Tokio tasks to complete for h in handles { h.await.unwrap(); // Await the JoinHandle (which is also a future) } } ``` **Explanation:** * **`#[tokio::main]`**: This macro transforms our `async fn main()` into a regular `fn main()` that initializes the Tokio runtime and runs the `async` code. * **`async move { ... }`**: This syntax creates an asynchronous block. This block doesn't execute immediately; instead, it defines a "future." The `move` keyword ensures any captured variables (like `i`) are moved into the future. * **`tokio::time::sleep(Duration::from_millis(100)).await`**: This is Tokio's asynchronous version of sleep. When `.await` is encountered: * The execution of *this specific `async` block* is paused. * Control is yielded back to the Tokio executor. * Crucially, the OS thread running this `async` block is *not* blocked. The executor can use that thread to run other `async` tasks that are ready. * Once the 100ms duration elapses, Tokio will schedule this task to resume execution from where it left off. * **`tokio::task::spawn(fut)`**: This function takes a future (`fut`) and schedules it to be run on Tokio's thread pool. It's a non-blocking operation; it returns immediately with a `JoinHandle` (specifically, `tokio::task::JoinHandle`). This `JoinHandle` is itself a future that resolves when the spawned task completes. * **`h.await.unwrap()`**: In the final loop, the `main` `async` function `await`s each `JoinHandle`. This ensures that `main` waits for all one million "hamburger making" tasks to finish before the program exits. **Important Correction:** When defining the future, ensure you use `let fut = async move { ... };`. An earlier common mistake might be to write `let fut = async move || { ... };`. The `||` syntax makes it a closure that *returns* a future when called, which is not what we want here. We want to define the future directly. **Running Code Example 2:** When you compile and run this `async`/`await` version, you'll see a stark difference. The program will successfully print messages for all one million hamburgers without crashing. You'll likely observe that the numbers in the output appear out of order (e.g., "Async: 999756 🍔 is ready" might appear before "Async: 313878 🍔 is ready"). This out-of-order completion is a hallmark of concurrent execution: tasks finish as their work (the 100ms sleep) completes, not necessarily in the order they were started. This demonstrates that `async`/`await` with Tokio can efficiently manage a massive number of concurrent I/O-bound operations using a small, fixed pool of OS threads, thus avoiding the limitations we saw with spawning one OS thread per task. ## When to Use Threads vs. `async`/`await`: A Practical Guide The choice between native OS threads and `async`/`await` depends largely on the nature of the tasks you're trying to parallelize. Here's a general guideline: 1. **When to use Native OS Threads (`std::thread`):** * **For parallelizing computation (CPU-bound tasks).** If you have tasks that are computationally intensive and can be broken down into independent chunks of work, OS threads are a good choice. On a multi-core processor, each thread can run on a separate core, leading to a genuine speed-up in overall execution time. Examples include complex calculations, image processing, or intensive data analysis. * The number of threads in such scenarios is typically matched to the number of CPU cores available for optimal performance. Spawning significantly more threads than cores for CPU-bound work can lead to diminishing returns due to context-switching overhead. 2. **When to use `async`/`await` (with a runtime like Tokio):** * **For parallelizing waiting time (I/O-bound tasks).** If your program involves many tasks that spend most of their time waiting for external operations—such as network requests, database queries, file reads/writes, or timers—then `async`/`await` is highly effective. * `async`/`await` allows a small number of OS threads (managed by the Tokio runtime) to handle thousands or even millions of concurrent I/O-bound operations. When one `async` task `await`s an I/O operation, the thread it was running on is freed up to work on other tasks, rather than sitting idle. This leads to much better resource utilization and scalability for I/O-heavy workloads. **Applying the Guideline to Our Hamburger Example:** In our "making a hamburger" example, the core operation was `std::thread::sleep` or `tokio::time::sleep`. This sleep simulates waiting – perhaps for ingredients to become available, for a cooking step to finish, or, more generally, for a network response or disk I/O. This is a classic **I/O-bound** scenario (or, more accurately, a "waiting-bound" scenario). Because the task involves waiting rather than intensive CPU computation, `async`/`await` is the superior choice. It allows us to manage many concurrent "waiting" tasks without the heavy resource cost of an OS thread for each one. **A Note on Smart Contracts:** It's worth briefly mentioning that in specialized environments like smart contracts written in Rust, the ability to spawn native OS threads is often restricted or unavailable due to the deterministic and sandboxed nature of blockchain execution. If concurrency features are supported in such environments, they are more likely to resemble an `async`/`await`-like model, making an understanding of asynchronous programming potentially very relevant. In summary, `async`/`await` with a runtime like Tokio excels at managing a large number of I/O-bound concurrent tasks efficiently and without the overhead and system limits associated with creating a dedicated native OS thread for each task. Native OS threads remain the go-to solution for parallelizing CPU-intensive computations across multiple cores. Choose wisely based on the workload characteristics of your application.
Concurrency is the art of making a program do multiple things seemingly at the same time. This is crucial for building responsive and efficient applications, especially in web3 where handling numerous network requests or independent tasks is common. In Rust, two primary approaches to achieve concurrency are native OS threads and the async
/await
syntax with runtimes like Tokio.
Key Concepts:
Concurrency: The ability of a system to execute multiple tasks or parts of a program in overlapping time periods. It doesn't necessarily mean true parallelism (doing things at the exact same instant), but rather managing many tasks at once, switching between them as needed.
Native OS Threads: These are threads managed directly by your operating system. Each thread gets its own stack (a region of memory for local variables and function calls) and can be scheduled by the OS to run truly in parallel on multi-core processors. Creating and managing OS threads has some overhead.
async
/await
(Futures): This is Rust's modern approach to asynchronous programming.
An async
function, when called, doesn't execute its body immediately. Instead, it returns a "future." A future is a value that represents a computation that might not have completed yet. Think of it as a promise that a value will be available later.
The await
keyword is used inside an async
function to pause its execution until the awaited future completes. Critically, while one async
task is await
ing, the system can switch to run other tasks, rather than blocking an entire OS thread.
Tokio: Tokio is a popular asynchronous runtime for Rust. It provides the necessary infrastructure to execute async
code, including an "executor" that manages a pool of threads and schedules async
tasks (futures) onto them. It also offers utilities for asynchronous networking, timers, and inter-task communication.
CPU-bound vs. I/O-bound tasks: Understanding the nature of your tasks is key to choosing the right concurrency model.
CPU-bound tasks: These tasks spend most of their time performing intensive calculations, fully utilizing the CPU (e.g., complex mathematical algorithms, data processing, cryptography).
I/O-bound tasks: These tasks spend most of their time waiting for external operations to complete. This includes waiting for network requests, reading from or writing to a disk, or waiting for timers. During these waits, the CPU is often idle for that specific task.
This lesson will compare native OS threads with async
/await
to help you decide when to use each.
A common question is: why not just use native OS threads for everything? While threads offer true parallelism, they come with limitations, especially when dealing with a very large number of concurrent operations.
The primary problem with native threads is that spawning an excessive number can lead to program crashes. This is due to two main reasons:
OS Thread Limits: Operating systems impose a maximum limit on the number of threads a single process can create. Exceeding this limit will typically result in an error or crash.
Memory Limits: Each OS thread consumes system resources, most notably its own stack memory. Even if the OS limit isn't hit, creating thousands or tens of thousands of threads can exhaust the available system memory, leading to a crash.
Let's illustrate this with a code example. Imagine we want to simulate making one million hamburgers, where each "making" process involves a short wait.
Code Example 1: Demonstrating Thread Crash
The following Rust code attempts to spawn one million native OS threads. Each thread will simulate "making a hamburger" by pausing for 100 milliseconds.
Explanation:
std::thread::spawn(move || { ... })
: This function creates and starts a new OS thread. The move
keyword transfers ownership of any captured variables (like i
) into the new thread's closure. It returns a JoinHandle
.
handles
vector: We store each JoinHandle
in this vector. A JoinHandle
allows us to wait for the corresponding thread to finish.
std::thread::sleep(Duration::from_millis(100))
: This simulates an I/O-bound operation by pausing the current thread for 100 milliseconds.
h.join().unwrap()
: In the second loop, the main thread calls join()
on each JoinHandle
. This blocks the main thread until that specific spawned thread completes its execution. unwrap()
is used here for simplicity to panic if a thread panics.
Running Code Example 1:
If you compile and run this code (e.g., cargo run
), you'll observe it starts printing messages like "Thread: X 🍔 is ready". However, it will very quickly crash. The terminal output will likely show an error message similar to "thread caused non-unwinding panic. aborting." or an out-of-memory error, demonstrating that the system couldn't handle the creation of so many native threads.
This experiment clearly shows the limitations of naively spawning a thread for every concurrent task, especially when the number of tasks is very large.
async
/await
and TokioNow, let's refactor the "one million hamburgers" example to use Rust's async
/await
feature along with the Tokio runtime. This approach is designed to handle a large number of concurrent I/O-bound tasks much more efficiently.
Code Example 2: Successfully Handling Many Tasks with async
/await
We'll modify the previous code to use async
blocks and tokio::task::spawn
. Note that to run this, you'll need to add Tokio as a dependency to your Cargo.toml
(e.g., tokio = { version = "1", features = ["full"] }
) and use #[tokio::main]
for your main
function.
Explanation:
#[tokio::main]
: This macro transforms our async fn main()
into a regular fn main()
that initializes the Tokio runtime and runs the async
code.
async move { ... }
: This syntax creates an asynchronous block. This block doesn't execute immediately; instead, it defines a "future." The move
keyword ensures any captured variables (like i
) are moved into the future.
tokio::time::sleep(Duration::from_millis(100)).await
: This is Tokio's asynchronous version of sleep. When .await
is encountered:
The execution of this specific async
block is paused.
Control is yielded back to the Tokio executor.
Crucially, the OS thread running this async
block is not blocked. The executor can use that thread to run other async
tasks that are ready.
Once the 100ms duration elapses, Tokio will schedule this task to resume execution from where it left off.
tokio::task::spawn(fut)
: This function takes a future (fut
) and schedules it to be run on Tokio's thread pool. It's a non-blocking operation; it returns immediately with a JoinHandle
(specifically, tokio::task::JoinHandle
). This JoinHandle
is itself a future that resolves when the spawned task completes.
h.await.unwrap()
: In the final loop, the main
async
function await
s each JoinHandle
. This ensures that main
waits for all one million "hamburger making" tasks to finish before the program exits.
Important Correction:
When defining the future, ensure you use let fut = async move { ... };
. An earlier common mistake might be to write let fut = async move || { ... };
. The ||
syntax makes it a closure that returns a future when called, which is not what we want here. We want to define the future directly.
Running Code Example 2:
When you compile and run this async
/await
version, you'll see a stark difference. The program will successfully print messages for all one million hamburgers without crashing. You'll likely observe that the numbers in the output appear out of order (e.g., "Async: 999756 🍔 is ready" might appear before "Async: 313878 🍔 is ready"). This out-of-order completion is a hallmark of concurrent execution: tasks finish as their work (the 100ms sleep) completes, not necessarily in the order they were started.
This demonstrates that async
/await
with Tokio can efficiently manage a massive number of concurrent I/O-bound operations using a small, fixed pool of OS threads, thus avoiding the limitations we saw with spawning one OS thread per task.
async
/await
: A Practical GuideThe choice between native OS threads and async
/await
depends largely on the nature of the tasks you're trying to parallelize. Here's a general guideline:
When to use Native OS Threads (std::thread
):
For parallelizing computation (CPU-bound tasks). If you have tasks that are computationally intensive and can be broken down into independent chunks of work, OS threads are a good choice. On a multi-core processor, each thread can run on a separate core, leading to a genuine speed-up in overall execution time. Examples include complex calculations, image processing, or intensive data analysis.
The number of threads in such scenarios is typically matched to the number of CPU cores available for optimal performance. Spawning significantly more threads than cores for CPU-bound work can lead to diminishing returns due to context-switching overhead.
When to use async
/await
(with a runtime like Tokio):
For parallelizing waiting time (I/O-bound tasks). If your program involves many tasks that spend most of their time waiting for external operations—such as network requests, database queries, file reads/writes, or timers—then async
/await
is highly effective.
async
/await
allows a small number of OS threads (managed by the Tokio runtime) to handle thousands or even millions of concurrent I/O-bound operations. When one async
task await
s an I/O operation, the thread it was running on is freed up to work on other tasks, rather than sitting idle. This leads to much better resource utilization and scalability for I/O-heavy workloads.
Applying the Guideline to Our Hamburger Example:
In our "making a hamburger" example, the core operation was std::thread::sleep
or tokio::time::sleep
. This sleep simulates waiting – perhaps for ingredients to become available, for a cooking step to finish, or, more generally, for a network response or disk I/O. This is a classic I/O-bound scenario (or, more accurately, a "waiting-bound" scenario).
Because the task involves waiting rather than intensive CPU computation, async
/await
is the superior choice. It allows us to manage many concurrent "waiting" tasks without the heavy resource cost of an OS thread for each one.
A Note on Smart Contracts:
It's worth briefly mentioning that in specialized environments like smart contracts written in Rust, the ability to spawn native OS threads is often restricted or unavailable due to the deterministic and sandboxed nature of blockchain execution. If concurrency features are supported in such environments, they are more likely to resemble an async
/await
-like model, making an understanding of asynchronous programming potentially very relevant.
In summary, async
/await
with a runtime like Tokio excels at managing a large number of I/O-bound concurrent tasks efficiently and without the overhead and system limits associated with creating a dedicated native OS thread for each task. Native OS threads remain the go-to solution for parallelizing CPU-intensive computations across multiple cores. Choose wisely based on the workload characteristics of your application.
An illuminating exploration of Understanding Concurrency in Rust: Threads, Async/Await, and Tokio - Uncover when to use native OS threads versus `async`/`await` with Tokio for CPU-bound and I/O-bound tasks. Witness the pitfalls of overusing threads and see how `async` efficiently manages massive I/O concurrency.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Introduction to the Rust programming language
Rust variables and functions
Scalar types, arrays, strings, enum, structs, vectors, and hash maps in Rust
Rust control flows: If / else, if let and let else, loop, match
Rust ownership, including borrow and references
Rust error handling
Rust Modules
Rust Traits
Last updated on July 11, 2025
Rust Developer
Rust Programming BasicsDuration: 6min
Duration: 18min
Duration: 47min
Duration: 15min
Duration: 19min
Duration: 8min
Duration: 12min
Duration: 46min
Duration: 14min
Course Overview
About the course
Introduction to the Rust programming language
Rust variables and functions
Scalar types, arrays, strings, enum, structs, vectors, and hash maps in Rust
Rust control flows: If / else, if let and let else, loop, match
Rust ownership, including borrow and references
Rust error handling
Rust Modules
Rust Traits
Last updated on July 11, 2025