Дисклеймер
Обратите внимание: я сам новичок как в Rust, так и в целом в программировании и в коде могут быть ошибки.
Статья состоит из компиляции моего немногочисленного опыта и мнения, а также немного сравнения характеристик двух сферических коней в вакууме.
UPD после публикации
Спасибо всем, кто указывал на ошибки! Узнал много нового и полезного, а попутно исправил код в статье под новую версию 🙂
О Rust я слышал ещё несколько лет назад и все его либо хвалили, либо порицали, по различным причинам. Но сам как-то не брался за него — мне, неподготовленному к подобному синтаксису и не знакомому с подобными языками хотя бы на базовом уровне, в то время он казался совершенно непонятным. Но вот спустя время для себя решил написать что-то похожее на бенчмарк для тестов локальных HTTP API-серверов.
Об этом и моём опыте и пишу статью — вдруг кому из новичков окажусь полезен.
Первая версия такого «бенчмарка» была написана на Go. В целом эта версия меня устраивала, Go хорошо подходит для небольших приложений и, в отличии от Rust, имеет библиотеку для работы с HTTP в стандартном пакете, а fasthttp работает ещё лучше. Но всё-же вес бинарника в целых 5 Мбайт (это уже после -ldflags «-s -w») немного смущал.
Понятное дело, что в мире, где некоторые люди пишут небольшие приложения на Java с итоговым весом под 100 Мбайт, моё приложение кажется очень лёгким, но лично меня это не устраивало.
В тот момент я и решил, что надо бы попробовать это исправить и переписать на Rust, т.к. на C++ у меня не хватит ни навыков, ни терпения.
Основные минусы первой версии «бенчмарка» на Go:
-
Вес итогового бинарника. Даже после
-ldflags "-s -w"
и стрипания (которое отнимает всего около 100-200 Кбайт) это как-то много. -
Потребление RAM выше, чем могло бы быть. Особенно разница чувствуется на небольшом количестве запросов, если запросов 10К или более — разницы почти нет.
-
Нестабильная работа «главной» Go-рутины, которая при целевом RPS (request per second) в 1К могла выдавать от 600 до ~800 запросов в секунду.
О плюсах и минусах Go и Rust в сравнении расскажу далее.
Итак, для лёгкой реализации идиоматичного приложения на Rust нам нужны легковесные потоки (они же — горутины), к счастью их нам может предоставить Tokio! Эта библиотека может дать нам функционал Go в виде корутин и каналов, но только в Rust и лучше.
«Лучше» в плане меньшего веса бинарника, и как мне кажется, большей производительности из-за самого языка.
Итак, «рантайм» мы себе нашли — Tokio
, но в Rust нет ещё и стандартной библиотеки для работы с HTTP, здесь я решил использовать Hyper
, т.к. Reqwest
просто огромна и работает даже хуже стандартной библиотеки в Go, а ureq
всё-равно больше, чем Hyper, а по производительности вряд ли отличается.
Также будем использовать парсер аргументов командной строки — argparse.
Итого 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"] } argparse = "0.2.2" [profile.release] lto = true strip = true
В профиле настройки для уменьшения размера. Strip т.к. всё-равно не предполагается отладка приложения вне дебаг режима, а бинарник хочется уменьшить максимально.
Начнём же разбирать код.
Для самих нетерпеливых вот ссылка на GitHub с актуальным кодом, а здесь мы разберём основные моменты с пояснениями.
Начать стоит с главной функции всего приложения
async fn get(uri: Uri, client: Client<HttpConnector>) { // Записываем время начала, чтобы посчитать время ответа let start = Instant::now(); // Совершаем запрос по переданному URL и клиенту. match client.get(uri).await { // Если ответ есть, но ответ не 200 Ok(res) => { if !res.status().is_success() { *ERRORS.lock().unwrap() += 1; } }, // Если иная ошибка Err(_) => { *ERRORS.lock().unwrap() += 1; } } RESPONSE .lock() .unwrap() .add(start.elapsed().as_millis() as u32); }
Кстати о «глобальных переменных» — это два static Mutex<T>
// Под Mutex хранится структура с информацией // о количестве запросов, минимальном, максимальном и среднем // времени ответа от сервера static RESPONSE: Mutex<ResponseTime> = Mutex::new(ResponseTime::new()); // Просто u128, в котором хранится количество ошибок. // u128 потому, что можно ._. static ERRORS: Mutex<u128> = Mutex::new(0);
Немного об Mutex<T>
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)); // Создаём объект клиента, чтобы копировать его в get() let client = Client::new(); // Создаём главный таск, // который в цикле будет создавать другие таски tokio::spawn(async move { loop { // Клонируем URL и client из main в область видимости цикла, // концепция владения ведь :) let url = url.clone(); let client = client.clone(); // Создаём таск, в котором будет работать запрос tokio::spawn(async move { get(url, client).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();
А далее следует огромный блок с выводом информации
// Тут, в целом, всё понятно и без описания { let req = RESPONSE.lock().unwrap(); let err = *ERRORS.lock().unwrap(); print!("\n\n"); println!("Elapsed: {:.2?}", end); println!("Requests: {}", req.get_count()); println!("Errors: {}", err); println!("Percent of errors: {:.2}%", percent_of_errors(req.get_count(), &err)); println!("Response time: \ \n - Min: {}ms \ \n - Max: {}ms \ \n - Average: {}ms", req.get_min(), req.get_max(), req.get_average()); }
И функция вычисления процента ошибок, что используется при выводе:
fn percent_of_errors(req: u32, err: &u128) -> f32 { let res = (*err as f32 / req as f32) * 100.0; if res > 0 as f32 { res } else { 0 as f32 } }
Структура ResponseTime и её методы.
Если забыли, мы используем эту структуру в Mutex
в качестве глобальной переменной.
static RESPONSE: Mutex<ResponseTime> = Mutex::new(ResponseTime::new());
Изначально её не было ни в коде, ни в статье, соответственно. На её создание меня подтолкнул один из комментарием, что вместо Vec
с массивом из времён ответов можно использовать 4 переменные. И надеюсь, что я правильно понял идею…
Сама структура хранится в файле utils.rs
, а это уже отдельный crate (aka пакет, библиотека).
Структура выглядит так:
pub struct ResponseTime { average: u32, count: u32, min: u32, max: u32 }
И у неё несть несколько методов, которые нам стоит разобрать…
Во-первых это приватные методы проверки является ли переданное время ответа самым маленьким или самым большим из всех ранее переданных:
// Обе функции принимают ссылку на структуру, // методами которой они являются. // А также - сравнивоемое число u32. fn min_check(&mut self, item: u32) { self.min = self.min.min(item); } fn max_check(&mut self, item: u32) { self.max = self.max.max(item); }
Далее стоит разобрать главное «нововведение». Если раньше в приложении использовался вектор Vec<u32>
который хранил в себе время ответа для каждого запроса в отдельной переменной, то сейчас у нас используется лишь одна конкретная, не расширяемая переменная u32, которая в структуре ResponseTime
именуется average.
Преимущество в отсутствии аллокаций на куче и, по идее, большей производительности, чем при использовании Vec. Если я, конечно, всё правильно понял.
pub fn add(&mut self, new: u32) { // В переменную помещается новое среднее арифметическое, // вычисленное по такой вот формуле. // На самом деле при использовании этой формулы теряется точность // среднего арифметического, но по моим ощущениям - не сильно. // Возможно есть формула по-лучше, но я нашёл только эту, из рабочих. self.average = (self.average * self.count + new) / (self.count + 1); self.count += 1; // Вызываются описанные ранее функции с переданным новым значением. self.min_check(new); self.max_check(new); }
// Возвращает ResponseTime с заранее заданными полями pub const fn new() -> Self { Self { average: 0, count: 0, // При любом вызове min изменится на более корректное число, // если поставить 0 - минимальным временем ответа будет 0... min: 999_999_999, max: 0 } }
Сравним 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 сек. |
*Результат минутного теста в 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 в сравнении с Go
Плюсы:
-
Производительность
-
Размер бинарника
-
Отсутствие GC (Сборщика мусора)
-
Отсутствие рантайма
-
Хорошее ООП (Да, не стандартное, но этим оно и нравится мне, ИМХО)
-
Умный компилятор со множеством оптимизаций.
-
Совместимость по памяти. На Rust можно написать библиотеку к Go, Python, Ruby и т.д. Или использовать совместно с C/C++
Минусы:
-
Сложность в освоении. Как в освоении синтаксиса, концепции владения и времени жизни, так и в библиотеках, которыми пользоваться иногда в разы сложнее, чем в Go.
-
Сложнее делать кроссплатформенное приложение. Например, из под моего M1 не получится скомпилировать Rust в бинарник для Linux или Windows, а Go — легко.
-
VSCode, настроенный под Rust, просто отвратителен, опять же — ИМХО. Да и я не настраивал его три часа, как некоторые рекомендуют в таких ситуациях.
-
Сам не пробовал, но многие утверждают, что в Rust до сих пор бывают проблемы с async I/O. Утверждать не берусь, маловато опыта.
Собственно, это всё то немногое, что я успел узнать о Rust за пару месяцев ленивого изучения. Если нужен вывод — используйте то, что больше нравится. Go идеально подойдёт для API-серверов и подобного, где основная нагрузка — на сеть и накопители. А Rust хорошо подходит для вычислений. К тому же, никто не запрещает их совмещать.
ссылка на оригинал статьи https://habr.com/ru/articles/720382/
Добавить комментарий