tokio::spawn

Tokio is an asynchronous runtime for Rust. “It provides the building blocks needed for writing network applications.” – It’s pretty much the de-facto standard for Rust!

Before we look at “spawn” let’s review what tokio is, how to use it, and look at what tasks are.

tokio = { version = "1", features = ["full"] }

tokio::main


The #[tokio::main] function is a macro. It transforms the async fn main() into a synchronous fn main() that initializes a runtime instance and executes the async main function. ~ https://tokio.rs/tokio/tutorial/hello-tokio
#[tokio::main]
async fn main() {
    println!("hello");
}

gets transformed into:

fn main() {
    let mut rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        println!("hello");
    })
}

Tasks


Asynchronous programs in Rust are based around lightweight, non-blocking units of execution called tasks

Below is the Cargo.toml adjusted to import features for a library that just needs to tokio::spawn and use a TcpStream

tokio = { version = "1", features = ["rt", "net"] }

Tasks

what are tasks?

https://docs.rs/tokio/latest/tokio/task/index.html#what-are-tasks

A task is a light weight, non-blocking unit of execution. A task is similar to an OS thread, but rather than being managed by the OS scheduler, they are managed by the Tokio runtime. Another name for this general pattern is green threads.


1. Task Runs Independently (Fire-and-Forget) – tokio::spawn

By default, a task spawned with tokio::spawn runs concurrently and independently of the main task. Without awaiting it, the spawned task starts execution, but the main function does not wait for it to finish.

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

#[tokio::main]
async fn main() {
    tokio::spawn(async {
        sleep(Duration::from_secs(1)).await;
        println!("Task completed!");
    });

    println!("Main function ends");
}

Output:

Main function ends
Task completed!

In this example, the main function ends before the spawned task finishes, but the task continues running in the background until it completes.


2. Why Use await?

By calling task.await, the main task explicitly waits for the spawned task to complete. This is useful when:

  • You need to ensure order: Ensure that the spawned task completes before proceeding.
  • Error handling: task.await returns a Result that allows you to handle errors from the spawned task.

Example with await:

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

#[tokio::main]
async fn main() {
    let task = tokio::spawn(async {
        sleep(Duration::from_secs(1)).await;
        println!("Task completed!");
        42 // Return a value from the task
    });

    let result = task.await.unwrap();
    println!("Task result: {}", result);
}

Output:

Task completed!
Task result: 42

3. Without await, Potential Issues:

  • Unfinished Tasks: The program might terminate before background tasks complete, especially in short-lived programs.
  • Unobservable Errors: Errors in the spawned task are silently ignored unless awaited.

When Skipping await Is Okay:

  • Fire-and-forget tasks: If the task performs work you don’t need to track, such as logging or background cleanup.
  • Long-running tasks: Tasks designed to run for the program’s lifetime (e.g., a server handling requests).

How await Works

await: “Keep doing stuff until the result is available. When it’s available, resume from here.”It pauses the current task (but not the entire thread) and lets the Tokio runtime perform other tasks.

It’s efficient because it prevents unnecessary blocking while waiting for I/O operations, timers, etc.


Does tokio::spawn Spawn a New Thread?

No, tokio::spawn does not spawn a new OS thread. Instead, it schedules the asynchronous task to be run on the Tokio runtime’s thread pool.

“Tokio is able to concurrently run many tasks on a few threads by repeatedly swapping the currently running task on each thread.”

https://docs.rs/tokio/latest/tokio/#working-with-tasks

tokio::spawn

Here’s how it works:


1. How tokio::spawn Works

When you call tokio::spawn, the following happens:

  • Task Creation: The provided async block or function is wrapped into a task, which is an object that implements the Future trait.
  • Scheduling: This task is handed over to the Tokio runtime, which determines when and on which worker thread the task should run.
  • Execution: The runtime executes the task on one of the threads in its thread pool. Tasks may be paused and resumed depending on whether they are waiting for I/O or other asynchronous operations.

This means that tasks created with tokio::spawn are lightweight and cooperative:

  • They share threads with other tasks.
  • They yield control when performing asynchronous operations, allowing other tasks to run.

2. Does It Use a New Thread?

No, tokio::spawn does not create a new thread for every task. Instead:

  • In the multi-threaded runtime (default), tasks run on a shared thread pool that typically matches the number of CPU cores.
  • In the current-thread runtime, all tasks run on a single thread (no thread pool).

Example of thread reuse: If you spawn 1,000 tasks, they will all share the same thread pool (multi-threaded runtime) or single thread (current-thread runtime), not 1,000 threads.


3. Why Is This Efficient?

  • Lightweight Tasks: Unlike OS threads, tasks in Tokio are cheap to create and manage.
  • Asynchronous Execution: Tasks only consume CPU when they’re actively performing work. They yield when waiting for I/O, timers, or other async operations, allowing other tasks to use the same thread.
  • Thread Pool: A fixed number of threads (by default, num_cpus::get() threads) handle many tasks, avoiding the overhead of thread creation and destruction.

4. Compare to OS Threads

Aspecttokio::spawn TaskOS Thread
CostVery lightweightExpensive (OS-managed)
ConcurrencyCooperative (tasks yield control)Preemptive
Thread CreationDoes not create a new threadCreates a new thread

5. Example with Thread IDs

To demonstrate that tasks share threads, you can log the thread ID for each task:

use tokio::task;
use std::thread;

#[tokio::main]
async fn main() {
    for _ in 0..5 {
        tokio::spawn(async {
            println!("Running on thread: {:?}", thread::current().id());
        });
    }
}

Output:

Running on thread: ThreadId(1)
Running on thread: ThreadId(2)
Running on thread: ThreadId(1)
Running on thread: ThreadId(2)
Running on thread: ThreadId(1)

This shows that tasks reuse existing threads rather than creating new ones for every tokio::spawn.



https://docs.rs/tokio/latest/tokio/#examples

Multi-threaded runtime

If you use #[tokio::main] without specifying the flavor, it defaults to the multi-threaded runtime. This means:

  1. Multiple Threads: Tokio creates a thread pool, with the number of threads determined by the number of CPU cores (you can customize this with worker_threads).
  2. Task Scheduling: Tasks created with tokio::spawn can run on any thread in the thread pool, so you’re likely to see different thread IDs for tasks and the main function.

Example:

use tokio::task;
use std::thread;

#[tokio::main] // Defaults to multi-threaded runtime
async fn main() {
    println!("Main thread: {:?}", thread::current().id());

    tokio::spawn(async {
        println!("Spawned task thread: {:?}", thread::current().id());
    })
    .await
    .unwrap();
}

Possible Output:

Main thread: ThreadId(1)
Spawned task thread: ThreadId(2)

In this example:

  • The main task runs on one thread (e.g., ThreadId(1)).
  • The spawned task runs on a different thread from the thread pool (e.g., ThreadId(2)).

Runtime Behavior:

Single-threaded runtime (current_thread):

#[tokio::main(flavor = "current_thread")]
async fn main() {
    // Single-threaded runtime: All tasks use the same thread.
}

Multi-threaded runtime (multi_thread):

#[tokio::main(flavor = "multi_thread", worker_threads = 4)] // Specify threads if needed
async fn main() {
    // Multi-threaded runtime: Tasks can run on different threads in the pool.
}

Default behavior (#[tokio::main]):

Equivalent to:

#[tokio::main(flavor = "multi_thread")]

By default, #[tokio::main] gives you the multi-threaded runtime, so tasks are distributed across multiple threads.


Summary:

  • #[tokio::main] defaults to the multi-threaded runtime.
  • Tasks can run on different threads, managed by Tokio’s thread pool.

Conclusion

tokio::spawn schedules tasks to run on the Tokio runtime’s thread pool. It does not create a new thread for each task, making it efficient for handling thousands or even millions of concurrent tasks. If you need explicit thread creation, you can use Rust’s standard library (std::thread) instead.

AI ML

Next article

Qdrant & Rust – embeddings