Actix is a Rust framework designed around an actor-based model, making it fundamentally different from traditional web frameworks. It is built on top of the Actix actor system, which provides lightweight concurrency while maintaining strong isolation between components. This allows for high performance and predictable scaling while keeping application logic straightforward.
The Actor Model in Actix
Unlike traditional thread-based concurrency models, Actix uses actors to encapsulate state and behavior. Each actor runs in a dedicated execution context and communicates with others through message passing, preventing race conditions and ensuring thread safety.
This design choice eliminates the need for explicit locks while allowing highly concurrent applications. Since each actor processes messages sequentially, developers can write logic without worrying about concurrent state modifications.
Actors in Actix are defined as structs that implement the Actor trait. They can maintain their own state and respond to messages asynchronously:
use actix::prelude::*;
struct MyActor;
impl Actor for MyActor {
type Context = Context<Self>;
}
#[actix::main]
async fn main() {
let addr = MyActor.start(); // Start an actor instance
}When an actor receives a message, it runs the corresponding handler and may return a response asynchronously. This structure allows Actix to scale efficiently while ensuring strong memory safety guarantees.
How Actix Web Handles Async Execution
Despite being asynchronous, Actix Web does not use a multi-threaded task scheduler like Tokio. Instead, it runs each HTTP worker in a single-threaded event loop. This means that all async operations within a worker—such as database queries, network requests, and filesystem interactions—execute without needing Send unless explicitly required.
Each request is handled by an independent worker, and Actix Web can spawn multiple workers to utilize all available CPU cores. However, within a single worker, .await operations always resume on the same thread. This is a key reason why awc::Client, which relies on Rc internally, can be used without requiring Send.
use actix_web::{web, App, HttpServer, Responder};
use awc::Client;
async fn fetch_data() -> impl Responder {
let client = Client::default();
let response = client.get("https://api.example.com").send().await.unwrap();
format!("{:?}", response)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().route("/", web::get().to(fetch_data)))
.workers(4) // Multiple single-threaded workers
.bind("127.0.0.1:8080")?
.run()
.await
}Because each worker is isolated and does not share memory, Actix Web avoids synchronization overhead. However, this also means that any long-running, blocking task should be offloaded to a separate thread pool to prevent starvation of the event loop.
Why Actix Web Does Not Require Send
The key reason Actix Web does not require Send for async functions is that .await always resumes execution on the same worker thread. In contrast, Tokio default scheduler moves futures between threads, which necessitates Send.
Rust determines whether a future must be Send by analyzing the function signature of spawn functions. The implementation of actix_rt::spawn explicitly avoids enforcing Send:
pub fn spawn<F>(future: F) -> impl Future<Output = ()>
where
F: Future<Output = ()> + 'static, // No `Send` requirementSince the runtime guarantees that the future will not be sent to another thread, Rust does not require Send. If Actix Web’s spawn function were modified to allow execution across threads, Rust would not prevent the misuse of Rc or other non-thread-safe types.
Offloading Blocking Tasks in Actix Web
Although Actix Web avoids blocking by design, some operations—such as reading files or executing CPU-intensive tasks—cannot be performed asynchronously within a single-threaded event loop. To handle these cases, Actix provides web::block, which offloads work to a thread pool:
use actix_web::{web, App, HttpServer, Responder};
use std::fs;
use actix_web::web::block;
async fn read_file() -> impl Responder {
let contents = block(|| fs::read_to_string("file.txt")).await.unwrap();
format!("File contents: {}", contents)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().route("/", web::get().to(read_file)))
.workers(4)
.bind("127.0.0.1:8080")?
.run()
.await
}This ensures that CPU-intensive or blocking operations do not starve Actix Web’s async event loop, keeping the system responsive.
The Trade-Off: Performance vs. Flexibility
By maintaining strict worker isolation and avoiding implicit multi-threading, Actix Web achieves exceptional performance, often outperforming frameworks that rely on Tokio’s multi-threaded scheduler. However, this also limits flexibility—certain async libraries designed for Tokio, such as reqwest, require Send and cannot be used directly within an Actix Web handler.
If cross-thread execution is required, developers can either:
- Use
#[async_trait(?Send)]when defining async traits to ensure compatibility. - Wrap non-Send types in an
Arc<Mutex<>>and pass ownership across workers explicitly. - Offload work to a separate Tokio runtime if full multi-threading is required.
Actix Web’s architectural choices make it ideal for high-performance, event-driven web applications but require careful design decisions when integrating external async libraries.