В этой статье мы сравним производительность 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.io23 миллиона

Код

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.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.io5,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.

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

  • Типобезопасность

  • Свобода от шаблонного кода

  • Простой, интуитивно понятный API

  • Расширяемость

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

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

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

Официальный сайт: https://rocket.rs/
Количество загрузок на crates.io3,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 запросов.

Тест

Метрика

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

Комментарии (12)


  1. Revertis
    22.10.2023 11:58
    +1

    Возможность описывать эндпоинты без использования макросов очень удобна. 

    Так у Actix-web тоже можно без макросов их задавать, через билдер.


    1. orekh
      22.10.2023 11:58

      Может плохо помню, но когда несколько лет назад смотрел доки актикса, то он очень плохо объяснял свою макросную магию. Там емнип доки вообще были 5 страничным «обмазываете макросом майн, потом обработчики, и оно магически связывается». Разумеется, чуть в сторону и вся магия ломается - ни обработчик в отдельный файл вынести, ни поменять возвращаемый тип. Да, можно поспрашивать всё у сообщества (или самому развернуть маркросы?), но нафиг такую мороку.


      1. Gorthauer87
        22.10.2023 11:58
        +1

        Не пользовался вообще макросами в актиксе, в первых версиях их и не было, там код получался похожим на то, что пишут в axum


  1. sshikov
    22.10.2023 11:58
    +6

    собственно, это можно наблюдать на тестах timed и bcrypt - разрыв между всеми тремя фреймворками становится почти незаметным

    Собственно, это может означать простую вещь - что вы меряете что-то не то?


    1. KivApple Автор
      22.10.2023 11:58
      +2

      Я мерию накладные расходы фреймворков. При росте константной задержки (вызванной вычислениями или банальным sleep - этот код общий для всех трёх тестов) доля оверхеда в общих числах уменьшается. Разница между 7 и 12 мс (= 5 мс) может быть ощутима. Разница между 505 мс и 501 мс (= 4 мс) уже вряд ли будет кого-то волновать. Она не исчезает в абсолютных числах, но исчезает в относительных.


      1. sshikov
        22.10.2023 11:58

        Так я не говорю что вы прямо неправильно измеряли, а скорее что может быть и надо отображать чуть иначе? По сути, вам же только накладные расходы и интересны. Ну так, условно - померять, сколько времени займут одни только вычисления, и сравнить с вычислениями + REST?


        1. KivApple Автор
          22.10.2023 11:58

          Для sleep, думаю, его можно считать равным 20 мс, как в самом sleep и написано. Для bcrypt действительно можно было бы померить.

          По идее важно учесть количество рабочих тредов в этом случае, потому что, очевидно, 1 bcrypt и 10 bcrypt в разных тредах будут выполняться разное время (даже если ядер CPU больше 10, есть, например, тепловой троттлинг, который общий на всех).


          1. sshikov
            22.10.2023 11:58

            Ну, в случаях многозадачности/многопоточности вообще очень сложно мерять. Т.е. самый простой вариант - вы задачу решили быстрее, но ресурсов в форме ядра*часы или гигабайты*часы потребили больше. И хорошо ли это, в общем случае не очевидно. Потому что эти измерения - они все сделаны в рамках одного приложения, если же на машине приложений больше одного - все становится сильно веселее.


      1. domix32
        22.10.2023 11:58

        Так попробовали бы какой-нибудь профайлер подрубить и посмотреть, что фреймворки практически и не добавляют расходов. Основной жёр почти наверняка придётся на сериализацию-десериализацию в JSON. Поэтому согласен с предыдущим оратором, что вы определённо меряете что-то не то. Эксепримента ради верните plain text и замерьте заново.
        А что насчёт других фреймворков?


  1. mirecl
    22.10.2023 11:58

    Рекомендую попробовать делать такие bench c https://github.com/hatoo/oha

    P.S. Интересно ещё было бы посмотреть результаты с mTLS.


  1. Gorthauer87
    22.10.2023 11:58
    +5

    Принимает параметр в JSON, хеширует его алгоритмом bcrypt с параметром cost=10, возвращает результат в JSON

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

    use tokio::task;
    
    let result = task::block_in_place(|| {
        // do some compute-heavy work or call synchronous code
        "blocking completed"
    });
    
    assert_eq!(result, "blocking completed");

    Тут можно больше про это почитать


  1. grozovoeoblako
    22.10.2023 11:58

    Не хватает второго теста запущенного сервиса, чтобы видеть расход памяти.

    То что actix в пике скушал столько же сколько победитель, никак не говорит о его ущербности.