Дисклеймер

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

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


  1. JavaNoob
    00.00.0000 00:00
    -15

    Не хватает в бенчмарке результатов apache ab

    Ну и, думаю, с прямыми руками те же самые ЯП можно ускорить ещё на десятки процентов, а то и в разы.

    "люди пишут небольшие приложения на Java с итоговым весом под 100 Мбайт"

    пук в лужу. или саму jvm посчитали? тогда не забудьте ещё ОС посчитать, оно же JVM запускает. в итоге чё там, 2гб против 2.1гб выйдет


    1. izogfif
      00.00.0000 00:00
      +2

      "люди пишут небольшие приложения на Java с итоговым весом под 100 Мбайт"

      Вроде через GraalVM можно сделать "нативный бинарник", в который нужные куски JVM будут вкомпилены / влинкованы статически. В результате будет меньше.


    1. keigisdead Автор
      00.00.0000 00:00

      В самой Java опыта не имею и именно на бекенд разработку, выполнение под JVM и подобное вообще не лезу, конкретно я имел ввиду скомпилированные бинарники из Java кода. Таких на GitHub хватает.

      ИМХО не должна весить небольшая консольная утилита под 100 Мбайт, это уже перебор как-то :)


      1. JavaNoob
        00.00.0000 00:00

        >ИМХО не должна весить небольшая консольная утилита под 100 Мбайт

        и зачем такие люди как ты статьи пишут? )

        ls -l target/mtLoader-1.0-jar-with-dependencies.jar

        3501 Mar 5 21:01 target/mtLoader-1.0-jar-with-dependencies.jar

        3501 БАЙТ, Карл, 3501 БАЙТ!

        внутри

        ` final String URL = "http://127.0.0.1:11456/tika"; HttpClient httpClient = HttpClient.newBuilder().build(); IntStream.range(1, 1_000_000).parallel().forEach(num -> {

        try {

        HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(URL)) .GET() .build(); httpClient.send(request, HttpResponse.BodyHandlers.discarding()); } catch (Exception ee) { ee.printStackTrace(); } }); System.out.println("used memory: " + ((Runtime.getRuntime().totalMemory()-(Runtime.getRuntime().freeMemory())) / 1048576) + " MB"); System.out.println("allocated memory: " + (Runtime.getRuntime().totalMemory() / 1048576) + " MB"); }

        `

        запуск с лимитом 15МБ

        java -Xmx15m -jar target/mtLoader-1.0-jar-with-dependencies.jar

        used memory: 8 MB
        allocated memory: 16 MB

        шах и мат )))

        ещё можно с 2017 года резать JRE на модули, гугли jigsaw, оставлю в качестве домашнего задания. Не думаю, что до 5МБ ужмется, но блин насколько это всё далеко от цитируемого бреда.

        p.s. 100МБ jar даже очень жирные сервисы не занимают, у которых внутри и spring (30МБ) с вебсервером и IOC контейнером и куча бизнес-фарша.

        p.p.s. Про apache ab то что скажем? Насколько помню, он легко 50k rps выжимает на локалхосте.


  1. slava_k
    00.00.0000 00:00
    +2

    Возможно, имеет смысл посмотреть в сторону K6 .


    1. keigisdead Автор
      00.00.0000 00:00

      Сама идея "написать свой бенчмарк" появилась, когда я ещё не нашёл другие удобные утилиты, но а после хотелось самому что-то подобное реализовать.


  1. ivankudryavtsev
    00.00.0000 00:00
    +18

    А почему в бенчмарке одни ошибки?

    Requests:            606176
    Errors:              603539
    Percent of errors:   99.56%


    1. Vest
      00.00.0000 00:00
      +22

      Потому, что он ДДОСил чужой сервер, а тот его блокировал. Кстати, такой тест считается провальным.
      Меня в этой истории не удивляет то, что человек просто переписал свою программу не вдаваясь в детали, почему предыдущая версия тормозила и какие у неё были боттлнеки.


      1. funny_falcon
        00.00.0000 00:00
        +3

        Случайно минус нажал. Прошу прощения.


        1. Vest
          00.00.0000 00:00
          +1

          Ничего, я не коллекционирую плюсы-минусы :)


      1. keigisdead Автор
        00.00.0000 00:00

        Про ДДОС сервера верно, httpbin мог начать отвечать ошибками при RPS от 1К.
        Я переписал приложение не потому, что не хотел чинить версию на Go, а потому, что хотел написать версию на Rust для обучения. Так-то понятно, что и Go версию можно оптимизировать, но размер бинарника вряд ли получится сильно уменьшить.

        И в целом, ничего против Go не имею, это сравнение не языков, а двух "сферических коней в вакууме".


    1. Mingun
      00.00.0000 00:00
      +5

      Видимо, автор был уверен, что только ошибки и будут, вон, аж u128 для подсчетов использовал :)


  1. eee
    00.00.0000 00:00
    -1

    Сравнивали с hey?


    1. rusik2293
      00.00.0000 00:00

      Глянул в исходник hey, вижу там net/http, это собственно стандартная библиотека про которую писал автор


    1. keigisdead Автор
      00.00.0000 00:00

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


  1. kivsiak
    00.00.0000 00:00
    +6

    А что за проблема с кроскомпиляцией? Под m1 нет тулчейна?


    1. keigisdead Автор
      00.00.0000 00:00

      С M1 не получается скомпилировать под x86 Windows или Linux. Сам особо не вникал, но ни одно решение из интернета не сработало.


  1. micronull
    00.00.0000 00:00
    +5

    Узким местом все равно будет база.


  1. miga
    00.00.0000 00:00
    +14

    Гошный код не показали, бенчмарк на чужом удаленном сервере (небось еще и по wifi). Даж не знаю, что автор тут тестировал, но точно не языки


    1. ShashkovS
      00.00.0000 00:00
      +6

      Размер бинаря же :)

      "Лучше" в плане меньшего веса бинарника


      1. rusik2293
        00.00.0000 00:00

        Интересно на кой нужен меньший вес бинарника, с роутера кого-то дудосить или я не в теме. Просто привык иметь дело с продом в несколько десятков терабайт места на ссд, смысл мне за пару мегабайт код преписывать


        1. DirectoriX
          00.00.0000 00:00
          -2

          Как минимум потому что чем меньше бинарь — тем меньше вероятность выпадения «горячих участков» из кеша. Особенно в случае с GC-языками, когда сборщик может запускаться достаточно редко, чтоб быть изгнанным из кеша, но достаточно регулярно чтоб постоянно загружаться обратно.


          1. 0xd34df00d
            00.00.0000 00:00
            +3

            Как минимум потому что чем меньше бинарь — тем меньше вероятность выпадения «горячих участков» из кеша.

            Для уменьшения этой вероятности нужно, чтобы горячие участик были меньше размером. Размер всего бинаря тут не столь важен.


        1. keigisdead Автор
          00.00.0000 00:00

          Не то, чтобы нужен, но я изучаю почти параллельно несколько языков и было интересно, на сколько меньше будет бинарник на Rust. Сам Go изначально задуман для бекенда, где размер бинарника не особо важен, а я хотел маленькую утилиту.


    1. t38c3j
      00.00.0000 00:00

      Код в соседней репе автора, https://github.com/xeynyty/go-bench


      1. miga
        00.00.0000 00:00
        +16

        Горутина на каждый реквест, действительно, куда ж все время и память уходит :)


    1. keigisdead Автор
      00.00.0000 00:00

      Я и сам писал об этом:

      [...а также немного сравнения характеристик двух сферических коней в вакууме.]

      Этот тест совсем не показательный, просто сравнение двух моих приложений с похожей архитектурой на двух языках. Ну а главным "камнем преткновения" был размер итогового бинарника.


  1. staticmain
    00.00.0000 00:00
    +3

    и стрипания (которое отнимает всего около 100-200 Кбайт)

    Да сколько раз посторять, что НЕЛЬЗЯ стрипать бинарники на go.


    1. t38c3j
      00.00.0000 00:00

      Если не требуется отлаживать или анализировать бинарник, то вполне годный компромисс. Или я чего-то не знаю?


      1. staticmain
        00.00.0000 00:00
        +2

        С 2011го года идеи срач, можно или нельзя. В мейлинг листе были сообщения, что это портит секции, что используется для менеджмента памяти рантайма.

        Официального объявления о том, что это починено не было. Отдельные индивиды утверждают, что всё ок: https://honnef.co/articles/stripping-go-elf-binaries-is-safe/


        1. DirectoriX
          00.00.0000 00:00
          +1

          Почитал статейку по ссылке: как будто go-компилятор не умел (не умеет?) правильно собрать заголовки, из-за чего strip делал как привык, а не как надо в этих странных случаях.
          Простите, но это бага компилятора, а не strip. По-хорошему, об этом надо кричать в баг-трекере, потому что некорректные заголовки бинаря — это вообще как так можно накодить компилятор??


          1. staticmain
            00.00.0000 00:00
            +2

            Так а где я сказал, что это баг strip?


            1. DirectoriX
              00.00.0000 00:00
              +5

              Так я тоже нигде не утверждал, будто вы сказали что это баг strip.
              Я хотел сказать, что обычно люди думают, что если нечто (бинарь) работало до воздействия (strip), но перестало после — значит виновато именно воздействие (и хотя в большинстве случаев это так, в этом конкретном примере виноваты сами бинари). И из вашего

              НЕЛЬЗЯ стрипать бинарники на go
              можно подумать, что это именно стрипанье их ломает, хотя оно лишь обнажает изначально некорректную структуру бинарей.
              А также я хотел сказать, что если корректное стрипанье показывает проблему из-за некорректности структуры (которая, вообще-то, описана задолго до go) — НУЖНО стрипать, чтобы заваливать разработчиков go баг-репортами. Если сегодня некорректная структура приводит к проблемам после strip, то кто знает, что может сломаться завтра? Стандарты и спецификации (в данном случае ELF) не просто так существуют.


    1. keigisdead Автор
      00.00.0000 00:00

      Не натыкался на этот момент ранее, спасибо. Да и в целом оно не много веса отнимает, так что я его и не использовал особо, но указать стоило для понимания.


    1. das_tier
      00.00.0000 00:00
      -1

      Кто ж нам запретит?


  1. MountainGoat
    00.00.0000 00:00
    +6

    С прошлого лета конструктор Mutex может быть static, поэтому уже можно создавать глобальные переменные и обойтись без lazy static.


  1. Cheater
    00.00.0000 00:00

    Стесняюсь спросить, зачем здесь асинхронщина с tokio? Если я правильно понял сценарий, то выполнить все запросы в цикле последовательно vs распараллелить никак не повлияет на результат теста, тк меряется сумма времён обработки запросов.


    1. keigisdead Автор
      00.00.0000 00:00

      Tokio использовал не из необходимости, а потому, что в Go приложении во всю использовалить корутины (горутины) и здесь хотелось чего-то похожего. К тому же это максимальное простое решение проблемы для новичка вроде меня. Т.е. мы просто на кажды запрос создаём отдельную корутину, которая и занимается обработкой своего запроса.


  1. commanderkid
    00.00.0000 00:00
    +2

    Особой разницы не заметил, да бинарник в 3 траза больше, потребление памяти такое же, а скорость на 10% больше. Не, в качестве изучения Rust норм. Короче паритет.


  1. google_fan
    00.00.0000 00:00
    +8

    Если вы считаете только max/min/average зачем вы храните массив со всеми данными (в хипе! С реалокациями!) , вместо того чтобы хранить четыре переменные u64?

    Вы каждом цикле клонируете строку ( опять же - реаллокация в хипе!) вместо того чтобы завернуть строку в Arc и получить почти бесплатный clone()?

    Какой рантайм токио вы использовали? Пробовали ли вы запустить это все в one thread runtime? Как писали выше у вас нет никакой конкурентности, вам не нужно запускать треды на каждом ядре.


    1. keigisdead Автор
      00.00.0000 00:00
      +1

      Спасибо за дельный совет, учту. Изначально хотел просто сделать идиоматически схожее приложение с Go версией.Под "рантаймом" подразумевал, скорее, некую схожесть с удобными корутинами в Go, ведь Tokio позволяет создавать миллионы тасков (task) и работать с ними также, как и в Go, что очень легко воспринимается при переходе между языками.

      Касательно

      Какой рантайм токио вы использовали? Пробовали ли вы запустить это все в one thread runtime? Как писали выше у вас нет никакой конкурентности, вам не нужно запускать треды на каждом ядре.

      Я, если честно, не совсем понял. Как я уже указывал в дисклеймере в начале статьи - я и сам новичёк в Rust и многого не знаю, но спасибо за упоминание, поищу информацию об этом.


      1. vlad4kr7
        00.00.0000 00:00
        +2

        *ERRORS.lock().unwrap() += 1;

        есть такое: https://doc.rust-lang.org/std/sync/atomic/index.html


  1. s_f1
    00.00.0000 00:00

    Плюсы:
    Отсутствие GC (Сборщика мусора)
    Кто-то может пояснить, почему это «плюс» языка?


    1. rutexd
      00.00.0000 00:00
      +2

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

      Отсутствие гк в связке с идеей владения в контексте раста - даёт не только буст по перфомансу но так же лишает части проблем, как например гонка состояний и заставляет программиста думать над своим кодом. Понятно, что работает не всегда и не везде, но процент кода на порядок выше чем у скажем какого нибудь питона или го.


      1. Vsevak
        00.00.0000 00:00

        В гарантиях там есть довольно четкие грани - по поводу "гонки состояний" можно уточнить, что Rust без unsafe исключает data race, который UB, но позволяет создавать race condition, которые логические ошибки.


        1. rutexd
          00.00.0000 00:00

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


    1. Vsevak
      00.00.0000 00:00
      +2

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


  1. Qudres
    00.00.0000 00:00

    Поскольку не приведен код на go, хочу уточнить у Вас один вопрос, как изменятся результаты если выключить gc.


    1. keigisdead Автор
      00.00.0000 00:00

      Отключать GC не пробовал, если честно. Но сам GC включается не так уж и часто, на сколько я мог заметить - само приложение обычно не живёт долго. В статье GC упоминал в целом, как особенность языка и его рантайма.


  1. JohnScience
    00.00.0000 00:00
    +1

    Большое спасибо за статью! А почему Arc<Mutex<u128>> вместо atomic'а?


    1. keigisdead Автор
      00.00.0000 00:00
      -1

      Спасибо за отзыв!

      Я новичок в Rust и использую в первую очередь то, до чего легче всего додуматься :)
      А об Arc я часто слышал в гайдах / лекциях, потому и использовал его.

      На самом деле сейчас переписываю код после прочтения комментариев под статьёй и скоро изменю её, в новой, предположительно, не будет Arc совсем, просто Mutex, т.к. это глобальная переменная и нет нужны считать количество ссылок. Но пока не точно :)


  1. rehci
    00.00.0000 00:00
    -1

    Если я правильно понял по результатам, то переписывание не стоило переписывания, Go показал себя вполне достойно.


  1. guvernir4
    00.00.0000 00:00
    -1

    Интересно узнать сколько ушло времени и сил на отладку кода на Rust. У rust всётаки порог вхождения повыше чем в go. Соответственно и отлаживать должно быть тяжелее.


    1. keigisdead Автор
      00.00.0000 00:00

      Сам Rust я, на момент написания, лениво изучал уже как пару месяцев. А на само прилодежение ушло пол дня, на первую версию. В основном спотыкался об особенности языка и Tokio, как те же sleep и interval в токио, котя в Go хватало лишь метода Sleep


  1. nuald
    00.00.0000 00:00
    +8

    Статья немного перекликается с работой, которую я проводил месяц назад. Скажу сразу, что для меня это был рабочий проект, так что он под NDA и исходники не могу выложить. Но у меня тоже было сравнение Go и Rust (и другие решения включая чистый Си), но только в совершенно другом масштабе: мне нужно было нагрузить наши железки высокоуровневым (высшие слои OSI) трафиком под завязку. iperf-тесты уже были, но нам нужен был реалистичный трафик.

    Мои результаты на localhost на моем рабочем linux ноутбуке (задейственны все ядра CPU, никаких мьютексов и других пожирателей CPU, статистика собиралась lock-free алгоритмами, сокеты с TCP_NODELAY):

    • Go - 400 тыс запросов секунду

    • Tokio/hyper - 300 тыс запросов в секунду (да, у меня эта пара оказалась медленнее, но у меня была немного другая методика тестирования)

    • Чистый epoll (rust и C показали примерно одинаковые результаты) - 600 тыс запросов в секунду

    • io_uring (пробовал только Rust, программировать на Си все-равно не собирался), тестировал пакеты glommio и monoio - 800 тыс. Остановился на последнем потому что есть fallback на epoll (старые linux) и kqueue (macos).

    Ещё хотел бы отметить про hyper - я от него в конце отказался полностью и написал специализированный генератор и парсер HTTP, благо протокол простой. Flamegraph показал что hyper тратит слишком много времени CPU, и хотя у него могут быть более корректные по стандарту парсеры, для benchmark-а он стал бутылочным горлышком.