Ржавеем дальше. Как появился Rust и можно ли на нём WEB?
Глубокое погружение в историю компиляции — от процессоров и опкодов через C, Java и JavaScript до LLVM и Rust. Понимание того, зачем нужен Rust и когда его использовать для веба.
Неделю назад я написал статью «Как мы ржавели». Статья, видимо, понравилась, раз набрала кучу просмотров. Среди комментариев мне задали вопрос: «А можно ли делать веб на Rust?» Ответ: да, можно, и я расскажу как. Но перед тем как отвечать на этот вопрос, нам нужно разобраться — а что вообще такое Rust? Чтобы ответить на этот вопрос, мне придётся объяснить, как вообще компилируются программы. Усаживайтесь поудобнее, дорога длинная.
Акт номер 0: Вступление
Я очень люблю учить программистов. У меня почему-то это хорошо получается. Мне говорят, что после разговоров со мной какие-то вещи начинают укладываться на свои места. Наверное, это потому что я не объясняю вещи так, как вас учат в вузе.
Я объясняю не «что это такое», а «зачем это нужно». Когда ты понимаешь, зачем что-то создано, ты можешь самостоятельно дойти до того, что это такое.
Так вот, если вы хотите понять, что такое Rust, нужно разобраться в том, что такое процессор и как он работает. Потому что Rust — это инструмент, который говорит процессору, что ему делать. Это его единственная задача. Всё остальное — синтаксический сахар.
Акт номер 1: Процессор
Процессор — это кусок кремния, который умеет делать математику. Всё. Больше он ничего не умеет. Сложить, вычесть, умножить, разделить. Ну ещё он умеет двигать данные туда-сюда. Но суть его работы — это математика.
У процессора есть набор команд. Каждая команда — это число. Процессор берёт число, смотрит, какая это команда, и выполняет её. Следующее число — следующая команда. И так далее. Очень быстро.
Вот эти числа и называются опкодами (operation codes). У каждого процессора свой набор опкодов. Программа для процессора Intel x86 не запустится на ARM-процессоре, потому что у них разные наборы команд.
У процессора есть регистры — маленькие ячейки памяти прямо внутри процессора. Их немного, но они очень быстрые. В x86 у нас есть EAX, EBX, ECX, EDX и ещё несколько. Процессор берёт данные из памяти, кладёт в регистры, делает математику и кладёт результат обратно.
Кроме основных команд, у современных процессоров есть расширения — SSE, AVX и прочие. Это дополнительные наборы команд, которые позволяют делать хитрую математику быстрее. Например, обрабатывать несколько чисел за одну команду. Видео, аудио, 3D-графика — всё это крутится на этих расширениях.
Ещё есть прерывания (interrupts). Это механизм, с помощью которого процессор общается с внешним миром. Нажали кнопку на клавиатуре — процессор получил прерывание. Данные пришли по сети — прерывание. Таймер тикнул — прерывание. Процессор бросает то, что делал, обрабатывает прерывание и возвращается к работе.
И вот сидит этот кусок кремния и молотит числа. Миллиарды операций в секунду. Но для человека писать эти числа вручную — это ад. Поэтому люди придумали штуку поудобнее.
Акт номер 2: Ассемблер
Ассемблер — это первый уровень абстракции над процессором. Вместо того чтобы писать числа, вы пишете человекочитаемые команды, которые один-в-один соответствуют опкодам процессора.
Вот пример:
INC COUNT ; Увеличить переменную COUNT на 1
MOV TOTAL, 48 ; Положить число 48 в переменную TOTAL
ADD AH, BH ; Сложить содержимое регистров AH и BH
AND MASK1, 128 ; Побитовое И переменной MASK1 и числа 128
Каждая строчка — одна команда процессора. Программа-ассемблер (компилятор) берёт эти текстовые команды и превращает их в числа-опкоды. Один-в-один. Никакой магии.
Это даёт вам полный контроль над процессором. Вы точно знаете, что он будет делать. Вы можете оптимизировать каждый такт. Вы можете выжать из железа абсолютный максимум.
Но есть проблемы:
- Переносимость. Написали программу для Intel? На ARM она не пойдёт. Нужно переписывать.
- Продуктивность. На ассемблере можно писать идеальный код, но очень медленно. Простая программа превращается в тысячи строк.
- Читаемость. Через полгода вы сами не поймёте, что написали.
Ассемблер — прекрасен для маленьких критических кусков кода. Драйверы, загрузчики, прошивки. Но писать на нём большие программы — это мазохизм.
Нужно что-то получше.
Акт номер 3: Сиииии и Сипипииии
В 1972 году Деннис Ритчи создал язык Си. Идея была гениальной: давайте напишем язык, который выглядит как человеческий текст, но компилируется в машинный код. Не один-в-один, как ассемблер, а с умом. Компилятор сам решит, какие регистры использовать и как оптимизировать.
int total = 48;
total++;
Вот эти две строчки компилятор Си превратит в набор опкодов. Причём хороший компилятор сделает это лучше, чем средний программист на ассемблере. Потому что компилятор знает тонкости конкретного процессора и может оптимизировать код так, как человеку и не снилось.
А ещё Си переносим. Написали программу на Си — скомпилировали для Intel. Потом перекомпилировали для ARM. Код один и тот же, а процессоры разные. Красота.
Потом появился C++ — Си с объектами, классами и прочими ООП-штуками. Мощный, быстрый, но сложный как атомный реактор.
Процесс компиляции в Си и C++ выглядит так:
- Препроцессор — обрабатывает директивы
#include,#defineи прочее. - Компилятор — превращает код в объектные файлы (машинный код, но ещё не готовый к запуску).
- Линковщик (linker) — собирает объектные файлы вместе, подключает библиотеки и создаёт исполняемый файл.
А когда проект большой, нужен ещё и Makefile — файл с инструкциями, что и в каком порядке компилировать. Потому что перекомпилировать весь проект при каждом изменении — это долго. Makefile знает, какие файлы изменились, и перекомпилирует только их.
Но у Си и C++ есть одна страшная проблема. Они дают вам полный контроль над памятью. И если вы облажались — программа не упадёт с красивой ошибкой. Она сделает что-то непонятное. Это называется undefined behavior — неопределённое поведение.
Если в вашей программе есть неопределённое поведение, то по стандарту языка компилятор имеет право сделать что угодно. Буквально что угодно. В шутку говорят, что если в вашей программе есть undefined behavior, то ваш код имеет полное право выпустить демонов из ваших ноздрей. 봎볈볬 — вот вам и неопределённое поведение.
И это реальная проблема. Огромное количество уязвимостей в софте — это ошибки работы с памятью в Си и C++. Переполнение буфера, использование освобождённой памяти, двойное освобождение — всё это ведёт к дырам в безопасности.
Но Си и C++ — это всё ещё компиляция в нативный код. Программа работает напрямую на процессоре, без посредников. Быстро. Нужно что-то, что решит проблемы с памятью, но не замедлит программу.
А пока люди думали, другие люди пошли другим путём.
Акт номер 4: Ява и дотнет
В 1995 году Sun Microsystems выпустила Java. Идея была революционной: а давайте не будем компилировать в машинный код конкретного процессора. Давайте придумаем виртуальный процессор и будем компилировать для него.
Этот виртуальный процессор называется JVM (Java Virtual Machine). Код на Java компилируется в байткод — набор команд для этого виртуального процессора. А потом JVM, запущенная на конкретном компьютере, выполняет этот байткод.
Переносимость? Идеальная. Написал один раз — работает везде, где есть JVM. «Write once, run anywhere» — девиз Java.
Но есть нюанс. Виртуальная машина — это дополнительный слой. Программа работает медленнее, чем нативный код. Поэтому придумали JIT-компиляцию (Just-In-Time). JVM смотрит, какие куски байткода выполняются часто, и компилирует их в нативный машинный код прямо во время работы программы. Умно.
Microsoft посмотрела на всё это и сказала: «Мы тоже так хотим, но лучше». И создала .NET с языком C#. Идея та же — виртуальная машина, байткод, JIT. Только байткод называется CIL (Common Intermediate Language), а виртуальная машина — CLR (Common Language Runtime).
Вот как выглядит CIL:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
.maxstack 8
IL_0000: nop
IL_0001: ldstr "Hello World"
IL_0006: call void [System.Console]System.Console::WriteLine(string)
IL_000b: nop
IL_000c: ret
}
Видите? Это по сути ассемблер, но для виртуальной машины. Вместо регистров процессора — стек. Вместо опкодов Intel — опкоды CIL. А дальше CLR берёт этот код и JIT-компилирует его в нативный код вашего процессора.
И Java, и .NET решили проблему с памятью элегантно: они забрали у программиста право управлять памятью вручную. Память выделяется и освобождается автоматически. Красота.
Но за эту красоту вы платите: ваша программа тащит за собой виртуальную машину. JVM весит сотни мегабайт. .NET Runtime — тоже не пушинка. И память жрут они неслабо.
Акт номер 5: Яваскрипт
А потом появился JavaScript. И всё стало ещё интереснее.
JavaScript — это интерпретируемый язык. То есть изначально он вообще не компилировался. Браузер читал код строчка за строчкой и выполнял его на лету. Медленно? Очень. Но для менюшек на веб-страничках хватало.
А потом Google сделал V8 — движок JavaScript для Chrome. V8 берёт JavaScript и компилирует его в машинный код. Прямо в браузере. JIT-компиляция, оптимизации, деоптимизации — целая фабрика по превращению скриптового языка в что-то быстрое.
А потом Райан Дал взял V8 и засунул его в серверную оболочку. Получился Node.js. И JavaScript вышел за пределы браузера. Теперь на нём можно писать серверы, утилиты, да что угодно.
Но JavaScript — это динамически типизированный язык. Переменная может быть числом, потом строкой, потом объектом. V8 приходится угадывать типы на лету и оптимизировать код по ходу дела. Если угадал неправильно — деоптимизация, и всё заново.
JavaScript не компилируется в нативный код заранее. Он каждый раз компилируется при запуске. И хотя V8 делает это очень умно, это всё равно медленнее, чем заранее скомпилированная программа на Си.
А ещё JavaScript — однопоточный. Один поток. Один. Чтобы не блокировать этот единственный поток, придумали асинхронное программирование, event loop, callbacks, promises, async/await. Всё это — обходные пути вокруг одного потока.
Для веба — отлично. Для серверных приложений с кучей I/O — сойдёт. Для чего-то вычислительно-тяжёлого — забудьте.
Но подождите. Прежде чем перейти к Rust, нам нужно разобраться ещё с парой вещей.
Акт номер 0x00000000: Память
Давайте поговорим о памяти. Не о регистрах процессора, а о той большой штуке, которая называется RAM.
Когда программа запускается, операционная система выделяет ей кусок памяти. Программа может в эту память писать и из неё читать. Но тут есть один момент — виртуальная память.
Каждая программа думает, что у неё есть свой собственный непрерывный кусок памяти, начинающийся с адреса 0. Но на самом деле операционная система подкладывает ей виртуальные адреса, которые отображаются на реальные физические адреса в RAM. Это называется protected mode — защищённый режим.
Зачем? Чтобы одна программа не могла залезть в память другой. Безопасность.
В программе есть два основных места для хранения данных:
- Стек (stack) — быстрая память для локальных переменных. Работает по принципу «последним пришёл — первым ушёл» (LIFO). Функция вызвана — переменные создались на стеке. Функция завершилась — переменные уничтожились. Автоматически.
- Куча (heap) — медленная, но большая память для динамических данных. Когда вы создаёте объект в рантайме и не знаете заранее его размер — он идёт в кучу. Но кучу нужно убирать за собой. Если вы выделили память в куче и забыли её освободить — это утечка памяти.
А если вы освободили память, но продолжаете туда обращаться — это use after free. Классическая дыра в безопасности.
А если вы пишете в буфер больше данных, чем он вмещает — это переполнение буфера (buffer overflow). Ещё одна классическая дыра.
А если вы читаете из памяти до того, как туда что-то записали — вы получаете мусор. Как Буратино, который полез в чулан до того, как папа Карло положил туда еду. Ничего хорошего из этого не выходит.
Все эти проблемы существуют в Си и C++, потому что эти языки дают вам прямой доступ к памяти. С великой силой приходит великая ответственность. А большинство программистов — не Человек-Паук.
Акт номер 0xFFFFFFFF: Meet Garbage Collector
Сборщик мусора (Garbage Collector, GC) — это программа, которая автоматически убирает за вами.
Вы создали объект. Поработали с ним. Перестали им пользоваться. Сборщик мусора заметит, что на объект больше никто не ссылается, и освободит занятую им память.
Java, C#, JavaScript, Python, Go — все они используют сборщик мусора. И это решает кучу проблем:
- Нет утечек памяти (ну, почти).
- Нет use-after-free.
- Нет double-free.
- Программист не думает о памяти и пишет бизнес-логику.
Но за это вы платите:
- Паузы. Когда GC запускается, он может на мгновение заморозить программу. Для веб-сервера — пофиг. Для системы управления ракетой — критично.
- Расход памяти. GC нужна память для работы. Программа потребляет больше RAM, чем аналогичная на Си.
- Непредсказуемость. Вы не знаете точно, когда GC запустится и сколько времени займёт.
Для 99% приложений сборщик мусора — это правильный выбор. Но для системного программирования, встраиваемых систем, игровых движков, операционных систем — нужен контроль над памятью.
Итак, нам нужен язык, который:
- Компилируется в нативный код (быстро).
- Не имеет сборщика мусора (предсказуемо).
- Не позволяет ошибиться с памятью (безопасно).
Звучит как фантастика? Почти. Но сначала нужно рассказать про LLVM.
Акт номер 6: LLVM
LLVM — это, пожалуй, самая важная вещь, которая произошла с компиляторами за последние 20 лет.
Началось всё с того, что Крис Латтнер (Chris Lattner) написал диссертацию в Университете Иллинойса. Идея была такая: давайте разделим компилятор на три части.
- Frontend — берёт код на конкретном языке и превращает его в промежуточное представление (IR — Intermediate Representation).
- Middle-end — оптимизирует это промежуточное представление. Удаляет мёртвый код, разворачивает циклы, инлайнит функции — всё то, что делает код быстрее.
- Backend — превращает оптимизированное IR в машинный код для конкретного процессора.
Зачем это нужно? Потому что до LLVM каждый язык имел свой собственный компилятор от начала до конца. Хотите новый язык — пишите компилятор с нуля, включая все оптимизации и поддержку всех процессоров. Титанический труд.
А с LLVM вы пишете только frontend — часть, которая понимает ваш язык и переводит его в LLVM IR. А оптимизации и генерация машинного кода — это уже забота LLVM. И они уже написаны. И они отличные.
Apple увидела LLVM и сказала: «Это наше.» Ну, почти. Apple наняла Криса Латтнера и активно развивала LLVM. Результат — clang, компилятор C/C++/Objective-C на основе LLVM. Clang стал альтернативой GCC и со временем стал компилятором по умолчанию на macOS.
А ещё Крис Латтнер в Apple создал язык Swift, который тоже компилируется через LLVM.
И знаете, что ещё компилируется через LLVM?
Акт номер 7: И где же Rust?
Rust был создан Грейдоном Хоаром (Graydon Hoare) в 2006 году как личный проект. В 2009 году Mozilla заинтересовалась и начала спонсировать разработку. В 2010 году Rust был публично анонсирован. Первый стабильный релиз — Rust 1.0 — вышел 15 мая 2015 года.
Зачем Mozilla создавала Rust? Им нужен был язык для написания нового движка браузера. Движок браузера — это штука, которая должна быть безумно быстрой и при этом безопасной. Си и C++ — быстрые, но небезопасные. Java и C# — безопасные, но недостаточно быстрые для движка. Нужен был новый язык.
Rust компилируется через LLVM. То есть frontend Rust превращает ваш код в LLVM IR, а дальше LLVM делает свою магию — оптимизирует и генерирует нативный машинный код. Программа на Rust работает с такой же скоростью, как программа на Си. Без виртуальной машины. Без интерпретатора. Без JIT. Просто машинный код.
Но главная фишка Rust — это borrow checker (проверка заимствований). Это часть компилятора, которая на этапе компиляции проверяет, что вы правильно работаете с памятью.
Правила простые:
- У каждого значения есть владелец (owner). Только один.
- Когда владелец выходит из области видимости, значение уничтожается. Автоматически.
- Вы можете дать кому-то ссылку на значение (заимствование). Но: либо одна мутабельная ссылка, либо сколько угодно иммутабельных. Но не одновременно.
Эти три правила решают все проблемы с памятью на этапе компиляции:
- Утечки памяти? Невозможно — значение уничтожается, когда владелец уходит из области видимости.
- Use-after-free? Невозможно — компилятор не даст использовать значение после того, как оно уничтожено.
- Data races? Невозможно — нельзя одновременно иметь мутабельную и иммутабельную ссылку.
- Double-free? Невозможно — только один владелец.
И всё это — на этапе компиляции. Никаких проверок в рантайме. Никакого сборщика мусора. Zero-cost abstractions — абстракции, которые не стоят ничего во время выполнения.
Если ваш код скомпилировался — в нём нет ошибок работы с памятью. Компилятор гарантирует. Это как если бы ваш код проходил аудит безопасности каждый раз, когда вы нажимаете «Build».
Да, компилятор Rust строгий. Да, он будет ругаться. Да, вы потратите время на то, чтобы «уломать» его скомпилировать ваш код. Но каждая ошибка компилятора — это баг, который в C++ превратился бы в уязвимость в продакшене.
Акт номер 0xFE: Заключение
Итак, давайте подведём итоги. Когда что использовать?
Вам нужно быстро написать скрипт для 200 пользователей, который работает с API, дёргает базу данных и отдаёт JSON? Node.js. Быстро, просто, куча библиотек. Не думайте о производительности — для 200 пользователей хватит с головой.
Вам нужно написать серьёзное бизнес-приложение для 2000 пользователей с кучей бизнес-логики, очередями, микросервисами? C# или Java. Мощные экосистемы, проверенные временем. Сборщик мусора справится. Всё будет работать стабильно.
Вам нужно написать систему, которая обслуживает 200000 пользователей одновременно, и каждый мегабайт памяти на счету, и каждая миллисекунда задержки стоит денег? Rust. Нативный код, нет сборщика мусора, предсказуемая производительность. Системное программирование на максималках.
Rust не заменяет Node.js. Rust не заменяет C#. Каждый инструмент — для своей задачи. Молотком не закручивают шурупы.
Но если вам нужен быстрый, безопасный, нативный код — Rust это то, что вам нужно. И да, на нём можно делать веб.
Акт номер 0xFF: Учимся
Итак, вы решили попробовать Rust для веба. Вот ваш путь обучения:
-
The Rust Programming Language — читайте, как я описал в предыдущей статье. Начните с главы 4, потом 3, потом 5, 6, 8, 9 и далее.
-
actix.rs — Actix Web. Это веб-фреймворк для Rust. Быстрый, как чёрт. Основан на акторной модели. Вот вам пример HTTP-сервера:
use actix_web::{web, App, HttpServer, Responder};
async fn hello() -> impl Responder {
"Hello, world!"
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.route("/", web::get().to(hello))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Выглядит почти как Express.js, только это компилируется в нативный код и работает в разы быстрее.
-
Rocket (rocket.rs) — ещё один веб-фреймворк. Проще, чем Actix, с более дружелюбным API. Хорош для начала.
-
Diesel (diesel.rs) — ORM для Rust. Если вам нужно работать с базой данных (PostgreSQL, MySQL, SQLite) — это ваш инструмент. Запросы проверяются на этапе компиляции. Написали кривой SQL? Не скомпилируется.
-
Serde — библиотека для сериализации и десериализации. JSON, TOML, YAML, MessagePack — что угодно. Без Serde в Rust никуда.
-
wasm-bindgen и wasm-pack — если хотите запускать Rust в браузере. Rust компилируется в WebAssembly (WASM), который работает в браузере рядом с JavaScript. Хотите написать критический по производительности кусок логики, который будет работать в 10 раз быстрее JavaScript? Rust + WASM.
Вот и всё. Путь от «что такое процессор» до «как писать веб на Rust» — это длинный путь. Но теперь вы понимаете, зачем нужен Rust. Не потому что он модный. А потому что он решает конкретную проблему — быстрый и безопасный нативный код без сборщика мусора.
Ржавейте на здоровье.
Читать дальше
Похожие посты
Прокачиваем силу — Rust и Windows API
Продолжение серии о компактных программах: пишем 2048 на Rust с использованием windows-rs, создаём окно через WinAPI и разбираемся с очередью сообщений.
Как научиться работать в Blazor, делая что-то полезное. Часть II
Вторая часть о Blazor: подводные камни WASM-бинарников, ловушки Razor, проблемы с общением компонентов, жизненный цикл и состояние экосистемы.
Как научиться работать в Blazor, делая что-то полезное. Часть I
Первая часть о том, как вместо очередного ToDo-листа написать на Blazor полезную систему управления IoT-реле: Entity Framework, MVC-контроллер, серверный рендеринг и C# вместо JavaScript.