Rust vs. Go backends

Rust vs. Go backends

skatromb


Какого чёрта я пишу про Go и Rust, учитывая, что никакого продакшн опыта с ними у меня нет? Дата инженер, всю жизнь писавший на SQL и Python рассказывает про то, как весело писать бэкенды на Гошечке?

Всё верно, мы тут на работе немножко поехали кукухой и решили, что нам нужно обязательно написать свою собственную проксю для ивент-стриминга. Непременно на Go, потому что язык компилируемый и быстрый. Никто до этого не писал на Go? Да ладно, навайбкодите там что-нибудь. А ещё, вторую часть прокси давайте сделаем на TypeScript. Что, и на TS никто из дата инженеров не писал? Да это ж практически тот же Python, чё там. Впрочем, о TS не сегодня.

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

По ходу дела (читай, спустя чуть менее год страданий), я решил развлечь себя переписыванием Go-части на Rust при помощи Клод Кода: хотелось отвлечься от набившего оскомину Go, да и потренироваться писать (ладно, ладно, вайбкодить) реальный проект на Rust. И вот, благодаря этому сегодня у меня есть для вас статья, к сожалению, без исходного кода (ибо NDA); но можете поверить на слово, что я старался писать максимально эквивалентный код с учетом разницы паттернов этих языков.

Developer experience

Тут я обязан расшаркаться перед Golang-ом. Всё-таки я потратил в своё время сотни часов на изучение Rust с его The book, прорешал все Rustlings, и даже после этого не чувствовал себя уверенно. Go же я в глаза не видел до того, как начался этот проект, я не прочёл ни одной книги, ни одной статьи о том, как на нём кодить. Вероятно, если бы я правильно его учил, мои впечатления были бы позитивнее. А может и нет.

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

Гошечка обещает, что будет просто
Раст-блейзин-фаст ясен-красен


Go хорош

Чистый синтаксис. Нет ненужных точек с запятой в конце строки, нет двоеточий в аннотациях типов, нет let-ов — в коде только то, что имеет значение. Единственное, до чего можно докопаться — это чтобы Go перешёл на indent-ы как в питоне вместо фигурных скобочек для вложенных блоков. Короче, читать Go легко, даже если вы видите его впервые, даже проще питона.

Выглядит примерно так:

// Shutdown gracefully stops the metrics collector.
func (mc *MetricsCollector) Shutdown() {
 close(mc.done)
 mc.ticker.Stop()
 mc.wg.Wait()
 mc.statsd.Close()
}

Легко изучить. В Go выпилена всякая трудночитаемая функциональщина, нет наследований и классов (хотя есть структуры и их функции). Единственными незнакомыми мне концептами оказались горутины (изи) и каналы (channels), но там всё просто, до тех пор, пока вам не надо вручную ими управлять.

Роскошная стандартная библиотека. Вот тут чистосердечный респект: здоровенная прокся была написана всего с несколькими внешними зависимостями: go-redissentry-godd-trace-go (DataDog), go-envtestify. Учитывая, что 3 их них — это либы для внешних сервисом, остаётся и вовсе только 2 внешних модуля. Всё остальное доступно из коробки: http сервер, структурированные логи, даты, время, json, параллелизм и асинк, io стримы и многое другое.

Зрелая экосистема. Го уже не новый язык, и практически всё, что может быть нужно разработчику для написания сервиса уже кто-нибудь заопенсорсил.

Go так себе

Гарантии компилятора. Всё-таки когда слышишь «статически типизированный компилируемый язык», ожидаешь, что компилятор отловит все ошибки до рантайма и не даст тебе облажаться. Но тут вам не Rust.

Например, можно сделать тип данных и его алиас, а затем сравнить такие переменные в функции DeepEqual(...), и компилятор не скажет ни слова. Зато в рантайме ассёрт упадёт, даже если содержимое действительно глубоко одинаковое

package main

import (
 "reflect"
 "testing"

 "github.com/stretchr/testify/assert"
)

func TestEqualityTypes(t *testing.T) {
 type Event map[string]any

 m := map[string]any{"user_id": "123"}
 e := Event{"user_id": "123"}

 assert.True(t, reflect.DeepEqual(m, map[string]any(e)))
 assert.True(t, reflect.DeepEqual(m, e))  // surprisingly `False`
}

Ручное управление параллелизмом. Гошечка обещает, что с горутинами параллелизм лёгок и прост. Как бы да, пока вам не нужно управлять тем, что происходит внутри. А если внутри у вас сервисы или код, которому важно выполниться в определённом порядке, то управлять вам будет нужно. Пример:

Если вам хочется хотя бы просто убедиться, что весь код в горутинах завершился, нужно руками при запуске горутины сказать явно хендлеру «я тут запустил горутину», а потом не забыть при завершении сказать «Я всё». Если забудешь что-то из этого — получишь дедлок, незавершённый процесс или панику.

В Go 1.25 наконец-то добавили функцию-декоратор wg.Go(), чтобы менеджить это автоматом, но удивительно, как долго язык жил без такой очевидной вещи.

Дефолтные значения для всего! Вот это реально самая бесючая вещь. В C при обращении к неинициализированной переменной, ты натыкался на «мусорное» значение. В Го решили это пофиксить, задав любым типам данных дефолтные значения: пустая стока, ноль и т.д. Звучит разумно, однако по факту перекладывает проблема на разработчика, который должен не пропустить дефолт там, где его быть не должно.

Например, я объявляю структуру S с полями a str, b str. Создаю переменную такого типа: S{a: "a", b: "b"} в нескольких местах в коде и использую её. Когда я решу, что пора её расширить ещё одним полем c str, я должен не забыть везде обновить создание переменной такой структуры, потому что в противном случае, я получу на месте c: "" вместо ошибки компиляции и осознанного фикса. Стреляет в колено с завидной регулярностью.

Нет базовых функций. Внезапно, в Гошечке до версии 1.21 не было даже min()! Натурально, его надо писать самому. Никаких вам map и filter и прочих функциональных штуковин — пиши for-loop и итерируй.

if err != nil.

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

Невесело. Тут полностью субъективно, но писать на Go мне вообще не доставляет удовольствия. И я слышал это от многих других разработчиков. Возможно, он слишком прост и не даёт получить удовольствия от решения задачи выразить сложную операцию просто, не знаю. Может, меня просто забесили недостатки выше.

Rust хорош

Гарантии от компилятора. Конечно, сказать про Rust «если оно компилируется, значит там нет багов» — большая натяжка. Однако, если я и встречал язык, дающий максимум гарантий корректности — то это, определённо, Rust. Все эти восхитительные Result, Option, ?, borrow checker и mut снижают вероятность пропустить ошибку практически к нулю. Никаких тебе неинициализированных или внезапных дефолтов — компилятор заругает.

Питон, даже будучи обвешанный всеми тайп-чекерами и раффами и близко не стоит (даже с Го, а не то, что с Растом).

cargo-культ. Даже если представить, что завтра Rust'а не станет, тот пример, который он показал своим cargo тулом останется в веках и повлияет на другие языки, показывая, как должна быть устроена стандартная CLI тулза языка программирования. Тут тебе и билд система, и самодостаточный линтер, и тест-раннер, и форматтер — литералли, всё, что нужно для разработки.

Тесты — first-class citizens. В Расте принято писать тесты в тех же файлах, что и основной код. Сначала мне показалось это странным — зачем мне «засорять» файлы с бизнес-логикой тестами? И вот на этой фразе бы и насторожиться. А почему, собственно, мы относимся к тестам, как к чему-то второсортному? А может, если тестов слишком много, что-то не так с логикой? Зато, если тесты лежат в файлах с имплементацией, меньше шансов, что забудешь их сразу пофиксить. Кроме того, тесты — отличная документация. И это ещё не упомянул как обмазульны doc-string тесты.

No data races. Да, ownership и borrowing в Расте не самые простые концепции, и порой удовлетворение компилятора занимает порядочное время. Однако, это меняет мышление в правильную сторону, когда ты априори делаешь гонку данных невозможной, что особенно важно, когда начинается параллелизм и асинк.

Кайф. Я, конечно, немножко мазохист — иначе бы я давно бросил учить Раст и плюнул на эти бодания с компилятором. А всё же, язык по мне кайфовый, и так думаю не только я, но и множество рейтингов Most admirable programming languages.

Блейзин фаст™.

Ну наконец-то, добрались! А впрочем, несмотря на то, что это реально сильная сторона Раста, для меня она не играет значительной роли, поскольку я не пишу перфоманс-критикал аппликейшнов. Однако, приятно знать, что у тебя есть быстрый язык в арсенале.

Отдельно отмечу, как легко написать либу на Расте под питон и скомпилировать её в пакет с помощью maturin. Документация огонь, оверхеда — на 5-20 строчек.

Rust так себе

Сложнааа. Все об этом говорят, вот и я тоже: выучи borrowing и ownership, lifetimes, учти что здесь нет Garbage Collector, разберись с trait и trait bounds, Enum и pattern matching, выучи что значит каждый из этих символов <&[(*)]>?, не забудь про существование mut, а также учти, что паттерн матчинг встречается даже в дефинициях функций:  (arg1: &Type, _: Type, &arg2: &Type).

Кстати, ещё есть макросы, tail expressions, ну и, конечно, функциональщина. Поздравляю, ты можешь написать хелловорлд.

Незрелая экосистема. Это боль. Стандартная библиотека? Не, не слышал. Тут даже джейсоны не из коробки! Хочешь использовать редкий корнер-кейс? поздравляю, он не покрыт в библиотеке, придётся писать самому. Чего далеко ходить, DataDog ещё не релизнул Rust версию своей библиотеки.

Куча соперничающих библиотек. Казалось бы, это противоречит пункту выше! Но нет, вместо одной библиотеки, которая является де-факто стандартом и умеет всё, что надо, вы встретите 3 примерно одинаково популярных веб-фреймворка. Какой выбрать? А для логгинга? А для http реквестов? Каждый раз приходится гуглить, вместо того, чтобы просто положиться на golden path.

Много способов сделать одно и тоже. Сам язык не отстаёт. У разных базовых структур в Расте огромное количество методов. Например, у трейта Iterator 75 методов, при этом с десяток из них делают очень похожие, но слегка разные вещи. Я уж не говорю про то, чтобы знать их все, я сдаюсь, — но даже выбрать из автокомплита бывает непросто. cargo clippy немного облегчает эту ситуацию, подсказывая, как можно переписать более идеоматично куски кода, но вот эта ситуация с туплением перед автокомплитом — типична для Раста.

Generics-nightmare. Поддержка кода с кучей дженерик-функций это непросто. Поддержка кода с кучей дженериков и трейт баундами — это когнитивный ад, ведь часть из них похожи на интерфейсы/протоколы/типы, но не совсем, а часть из них — обычная компиляторная магия. Вот вам относительно «простой» пример из библиотеки для Redis:

pub trait AsyncTypedCommands
pub fn xinfo_groups<'a, K>(&'a mut self, key: K) -> crate::types::RedisFuture<'a, streams::StreamInfoGroupsReply>
where
    K: ToRedisArgs + Send + Sync + 'a,
    // Bounds from trait:
    Self: crate::aio::ConnectionLike + Send + Sized,

Performance

Наконец-то, обещанное!

Собственно, прокся, про которую я говорил в начале, делает следующее: получает JSON по HTTP, валидирует его, записывает в Redis Stream. Тестировал я используя docker compose с valkey сервисом (опенсорсный Redis), и собственно приложением, Гошной или Растовской версией. Приложению выделено 2 CPU и 1 GB памяти (хотя по факту, 50 МБ хватило за глаза). Оба варианта гонялись на debian:bookworm-slim, запуская бинарники, сбилженные на отдельном docker build стейдже.

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

# docker-compose.yml
services:

  collector:
    image: rust-collector / go-collector
    build:
      context: .
    env_file: .env
    depends_on:
      valkey:
        condition: service_healthy
    ports: ["8080:8080"]
    mem_limit: 1g
    cpus: 2

  valkey:
    image: valkey/valkey:8
    container_name: valkey
    ports: ["6379:6379"]
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
      interval: 10s
      timeout: 5s
      retries: 3
    volumes: [valkey:/data]

Чтобы сгенерить нагрузку, я навайбкодил отвратительную лапшу на Расте и запускал её прямо в терминале, указывая, куда слать фейковую нагрузку. Сначала я хотел сделать красиво и прикрутил faker, чтобы нагрузка была случайной и у кода не было возможности оптимизироваться в рантайме (если вдруг это возможно), однако, уперся в потолок в 420.000 ивентов в секунду — что неплохо, если не учитывать, что приложение на расте справлялось и с 500к ивентов.

Пришлось перестать генерить каждый ивент, сделав повторяющиеся батчи, что позволило достичь 800k/секунду и выжать максимум из проксей.

Приложения тестировались на throughput, latency, memory и cpu consumption при нагрузке от генератора в [1, 4, 16, 64] потоков.

Результаты

Go

Rust

Оба языка доставляют солидный перфоманс для солидных господ, но очевидно, Раст быстрее и эффективнее.

Rust даёт примерно в 2,5 раза больший throughput (490 тыс. против 185 тыс. ивентов в секунду), занимает примерно в 3–5 раза меньше памяти и стабильнее держит latency при высокой нагрузке. У Go заметно просел latency при количестве потоков более 16 (p95: 60 мс против 3,4 мс), но была лучше при малой нагрузке.

Итого

Довольно очевидно, что мне больше нравится Раст — он кайфовее, несмотря на все трудности с поиском библиотек.

В то же время, очевидно, что выбирать Раст в качестве первого языка программирования — довольно самоубийственное занятие — можно и не пережить, да и работу на Расте всё ещё найти сложно (уж тем более, если ищет джун). Чего не скажешь про Го.

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

Однако, в конце концов, я верю, что надо выбирать то дело, которое доставляет удовольствие. Ведь если ты получаешь кайф от процесса, то и совершенствуешься быстрее, и с мотивацией проблем нет.

Report Page