A task in Tokio is a lightweight unit of asynchronous work managed by the runtime.
In the previous article, we learned the async and .await syntax. But if we write tarea_1().await; tarea_2().await;, we are not executing the tasks concurrently. The first one finishes completely before the second one starts.
To execute futures concurrently, we need tools like tokio::spawn, tokio::join!, and tokio::select!.
- Operating system thread: Reserves its own stack and is scheduled by the kernel.
- Tokio task: It is much lighter and is scheduled by the runtime over a pool of threads.
This means you can launch millions of tasks in Tokio without saturating your server’s memory.
Launching tasks with tokio::spawn
To execute a future in the “background” (without blocking your current flow waiting for it to finish), we use tokio::spawn. It’s the asynchronous equivalent of std::thread::spawn.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
// Launch a task in the background.
// main does NOT stop here.
let handle = tokio::spawn(async {
sleep(Duration::from_secs(2)).await;
println!("Background task finished");
"Success"
});
println!("Doing other things on the main thread...");
sleep(Duration::from_secs(1)).await;
println!("Main is still working...");
// Optional: Wait for the task to finish to get its value
let result = handle.await.unwrap();
println!("Result: {}", result);
}The 'static constraint
The task sent to spawn cannot contain references to local variables that might disappear before it finishes. An async move block allows capturing values by ownership, and Arc is useful when we need to share that ownership.
Concurrency with tokio::join!
Imagine you need to make two HTTP requests to two different servers. If you do:
let res1 = request_server_a().await; // Takes 2s
let res2 = request_server_b().await; // Takes 2s
// Total: 4 secondsThis is inefficient. We want to launch them simultaneously and wait for both to finish.
For that, we use the tokio::join! macro.
use tokio::time::{sleep, Duration};
async fn task_a() -> u8 {
sleep(Duration::from_secs(2)).await;
println!("A finished");
10
}
async fn task_b() -> u8 {
sleep(Duration::from_secs(1)).await; // This one is faster
println!("B finished");
20
}
#[tokio::main]
async fn main() {
println!("Starting the race...");
// Launch both simultaneously and wait
let (res_a, res_b) = tokio::join!(task_a(), task_b());
// The total time will be that of the slowest task (2s), not the sum (3s).
println!("Results: A={}, B={}", res_a, res_b);
}Note: join! executes the futures concurrently within the same task and polls them on the thread executing it. It does not provide parallelism by itself; for that, we would need to launch separate tasks.
Waiting for the first one with tokio::select!
Sometimes you don’t want to wait for all tasks to finish. Sometimes you only care about the first one to finish.
Examples:
- Trying to connect to 3 servers and keeping the one that responds first.
- Waiting for a response with a Timeout (a race between the response and the clock).
For this, we use tokio::select!.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let race = tokio::select! {
res_a = slow_task() => {
format!("Task won: {}", res_a)
}
_ = sleep(Duration::from_millis(500)) => {
"Clock won (Timeout)"
}
};
println!("Result: {}", race);
}
async fn slow_task() -> String {
sleep(Duration::from_secs(2)).await;
String::from("Completed")
}What happens to the loser?
When select! terminates because one branch has won, it drops the futures from the other branches. It is wise to ensure that operations are safe to cancel, especially when using select! inside a loop.
Dropping a JoinHandle does not cancel the task created with tokio::spawn; that task continues running in the background. If we need to stop it explicitly, we can use abort() or design a cooperative cancellation mechanism.
Shared state in async
In the concurrency article, we saw std::sync::Mutex.
In the asynchronous world, we must avoid blocking a runtime thread during a long wait.
If you use std::sync::Mutex and call .lock(), if the lock is held, you will block the Tokio Runtime thread. If you have few threads in the runtime, you could freeze the entire server.
Tokio provides its own tokio::sync::Mutex.
Its .lock() method is asynchronous (.lock().await).
use tokio::sync::Mutex; // Note the import!
use std::sync::Arc;
#[tokio::main]
async fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let c = counter.clone();
handles.push(tokio::spawn(async move {
// The .await here suspends the task, not the thread,
// if the lock is held.
let mut num = c.lock().await;
*num += 1;
}));
}
for h in handles {
h.await.unwrap();
}
println!("Counter: {}", *counter.lock().await);
}Practical Mutex criterion
- If the critical section is short and you don’t hold the guard across an
.await,std::sync::Mutexis usually appropriate and has less overhead. - If the guard must survive an
.await,tokio::sync::Mutexis designed for that. Still, keeping a resource locked during a network operation often warrants a design review.