В этой статье мы сравним производительность 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<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(¶m.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.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<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(¶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 = "<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(¶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<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 запросов.
Тест | Метрика | 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.
