Как стать автором
Обновить

Как я приложение с Go на Rust переписывал

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров18K
Дисклеймер

Обратите внимание: я сам новичок как в 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 хорошо подходит для вычислений. К тому же, никто не запрещает их совмещать.

Теги:
Хабы:
Всего голосов 42: ↑27 и ↓15+20
Комментарии56

Публикации