Тест производительности Web-фреймворков для Rust
В этой статье мы сравним производительность 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. Наравне с этим преимуществом авторы заявляют следующее:
Декларативный парсинг параметров запросов с использованием extractor-ов
Простая и предсказуемая модель обработки ошибок
Генерация ответов с минимумом вспомогательного кода
Возможность использовать экосистему middleware, сервисов и утилит tower и tower-http
Качество документации высокое — у меня не возникло никаких проблем со следованием руководству для начинающих.
Главная функция приложения — обычная асинхронная функция 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) -> impl IntoResponse {
Json(Data {
payload: format!("Hello, {}", param.payload)
})
}
async fn timed_endpoint(Json(param): Json) -> impl IntoResponse {
sleep(Duration::from_millis(20)).await;
Json(Data {
payload: format!("Hello, {}", param.payload)
})
}
async fn bcrypt_endpoint(Json(param): Json) -> impl IntoResponse {
Json(Data {
payload: bcrypt::hash(¶m.payload, 10).unwrap()
})
}
#[tokio::main]
async fn main() -> Result<(), Box> {
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.xml
[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 года.
Ключевые преимущества заявленные разработчиками:
Типобезопасность
Богатство функций (HTTP/2, логгирование и т. д.)
Расширяемость
Экстремальная производительность
Для описания эндпоинтов используются макросы. Главная функция приложения совместима с обычной функцией 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) -> impl Responder {
HttpResponse::Ok().json(Json(Data {
payload: format!("Hello, {}", param.payload)
}))
}
#[post("/test/timed")]
async fn timed_endpoint(Json(param): Json) -> 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) -> impl Responder {
HttpResponse::Ok().json(Json(Data {
payload: bcrypt::hash(¶m.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.
Ключевые преимущества заявленные разработчиками:
Типобезопасность
Свобода от шаблонного кода
Простой, интуитивно понятный API
Расширяемость
Для определения обработчиков активно используются макросы, также используется свой специальный макрос 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 = "")]
async fn simple_endpoint(param: Json) -> Json {
Json(Data {
payload: format!("Hello, {}", param.into_inner().payload)
})
}
#[post("/test/timed", data = "")]
async fn timed_endpoint(param: Json) -> Json {
sleep(Duration::from_millis(20)).await;
Json(Data {
payload: format!("Hello, {}", param.into_inner().payload)
})
}
#[post("/test/bcrypt", data = "")]
async fn bcrypt_endpoint(param: Json) -> Json {
Json(Data {
payload: bcrypt::hash(¶m.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> {
let args = std::env::args().collect::>();
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::>();
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 запросов.
Тест | Метрика | Axum | Actix | Rocket |
Simple | Среднее (мс) | 7,727 | 7,239 | 12,971 |
Медиана (мс) | 3,698 | 3,1 | 9,097 | |
RPS | 12010 | 12483 | 7419 | |
Timed | Среднее (мс) | 25,922 | 25,764 | 26,402 |
Медиана (мс) | 22,379 | 21,906 | 22,659 | |
RPS | 3799 | 3789 | 3696 | |
Bcrypt | Среднее (мс) | 493 | 505 | 501 |
Медиана (мс) | 474 | 486 | 503 | |
RPS | 93 | 86 | 91 |
(подчёркивание выделяет лучший результат, курсив — худший)
Как можно заметить, Axum и Actix идут ноздря в ноздрю по производительности, при этом Actix — немного вырывается вперёд. Rocket — явный аутсайдер по производительности. Следует учитывать, что тест всё же синтетический и в реальных приложениях вся разница в производительности съестся на бизнес-логике, запросах к БД и внешним сервисам и т. д. (собственно, это можно наблюдать на тестах timed и bcrypt — разрыв между всеми тремя фреймворками становится почти незаметным).
Потребление ОЗУ | Axum | Actix | Rocket |
После запуска | 0,75 MiB | 1,3 MiB | 0,97 MiB |
Во время теста (максимум) | 71 MiB | 71 MiB | 102 MiB |
После теста | 0,91 MiB | 2,4 MiB | 1,8 MiB |
По потреблению оперативной памяти Axum — однозначный победитель, Actix потребляет сопоставимое количество ОЗУ под нагрузкой, но вот в простое, особенно после первой нагрузки, потребяет больше всех. Rocket — среднячок по потреблению ОЗУ в простое, однако под нагрузкой потребляет на треть больше.
Заключение
Мой фаворит по результатам обзора — Axum. Самое большое сообщество и хорошая документация, много примеров, высокая производительность, наиболее экономное потребление ОЗУ (особенно актуально при разработке микросервисов). Отставание от Actix в производительности незначительно и может объясняться погрешностью методики тестирования, но даже если нет, то так как Axum самый молодой фреймворк, скорее всего разрыв исчезнет по мере его развития и выпуска обновлений. Возможность описывать эндпоинты без использования макросов очень удобна.
На второе место я бы поставил Actix, у которого не хуже документация, чуть выше производительность в некоторых сценариях и хорошее потребление памяти под нагрузкой. Но макросы и высокое потребление памяти в простое являются его существенными минусами.
Каких-либо преимуществ у Rocket на текущий момент я не вижу. Возможно, он был выдающимся фреймворком на момент своего появления в 2016 году, первопроходцем, но сейчас он проигрывает и по потреблению памяти, и по производительности новым фреймворкам, до сих пор имеет проблемы со stable веткой Rust и имеет запутанную документацию из-за ломающих изменений между версиями 0.4 и 0.5.
Исходный код бенчмарка на GitHub
Обсуждение в моём персональном блоге в Telegram