Обратите внимание: я сам новичок как в Rust, так и в целом в программировании и в коде могут быть ошибки.
Статья состоит из компиляции моего немногочисленного опыта и мнения, а также немного сравнения характеристик двух сферических коней в вакууме.
О Rust я слышал ещё несколько лет назад и все его либо хватили, либо порицали, по различным причинам. Но сам как-то не брался за него - мне, неподготовленному к подобному синтаксису и не знакомому с подобными языками хотя бы на базовом уровне, в то время он казался совершенно непонятным. Но вот спустя время для себя решил написать что-то похожее на бенчмарк для тестов локальных HTTP API-серверов.
Об этом и моём опыте и пишу статью - вдруг кому из новичков окажусь полезен.
Первая версия такого "бенчмарка" была написана на Go. В целом эта версия меня устраивала, Go хорошо подходит для небольших приложений и, в отличии от Rust, имеет библиотеку для работы с HTTP в стандартном пакете, а fasthttp работает ещё лучше. Но всё-же вес бинарника в целых 5 Мбайт (это уже после -ldflags "-s -w") немного смущал.
И так, для лёгкой реализации идиоматичного приложения на Rust нам нужны легковесные потоки (они же - горутины), к счастью их нам может предоставить Tokio! Эта библиотека может дать нам функционал Go в виде корутин и каналов, но только в Rust и лучше.
Также будем использовать парсер аргументов командной строки - argparse, и для "глобальных переменных" макрос lazy_static.
Итого Cargo.toml:
[package]
name = "akvy"
version = "0.2.0"
edition = "2021"
[dependencies]
tokio = { version = "1.24.2", features = ["full"] }
hyper = { version = "0.14", features = ["full"] }
lazy_static = "1.4.0"
argparse = "0.2.2"
[profile.release]
lto = true
strip = true
В профиле настройки для уменьшения размера. Strip т.к. всё-равно не предполагается отладка приложения вне дебаг режима, а бинарник хочется уменьшить максимально.
// Записываем время начала, чтобы посчитать время ответа
let start = Instant::now();
// Создаём объект клиента и совершаем запрос по переданному URL.
let client = Client::new();
let resp = client.get(uri).await;
/*
Тут lock в { .. } чтобы сразу же отдать блокировку.
На сколько знаю - компилятор должен в этой же
области видимости отдать блокировку.
*/
{
REQ_TIME
.lock()
.unwrap()
.push(start.elapsed().as_millis() as u32);
}
// Если случайная ошибка - ERRORS += 1 и выходим из функции
if resp.is_err() {
*ERRORS
.lock()
.unwrap() += 1;
return;
}
// Если ошибка HTTP - ERRORS += 1
if !resp.unwrap().status().is_success() {
*ERRORS
.lock()
.unwrap() += 1;
}
}
Кстати о "глобальных переменных" - это два Arc<Mutex<T>>, запакованные в lazy_static! { … } макрос:
lazy_static! {
// Хранит массив u32 в миллисекундах,
// по нему считается среднее, максимальное и минимальное
// время ответа, а по количеству элементов в массиве - количество запросов.
static ref REQ_TIME: Arc<Mutex<Vec<u32>>> = Arc::new(Mutex::new(Vec::new()));
// u128, в котором хранится количество ошибок.
static ref ERRORS: Arc<Mutex<u128>> = Arc::new(Mutex::new(0));
}
Немного об Arc<Mutex<T>>
Arc<Mutex<T>> используется, чтобы безопасно читать и изменять переменные, работать с переменными под Mutex может только та функция, которая заблокировала этот Mutex, а после работы она разблокирует его и воспользоваться переменной сможет другая функция и т.д.
T - любой тип данных.
// Если URL содержит HTTPS, то закрываем приложение
if !url.contains("https://") {
let uri = url.parse();
if uri.is_err() {
println!("URL error!");
exit(1)
}
return uri.unwrap();
}
println!("App work only with HTTP!");
exit(1)
}
Здесь всё стандартно, помимо проверки на содержание в строке https:// - дело в том, что изначально Hyper не поддерживает HTTPS, нужно подключать другие зависимости, а во-первых, это, скорее всего, добавит места бинарнику, во-вторых - приложение должно тестировать локальные HTTP-сервера, а не атаковать чужие HTTPS сайты, а в-третьих - мне лень пока.
В функции используется стандартный метод .parse(), а всё остальное просто удобная оболочка.
let mut url_in = String::from("http://localhost:8080");
let mut rps: u16 = 10;
И парсим аргументы командной строки:
{
// Создаём объект парсера и описание
let mut ap = ArgumentParser::new();
ap.set_description("Set app parameters");
// Парсим URL в переменную url_in
ap.refer(&mut url_in)
.add_option(
&["-u", "--url"], // Флаги
Store, // Store - положить значение в переменную
"Target URL for bench"); // Описание для -h
// Парсим RPS в переменную rps
ap.refer(&mut rps)
.add_option(
&["-r", "--rps"],
Store,
"Target number of requests per second"
);
// Сам парсинг аргументов
ap.parse_args_or_exit();
}
Далее парсим нашу строку в Uri и выводим характеристики бенчмарка в консоль:
let url = parse_url(url_in);
println!("\n{} | {}", url, rps);
// И записываем время начала теста
let start = Instant::now();
Также нужно создать наш "бесконечный" цикл, который будет с определённым интервалом вызывать функцию get(url) в отдельном таске (task, та же горутина).
let mut interval = time::interval(Duration::from_micros(1_000_000 / rps as u64));
// Создаём главный таск,
// который в цикле будет создавать другие таски
tokio::spawn(async move {
loop {
// Клонируем URL из main в область видимости цикла,
// концепция владения ведь
let url = url.clone();
// Создаём таск, в котором будет работать запрос
tokio::spawn(async move {
get(url).await; // await обязателен, т.к. функция async
});
// Ждём заданное время и обнуляем интервал,
// после повторяем цикл
interval.tick().await;
}
});
Здесь мы создаём Interval с периодичностью в нужное нам время. Важно заметить, что не получится использовать просто tokio::time::sleep т.к. на интервалы менее ~100 микросекунд такой цикл не будет способен. Sleep будет спать не меньше указанного времени, а больше может.
Т.к. главный цикл крутится в другом таске - приложение идёт дальше и нам нужно его корректно завершить. ИМХО лучший способ - обработать Ctrl + C в консоли:
// Создаём обработчик сигнала Ctrl + C
let mut stream = signal(SignalKind::interrupt()).unwrap();
// Ждём сигнала, не пускаем приложение дальше без него
stream.recv().await;
// Записываем время
let end = start.elapsed();
А далее следует огромный блок с выводом информации
{
// Переносим данные из Mutex в локальные переменные
let req = REQ_TIME.lock().unwrap().to_vec();
let err = *ERRORS.lock().unwrap();
// Считаем минимальное время ответа
let min: u32 = match req.iter().min() {
Some(min) => *min,
None => 0
};
// Считаем максимальное время ответа
let max: u32 = match req.iter().max() {
Some(max) => *max,
None => 0
};
// Считаем среднее время ответа
// проверяем на 0 элементов в массиве
let sum = req.iter().sum::<u32>() as u128;
let average: u32 = {
if sum != 0 {
(sum as u32 / req.len() as u32) as u32
} else {
0
}
};
// Красиво выводим всю накопившуюся информацию
print!("\n\n");
println!("Elapsed: {:.2?}", end);
println!("Requests: {}", &req.len());
println!("Errors: {}", err);
println!("Percent of errors: {:.2}%", percent_of_errors(req.len(), &err));
println!("Response time: \
\n - Min: {}ms \
\n - Max: {}ms \
\n - Average: {}ms", min, max, average);
}
*Результат минутного теста в Go:
{
"req_count": 471213,
"err_count": 441348,
"average_response_time_ms": 68.38669,
"max_response_time_ms": 7031,
"min_response_time_ms": 0,
"time_of_bench_sec": 61.92429,
"percent_of_errors": 93.6621
}
*Результат минутного теста в Rust:
http://httpbin.org/ip | 10000
Elapsed: 60.64s
Requests: 606176
Errors: 603539
Percent of errors: 99.56%
Response time:
- Min: 0ms
- Max: 36195ms
- Average: 17ms
Это что, получается, Go потребляет меньше ОЗУ, чем Rust? Пластмассовый мир победил?
Ну, не совсем... Как можно заметить из результатов обоих минутных тестов - Go недоделал ещё 130К положенных запросов, отсюда и потребление памяти меньше. Но всё-же он очень порадовал, а точнее не сам Go, а fasthttp. Если бы мы использовали стандартную библиотеку http, то разрыв и по ОЗУ, и по количеству запросов был бы намного больше.
Понятное дело, что всё это просто циферки и они не отображают реального положения дел, но всё же они есть и я их показал. И да, это было ожидаемо.
Статья состоит из компиляции моего немногочисленного опыта и мнения, а также немного сравнения характеристик двух сферических коней в вакууме.
О Rust я слышал ещё несколько лет назад и все его либо хватили, либо порицали, по различным причинам. Но сам как-то не брался за него - мне, неподготовленному к подобному синтаксису и не знакомому с подобными языками хотя бы на базовом уровне, в то время он казался совершенно непонятным. Но вот спустя время для себя решил написать что-то похожее на бенчмарк для тестов локальных HTTP API-серверов.
Об этом и моём опыте и пишу статью - вдруг кому из новичков окажусь полезен.
Первая версия такого "бенчмарка" была написана на Go. В целом эта версия меня устраивала, Go хорошо подходит для небольших приложений и, в отличии от Rust, имеет библиотеку для работы с HTTP в стандартном пакете, а fasthttp работает ещё лучше. Но всё-же вес бинарника в целых 5 Мбайт (это уже после -ldflags "-s -w") немного смущал.
В тот момент я и решил, что надо бы попробовать это исправить и переписать на Rust, т.к. на C++ у меня не хватит ни навыков, ни терпения.Понятное дело, что в мире, где некоторые люди пишут небольшие приложения на Java с итоговым весом под 100 Мбайт, моё приложение кажется очень лёгким, но лично меня это не устраивало.
Основные минусы первой версии "бенчмарка" на Go:
- Вес итогового бинарника. Даже после -ldflags "-s -w" и стрипания (которое отнимает всего около 100-200 Кбайт) это как-то много.
- Потребление RAM выше, чем могло бы быть. Особенно разница чувствуется на небольшом количестве запросов, если запросов 10К или более - разницы почти нет.
- Нестабильная работа "главной" Go-рутины, которая при целевом RPS (request per second) в 1К могла выдавать от 600 до ~800 запросов в секунду.
И так, для лёгкой реализации идиоматичного приложения на Rust нам нужны легковесные потоки (они же - горутины), к счастью их нам может предоставить Tokio! Эта библиотека может дать нам функционал Go в виде корутин и каналов, но только в Rust и лучше.
И так, "рантайм" мы себе нашли - Tokio, но в Rust нет ещё и стандартной библиотеки для работы с HTTP, здесь я решил использовать Hyper, т.к. Reqwest просто огромна и работает даже хуже стандартной библиотеки в Go, а ureq всё-равно больше, чем Hyper, а по производительности вряд ли отличается."Лучше" в плане меньшего веса бинарника, и как мне кажется, большей производительности из-за самого языка.
Также будем использовать парсер аргументов командной строки - argparse, и для "глобальных переменных" макрос lazy_static.
Итого Cargo.toml:
[package]
name = "akvy"
version = "0.2.0"
edition = "2021"
[dependencies]
tokio = { version = "1.24.2", features = ["full"] }
hyper = { version = "0.14", features = ["full"] }
lazy_static = "1.4.0"
argparse = "0.2.2"
[profile.release]
lto = true
strip = true
В профиле настройки для уменьшения размера. Strip т.к. всё-равно не предполагается отладка приложения вне дебаг режима, а бинарник хочется уменьшить максимально.
Начнём же разбирать код.
Для самих нетерпеливых вот ссылка на GitHub с актуальным кодом, а здесь мы разберём основные моменты с пояснениями.Начать стоит с главной функции всего приложения
async fn get(uri: Uri) {// Записываем время начала, чтобы посчитать время ответа
let start = Instant::now();
// Создаём объект клиента и совершаем запрос по переданному URL.
let client = Client::new();
let resp = client.get(uri).await;
/*
Тут lock в { .. } чтобы сразу же отдать блокировку.
На сколько знаю - компилятор должен в этой же
области видимости отдать блокировку.
*/
{
REQ_TIME
.lock()
.unwrap()
.push(start.elapsed().as_millis() as u32);
}
// Если случайная ошибка - ERRORS += 1 и выходим из функции
if resp.is_err() {
*ERRORS
.lock()
.unwrap() += 1;
return;
}
// Если ошибка HTTP - ERRORS += 1
if !resp.unwrap().status().is_success() {
*ERRORS
.lock()
.unwrap() += 1;
}
}
Кстати о "глобальных переменных" - это два Arc<Mutex<T>>, запакованные в lazy_static! { … } макрос:
lazy_static! {
// Хранит массив u32 в миллисекундах,
// по нему считается среднее, максимальное и минимальное
// время ответа, а по количеству элементов в массиве - количество запросов.
static ref REQ_TIME: Arc<Mutex<Vec<u32>>> = Arc::new(Mutex::new(Vec::new()));
// u128, в котором хранится количество ошибок.
static ref ERRORS: Arc<Mutex<u128>> = Arc::new(Mutex::new(0));
}
Немного об Arc<Mutex<T>>
Arc<Mutex<T>> используется, чтобы безопасно читать и изменять переменные, работать с переменными под Mutex может только та функция, которая заблокировала этот Mutex, а после работы она разблокирует его и воспользоваться переменной сможет другая функция и т.д.
T - любой тип данных.
Сразу же рассмотрим функцию парсинга из текста в Uri:
fn parse_url(url: String) -> Uri {// Если URL содержит HTTPS, то закрываем приложение
if !url.contains("https://") {
let uri = url.parse();
if uri.is_err() {
println!("URL error!");
exit(1)
}
return uri.unwrap();
}
println!("App work only with HTTP!");
exit(1)
}
Здесь всё стандартно, помимо проверки на содержание в строке https:// - дело в том, что изначально Hyper не поддерживает HTTPS, нужно подключать другие зависимости, а во-первых, это, скорее всего, добавит места бинарнику, во-вторых - приложение должно тестировать локальные HTTP-сервера, а не атаковать чужие HTTPS сайты, а в-третьих - мне лень пока.
В функции используется стандартный метод .parse(), а всё остальное просто удобная оболочка.
Теперь пройдёмся по main() сверху вниз.
Задаём стандартные характеристики для приложенияlet mut url_in = String::from("http://localhost:8080");
let mut rps: u16 = 10;
И парсим аргументы командной строки:
{
// Создаём объект парсера и описание
let mut ap = ArgumentParser::new();
ap.set_description("Set app parameters");
// Парсим URL в переменную url_in
ap.refer(&mut url_in)
.add_option(
&["-u", "--url"], // Флаги
Store, // Store - положить значение в переменную
"Target URL for bench"); // Описание для -h
// Парсим RPS в переменную rps
ap.refer(&mut rps)
.add_option(
&["-r", "--rps"],
Store,
"Target number of requests per second"
);
// Сам парсинг аргументов
ap.parse_args_or_exit();
}
Далее парсим нашу строку в Uri и выводим характеристики бенчмарка в консоль:
let url = parse_url(url_in);
println!("\n{} | {}", url, rps);
// И записываем время начала теста
let start = Instant::now();
Также нужно создать наш "бесконечный" цикл, который будет с определённым интервалом вызывать функцию get(url) в отдельном таске (task, та же горутина).
let mut interval = time::interval(Duration::from_micros(1_000_000 / rps as u64));
// Создаём главный таск,
// который в цикле будет создавать другие таски
tokio::spawn(async move {
loop {
// Клонируем URL из main в область видимости цикла,
// концепция владения ведь
let url = url.clone();
// Создаём таск, в котором будет работать запрос
tokio::spawn(async move {
get(url).await; // await обязателен, т.к. функция async
});
// Ждём заданное время и обнуляем интервал,
// после повторяем цикл
interval.tick().await;
}
});
Здесь мы создаём Interval с периодичностью в нужное нам время. Важно заметить, что не получится использовать просто tokio::time::sleep т.к. на интервалы менее ~100 микросекунд такой цикл не будет способен. Sleep будет спать не меньше указанного времени, а больше может.
Т.к. главный цикл крутится в другом таске - приложение идёт дальше и нам нужно его корректно завершить. ИМХО лучший способ - обработать Ctrl + C в консоли:
// Создаём обработчик сигнала Ctrl + C
let mut stream = signal(SignalKind::interrupt()).unwrap();
// Ждём сигнала, не пускаем приложение дальше без него
stream.recv().await;
// Записываем время
let end = start.elapsed();
А далее следует огромный блок с выводом информации
{
// Переносим данные из Mutex в локальные переменные
let req = REQ_TIME.lock().unwrap().to_vec();
let err = *ERRORS.lock().unwrap();
// Считаем минимальное время ответа
let min: u32 = match req.iter().min() {
Some(min) => *min,
None => 0
};
// Считаем максимальное время ответа
let max: u32 = match req.iter().max() {
Some(max) => *max,
None => 0
};
// Считаем среднее время ответа
// проверяем на 0 элементов в массиве
let sum = req.iter().sum::<u32>() as u128;
let average: u32 = {
if sum != 0 {
(sum as u32 / req.len() as u32) as u32
} else {
0
}
};
// Красиво выводим всю накопившуюся информацию
print!("\n\n");
println!("Elapsed: {:.2?}", end);
println!("Requests: {}", &req.len());
println!("Errors: {}", err);
println!("Percent of errors: {:.2}%", percent_of_errors(req.len(), &err));
println!("Response time: \
\n - Min: {}ms \
\n - Max: {}ms \
\n - Average: {}ms", min, max, average);
}
Сравним Go и Rust
Само это сравнение уже является неправильным, аморальным и должно караться полицией нравов, но мы это сделаем. Да, сравним высокоуровневый Go с низкоуровневым Rust. Само по себе это сравнение уже похвала для Go, ведь никто и не заикается сравнивать, например, Python и Rust в производительности, а Go - постоянно.Меряемся циферками:
Все тесты проводились на моём ноутбуке - MacBook Air M1 8gb, HTTP запросы на http://httpbin.org/ip | Rust | Go |
Вес бинарника | 1.5 Мбайт | 5.6 Мбайт |
Потребление RAM спустя минуту на 10К RPS | 28.6 Мбайт* | 25.7 Мбайт* |
Время выполнения 100К запросов при установленном лимите 10К в сек. | 10.03 сек. | 12.09 сек. |
{
"req_count": 471213,
"err_count": 441348,
"average_response_time_ms": 68.38669,
"max_response_time_ms": 7031,
"min_response_time_ms": 0,
"time_of_bench_sec": 61.92429,
"percent_of_errors": 93.6621
}
*Результат минутного теста в Rust:
http://httpbin.org/ip | 10000
Elapsed: 60.64s
Requests: 606176
Errors: 603539
Percent of errors: 99.56%
Response time:
- Min: 0ms
- Max: 36195ms
- Average: 17ms
Это что, получается, Go потребляет меньше ОЗУ, чем Rust? Пластмассовый мир победил?
Ну, не совсем... Как можно заметить из результатов обоих минутных тестов - Go недоделал ещё 130К положенных запросов, отсюда и потребление памяти меньше. Но всё-же он очень порадовал, а точнее не сам Go, а fasthttp. Если бы мы использовали стандартную библиотеку http, то разрыв и по ОЗУ, и по количеству запросов был бы намного больше.
Понятное дело, что всё это просто циферки и они не отображают реального положения дел, но всё же они есть и я их показал. И да, это было ожидаемо.
Плюсы и минусы Rust в сравнении с Go
Плюсы:- Производительность
- Размер бинарника
- Отсутствие GC (Сборщика мусора)
- Отсутствие рантайма
- Хорошее ООП (Да, не стандартное, но этим оно и нравится мне, ИМХО)
- Умный компилятор со множеством оптимизаций.
- Совместимость по памяти. На Rust можно написать библиотеку к Go, Python, Ruby и т.д. Или использовать совместно с C/C++
- Сложность в освоении. Как в освоении синтаксиса, концепции владения и времени жизни, так и в библиотеках, которыми пользоваться иногда в разы сложнее, чем в Go.
- Сложнее делать кроссплатформенное приложение. Например, из под моего M1 не получится скомпилировать Rust в бинарник для Linux или Windows, а Go - легко.
- VSCode, настроенный под Rust, просто отвратителен, опять же - ИМХО. Да и я не настраивал его три часа, как некоторые рекомендуют в таких ситуациях.
- Сам не пробовал, но многие утверждают, что в Rust до сих пор бывают проблемы с async I/O. Утверждать не берусь, маловато опыта.
Как я приложение с Go на Rust переписывал
Дисклеймер Обратите внимание: я сам новичок как в Rust, так и в целом в программировании и в коде могут быть ошибки. Статья состоит из компиляции моего немногочисленного опыта и мнения, а также...
habr.com