Axum vs Actix vs Rocket

В этой статье мы сравним производительность 3 наиболее популярных бекэнд-фреймворков для Rust: Axum, Actix и Rocket.

Методика тестирования

На каждом из фреймворков мы напишем простой веб-сервис имеющий три эндпоинта:

POST /test/simpleПринимает параметр в JSON, форматирует его, возвращает результат в JSON
POST /test/timedПринимает параметр в JSON, засыпает на 20 мс, форматирует как предыдущий метод, возвращает результат в JSON
POST /test/bcryptПринимает параметр в JSON, хеширует его алгоритмом bcrypt с параметром cost=10, возвращает результат в JSON

Первый эндпоинт позволяет измерить чистые накладные расходы фреймворка, олицетворяет собой эндпоинт с простейшей бизнес-логикой. Второй эндпоинт олицетворяет собой эндпоинт с каким-нибудь нетяжёлым запросом к БД или другому сервису. Третий эндпоинт олицетворяет собой какую-нибудь тяжёлую бизнес-логику. Все эндпоинты принимают и возвращают JSON-объект с одним строковым полем payload.

Код для всех трёх фреймворков написан с использованием примеров с официальных сайтов, все настройки, связанные с производительностью, оставлены по умолчанию.

Axum

Фреймворк впервые анонсирован 30 июля 2021 года. Самый молодой фреймворк из рассматриваемых и одновременно самый популярный, разрабатывается командой tokio — самого популярного асинхронного рантайма для Rust (Actix и Rocket под капотом тоже его используют).

Одним из достоинством фреймворка является возможность описывать эндпоинты без использования макросов, что делает код и сообщения компилятора более читаемыми и понятными, а также улучшает качество подсветки синтаксиса и подсказок в IDE. Наравне с этим преимуществом авторы заявляют следующее:

Качество документации высокое — у меня не возникло никаких проблем со следованием руководству для начинающих.

Главная функция приложения — обычная асинхронная функция main из tokio, можно совершать асинхронную инициализацию.

GitHub: https://github.com/tokio-rs/axum
Документация: https://docs.rs/axum/latest/axum/
Количество загрузок на crates.io: 23 миллиона

Код

main.rs

use std::str::FromStr;
use std::time::Duration;
use axum::Json;
use axum::response::IntoResponse;
use tokio::time::sleep;

#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct Data {
	payload: String
}

async fn simple_endpoint(Json(param): Json<Data>) -> impl IntoResponse {
	Json(Data {
		payload: format!("Hello, {}", param.payload)
	})
}

async fn timed_endpoint(Json(param): Json<Data>) -> impl IntoResponse {
	sleep(Duration::from_millis(20)).await;
	Json(Data {
		payload: format!("Hello, {}", param.payload)
	})
}

async fn bcrypt_endpoint(Json(param): Json<Data>) -> impl IntoResponse {
	Json(Data {
		payload: bcrypt::hash(&param.payload, 10).unwrap()
	})
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
	env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
	let router = axum::Router::new()
		.route("/test/simple", axum::routing::post(simple_endpoint))
		.route("/test/timed", axum::routing::post(timed_endpoint))
		.route("/test/bcrypt", axum::routing::post(bcrypt_endpoint));
	let address = "0.0.0.0";
	let port = 3000;
	log::info!("Listening on http://{}:{}/", address, port);
	axum::Server::bind(
		&std::net::SocketAddr::new(
			std::net::IpAddr::from_str(&address).unwrap(),
			port
		)
	).serve(router.into_make_service()).await?;
	Ok(())
}

Cargo.toml

[package]
name = "rust_web_benchmark"
version = "0.1.0"
edition = "2021"

[dependencies]
log = "0.4.20"
env_logger = "0.10.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
axum = "0.6.20"
serde = { version = "1.0.189", features = ["derive"] }
bcrypt = "0.15.0"

Actix

Первый релиз на GitHub датируется 31 октября 2017 года.

Ключевые преимущества заявленные разработчиками:

Для описания эндпоинтов используются макросы. Главная функция приложения совместима с обычной функцией main в tokio, можно совершать асинхронную инициализацию.

Качество документации для начинающих — неплохое, я написал тестовый код без затруднений с сопоставимой скоростью с кодом на Axum, хотя у меня не было опыта работы с Actix.

Официальный сайт: https://actix.rs/
Количество загрузок на crates.io: 5,8 миллионов

Код

main.rs

use std::time::Duration;
use actix_web::{post, App, HttpResponse, HttpServer, Responder};
use actix_web::web::Json;
use tokio::time::sleep;

#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct Data {
	payload: String
}

#[post("/test/simple")]
async fn simple_endpoint(Json(param): Json<Data>) -> impl Responder {
	HttpResponse::Ok().json(Json(Data {
		payload: format!("Hello, {}", param.payload)
	}))
}

#[post("/test/timed")]
async fn timed_endpoint(Json(param): Json<Data>) -> impl Responder {
	sleep(Duration::from_millis(20)).await;
    HttpResponse::Ok().json(Json(Data {
		payload: format!("Hello, {}", param.payload)
	}))
}

#[post("/test/bcrypt")]
async fn bcrypt_endpoint(Json(param): Json<Data>) -> impl Responder {
	HttpResponse::Ok().json(Json(Data {
		payload: bcrypt::hash(&param.payload, 10).unwrap()
	}))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
	env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
	let address = "0.0.0.0";
	let port = 3000;
	log::info!("Listening on http://{}:{}/", address, port);
    HttpServer::new(|| {
        App::new()
            .service(simple_endpoint)
            .service(timed_endpoint)
			.service(bcrypt_endpoint)
    })
        .bind((address, port))?
        .run()
        .await
}

Cargo.toml

[package]
name = "rust_web_benchmark"
version = "0.1.0"
edition = "2021"

[dependencies]
log = "0.4.20"
env_logger = "0.10.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
actix-web = "4"
serde = { version = "1.0.189", features = ["derive"] }
bcrypt = "0.15.0"

Rocket

Увидел свет в 2016 году. Старейший из рассматриваемых фреймворков, до версии 0.5 использовал свою реализацию асинхронности, с версии 0.5 перешёл на tokio.

Ключевые преимущества заявленные разработчиками:

Для определения обработчиков активно используются макросы, также используется свой специальный макрос rocket::launch для определения главной функции приложения, которая должна вернуть построенный экземпляр фреймворка.

Хотя версия 0.5 заявляет поддержку stable ветки Rust, собрать проект с её помощью не получилось, потому что зависимость библиотеки pear требует nighly, поэтому это единственный тест, который собран этой версией компилятора.

Также следует отметить путаницу в документации из-за сильного изменения API в версии 0.5. Поиск в Google часто выдаёт примеры для версии 0.4, которые не работают в версии 0.5. На написание кода для данного фреймворка я потратил в несколько раз больше времени, исправляя ошибки компиляции после копирования примеров из документации. Вероятно, если хорошо изучить фреймворк, это перестанет быть такой проблемой, но для новичка определённо существенный минус.

Официальный сайт: https://rocket.rs/
Количество загрузок на crates.io: 3,7 миллиона

Код

main.rs

use std::time::Duration;
use tokio::time::sleep;
use rocket::serde::json::Json;

#[macro_use] extern crate rocket;

#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct Data {
	payload: String
}

#[post("/test/simple", data = "<param>")]
async fn simple_endpoint(param: Json<Data>) -> Json<Data> {
	Json(Data {
		payload: format!("Hello, {}", param.into_inner().payload)
	})
}

#[post("/test/timed", data = "<param>")]
async fn timed_endpoint(param: Json<Data>) -> Json<Data> {
	sleep(Duration::from_millis(20)).await;
	Json(Data {
		payload: format!("Hello, {}", param.into_inner().payload)
	})
}

#[post("/test/bcrypt", data = "<param>")]
async fn bcrypt_endpoint(param: Json<Data>) -> Json<Data> {
	Json(Data {
		payload: bcrypt::hash(&param.into_inner().payload, 10).unwrap()
	})
}

#[launch]
fn rocket() -> _ {
	rocket::build()
		.configure(rocket::Config::figment()
			.merge(("address", "0.0.0.0"))
			.merge(("port", 3000))
		)
		.mount("/", routes![
			simple_endpoint,
			timed_endpoint,
			bcrypt_endpoint
		])
}

Cargo.toml

[package]
name = "rust_web_benchmark"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = "1"
rocket = { version = "0.5.0-rc.3", features = ["json"] }
serde = { version = "1.0.189", features = ["derive"] }
bcrypt = "0.15.0"

Бенчмарк

В качестве бенчмарка напишем простое приложение, порождающее N параллельных задач, каждая из которых должна отправить M запросов на указанный URL. Измеряется время успешных (200 OK) запросов (в микросекундах), неуспешные запросы просто подсчитываются. Для результата тестирования вычисляются среднее арифметическое и медианное значения, а также количество запросов в секунду (количество успешных запросов, делённое на полное время между запуском первой задачи и окончанием последней задачи).

Используется tokio и библиотека reqwest.

Код

main.rs

use reqwest::StatusCode;

static REQ_PAYLOAD: &str = "{\n\t\"payload\": \"world\"\n}\n";

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
	let args = std::env::args().collect::<Vec<_>>();
	if args.len() != 4 {
		println!("Usage: {} url thread-count request-count", args[0]);
		return Ok(());
	}
	
	let url = args[1].clone();
	let request_count = args[2].parse().unwrap();
	let thread_count = args[3].parse().unwrap();
	
	let client = reqwest::Client::new();
	let start = std::time::Instant::now();
	let handles = (0..thread_count).map(|_| {
		let url = url.clone();
		let client = client.clone();
		tokio::spawn(async move {
			let mut error_count = 0;
			let mut results = Vec::new();
			for _ in 0..request_count {
				let start = std::time::Instant::now();
				let res = client
					.post(&url)
					.header("Content-Type", "application/json")
					.body(REQ_PAYLOAD)
					.send()
					.await
					.unwrap();
				if res.status() == StatusCode::OK {
					res.text().await.unwrap();
					let elapsed = std::time::Instant::now().duration_since(start);
					results.push(elapsed.as_micros());
				} else {
					error_count += 1;
				}
			}
			(results, error_count)
		})
	}).collect::<Vec<_>>();
	
	let mut results = Vec::new();
	let mut error_count = 0;
	for handle in handles {
		let (out, err_count) = handle.await.unwrap();
		results.extend(out.into_iter());
		error_count += err_count;
	}
	let elapsed = std::time::Instant::now().duration_since(start);
	let rps = results.len() as f64 / elapsed.as_secs_f64();
	
	results.sort();
	println!(
		"average={}us, median={}us, errors={}, total={}, rps={}",
		results.iter()
			.copied()
			.reduce(|a, b| a + b).unwrap() / results.len() as u128,
		results[results.len() / 2],
		error_count,
		results.len(),
		rps
	);
	Ok(())
}

Cargo.toml

[package]
name = "bench"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
reqwest = "0.11.22"

Результаты

Каждый сервис был запущен в своём Docker-контейнере (для удобства отслеживания потребления ресурсов). Затем запускался бенчмарк к одному и тому же эндпоинту по очереди к каждому сервису. После этого все контейнеры перезапускались и процесс повторялся для следующего эндпоинта и т. д. Полный тест был повторён три раза для трёх разных порядков тестирования контейнеров (чтобы исключить преимущество в тесте из-за возможного троттлинга после первого теста или наоборот из-за возможного выхода процессора из энергосберегающего режима после первого теста), а результаты усреднены.

Для эндпоинта simple и timed использовалось 100 задач по 100 запросов. Для эндпоинта bcrypt использовалось 10 задач по 50 запросов.

ТестМетрикаAxumActixRocket
SimpleСреднее (мс)7,7277,23912,971
Медиана (мс)3,6983,19,097
RPS12010124837419
TimedСреднее (мс)25,92225,76426,402
Медиана (мс)22,37921,90622,659
RPS379937893696
BcryptСреднее (мс)493505501
Медиана (мс)474486503
RPS938691

Как можно заметить, Axum и Actix идут ноздря в ноздрю по производительности, при этом Actix — немного вырывается вперёд. Rocket — явный аутсайдер по производительности. Следует учитывать, что тест всё же синтетический и в реальных приложениях вся разница в производительности съестся на бизнес-логике, запросах к БД и внешним сервисам и т. д. (собственно, это можно наблюдать на тестах timed и bcrypt - разрыв между всеми тремя фреймворками становится почти незаметным).

Потребление ОЗУAxumActixRocket
После запуска0,75 MiB1,3 MiB0,97 MiB
Во время теста (максимум)71 MiB71 MiB102 MiB
После теста0,91 MiB2,4 MiB1,8 MiB

По потреблению оперативной памяти Axum — однозначный победитель, Actix потребляет сопоставимое количество ОЗУ под нагрузкой, но вот в простое, особенно после первой нагрузки, потребяет больше всех. Rocket — среднячок по потреблению ОЗУ в простое, однако под нагрузкой потребляет на треть больше.

Заключение

Мой фаворит по результатам обзора — Axum. Самое большое сообщество и хорошая документация, много примеров, высокая производительность, наиболее экономное потребление ОЗУ (особенно актуально при разработке микросервисов). Отставание от Actix в производительности незначительно и может объясняться погрешностью методики тестирования, но даже если нет, то так как Axum самый молодой фреймворк, скорее всего разрыв исчезнет по мере его развития и выпуска обновлений. Возможность описывать эндпоинты без использования макросов очень удобна. 

На второе место я бы поставил Actix, у которого не хуже документация, чуть выше производительность в некоторых сценариях и хорошее потребление памяти под нагрузкой. Но макросы и высокое потребление памяти в простое являются его существенными минусами. 

Каких-либо преимуществ у Rocket на текущий момент я не вижу. Возможно, он был выдающимся фреймворком на момент своего появления в 2016 году, первопроходцем, но сейчас он проигрывает и по потреблению памяти, и по производительности новым фреймворкам, до сих пор имеет проблемы со stable веткой Rust и имеет запутанную документацию из-за ломающих изменений между версиями 0.4 и 0.5.

Исходный код бенчмарка на GitHub

Обсудить в Telegram