Skip to content

Latest commit

 

History

History
452 lines (236 loc) · 78.6 KB

File metadata and controls

452 lines (236 loc) · 78.6 KB

Rust: Инженерия Систем

«Железу всё равно, насколько элегантен ваш алгоритм. Железу важно, где лежат байты.»
— Каждый системный инженер, рано или поздно.


Введение: День, когда мой скрипт на Python умер

Я до сих пор помню тот вечер. Пайплайн обработки данных, написанный на Python — элегантный, лаконичный, хвалёный командой — полгода работал как часы. Потом мы удвоили объём данных. Процесс не замедлился; он рухнул. Потребление памяти взлетело в стратосферу, сборщик мусора впал в транс, а сервер начал свопиться на диск, как тонущий, хватаясь за воздух.

Я открыл профайлер и понял: я понятия не имел, где живут мои байты. Сборщик мусора таскал их за спиной, из поколения в поколение, скрывая цену до тех пор, пока она не стала смертельной. В тот день я осознал: высокоуровневый комфорт — это кредит под лихву. Рано или поздно железо приходит взыскивать долг.

Эта книга — об этом долге. О физической реальности компьютеров: кремнии, шинах, кэшах. И о том, как Rust заставляет смотреть на эту реальность, не моргая. Мы не просто изучим концепции. Мы проследим путь одного байта: от рождения в исходном коде, через компилятор, по иерархии памяти, в регистры процессора — и обратно. В конце вы будете знать, почему быстрый код быстр, почему медленный код медленен, и почему borrow checker в Rust — не бюрократическая заноза, а прямой перевод того, как устроена машина.

Добро пожаловать в металл.


Часть I. Машина, которая лжёт

«Мы изучаем не просто историю. Эти три модели определяют физические пределы производительности и ограничения безопасности каждой строчки кода, который вы пишете.»

Глава 1. Жёсткий предел — Машины Тьюринга

В 1936 году двадцатичетырёхлетний математик Кембриджа Алан Тьюринг опубликовал статью, которая переживёт все компьютеры, которые он так и не увидел. Он пытался решить задачу формальной логики, но в процессе изобрёл Машину Тьюринга — теоретическое устройство с бесконечной лентой, читающей/пишущей головкой и таблицей правил. Её никогда не предполагалось строить. Она была нужна, чтобы провести черту в песке: это вычислимо, а то — нет.

Turing Machine

Машина Тьюринга — абсолютный бенчмарк. Если задачу нельзя решить на этом воображаемом устройстве, ни один суперкомпьютер, ни квантовый кластер, ни программа на Rust не решат её никогда. Это не инженерное ограничение; это математический закон. Когда говорят, что язык программирования «Тьюринг-полный», имеют в виду: он может эмулировать эту ленту с головкой. Сама система типов Rust — Тьюринг-полна. На логическом уровне Rust ровно так же мощен, как C++, Python или комната людей с ручками и бумагой, выполняющих инструкции. Мощь одинакова. Разница — в том, как мы управляем конечными ресурсами, которые требует реальный мир.

Модель Тьюринга учит смирению. Прежде чем беспокоиться о кэш-линиях и SIMD, нужно принять: некоторые проблемы просто вне досягаемости любого кода. Остальная часть книги — о проблемах, которые внутри досягаемости. И о том, как архитектура реальных машин делает их либо тривиальными, либо коварными.


Глава 2. Проклятие фон Неймана и мечта Гарварда

Если Тьюринг дал теоретический потолок, то Джон фон Нейман подарил нам подвал, в котором мы на самом деле живём. В 1945 году фон Нейман описал архитектуру настолько практичную, что она стала основой почти каждого компьютера на Земле. Принцип соблазнительно прост: одна общая память хранит и инструкции программы, и данные. Одна общая шина везёт и то, и другое. Процессор читает команду, исполняет, читает следующую. Просто. Гибко. И катастрофически уязвимо.

image

Бутылочное горлышко

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

Ответ индустрии — жульничать. Мы вставляем кэши — L1, L2, L3 — слои молниеносного SRAM, которые угадывают, что процессору понадобится следующим. Мы добавляем предсказатели ветвлений, которые делают ставки, куда прыгнёт if. Это не решения. Это пластыри на архитектурной ране, которая так и не зажила.

Рана, из которой кровоточил интернет

Есть более тёмное следствие единого адресного пространства. В мире фон Неймана код и данные — просто байты. Процессор не может по своей сути отличить, является ли последовательность байтов фотографией, паролем или цепочкой инструкций. Эта двусмысленность — не баг; это фича, позволяющая обновлять ПО. Но это и фича, делающая вирусы возможными.

В ноябре 1988 года аспирант Корнелла Роберт Моррис запустил программу, которая должна была измерить размер интернета. В ней был один баг: переполнение буфера. Программа записала в массив больше данных, чем тот мог вместить, разлившись в соседнюю память и перезаписав адрес возврата функции. Когда функция попыталась вернуться, она не пошла к вызывающему. Она прыгнула в данные атакующего, которые процессор с радостью исполнил как код. Шесть тысяч машин — примерно десятая часть тогдашнего интернета — упали или отключились в течение суток. Червь Морриса — не провал сетевой безопасности. Это прямое следствие неспособности архитектуры фон Неймана отличить данные от инструкций.

Современные ОС затыкают эту дыру правилом W^X (Write XOR Execute). Страница памяти может быть либо доступна для записи, либо исполняемой, но никогда не обеими сразу. Если процесс пытается выполнить код из стека — где живут данные — железо выбрасывает segfault. Но это эмуляция безопасности, а не сама безопасность. Языки с JIT-компиляцией — Java, JavaScript, PyPy — вынуждены постоянно переключать эти флаги, генерируя код на лету и тут же помечая его исполняемым. Работает. Дорого. И сложно.

Гарвардская альтернатива

Есть другой путь. Гарвардская архитектура, рождённая в релейном компьютере Mark I в 1944 году, настаивает на физическом разделении: одна память для инструкций, другая для данных; одна шина для кода, другая для переменных. Процессор может читать инструкцию и доставать данные одновременно. Память кода часто неизменяема на аппаратном уровне. Запущенная программа физически не может перезаписать собственные инструкции. Это не просто быстрее; это в принципе безопаснее.

Harvard Architecture

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

Внутри вашего современного процессора Intel или AMD, за фасадом фон Неймана в виде одной планки RAM, скрывается модифицированный Гарвард. Кэш L1 раздвоен: L1 Instruction Cache и L1 Data Cache. Процессор читает код из одной половины и данные из другой параллельно, обходя узкое место шины там, где это возможно — внутри самого кремния. А ваш Arduino живёт в более чистом гарвардском мире: код во Flash, данные в SRAM, и никогда они не встретятся.

Почему это важно для Rust

Rust — язык, который знает об этой архитектурной шизофрении. Он знает, что мы пишем для машины фон Неймана, но мечтаем о безопасности Гарварда.

Rust: Systems Engineering Core

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

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

Возьмём компиляцию. Когда вы собираете Rust-программу, функции попадают в секцию .text — только для чтения, исполняемую. Статические переменные — в .data или .bss. Если бы вы попытались записать в указатель на функцию, MMU операционной системы остановил бы вас. Система типов Rust обеспечивает это разделение на этапе компиляции; железо — во время исполнения. Вместе они закрывают окно, через которое пролез червь Морриса.

Вывод инженера: Мы пишем для фон Неймана. Процессор оптимизирует как Гарвард. Rust следит, чтобы мы не перепутали данные с инструкциями — и не расплатились за это.


Глава 3. Контракт с железом — ISA и Ассемблер

Есть документ, обязательность которого выше любой лицензии на софт. Это Архитектура Набора Команд (ISA), и это контракт между программистом (или компилятором) и транзисторами. ISA говорит: Если ты выдашь такой-то паттерн битов, процессор обязан сделать то-то. Ему всё равно, как устроен процессор внутри. Чип Intel и чип Apple Silicon могут исполнять один логический контракт совершенно разными способами. ISA — это API кремния.

CISC против RISC: две религии

Современный мир расколот между двумя философиями.

x86-64, лагерь CISC (Complex Instruction Set Computer), верит в мощь в точке применения. Одна инструкция может загрузить из памяти, прибавить значение и записать обратно — всё за раз. Код компактен, что когда-то имело значение, когда память измерялась в килобайтах. Цена — византийский декодер внутри CPU, который транслирует сложные инструкции в более простые микро-операции, сжигая энергию и кремний. x86-64 властвует на десктопах, серверах и консолях, потому что его экосистема укоренилась, а однопоточная производительность велика.

ARM64 и RISC-V, лагерь RISC (Reduced Instruction Set Computer), верит в простоту. Загрузи. Сложи. Сохрани. Каждая инструкция элементарна. Чтобы сделать что-то сложное, нужно написать больше команд. Процессор проще, меньше, эффективнее по энергии. Бремя ложится на компилятор. ARM64 живёт в вашем телефоне, в MacBook на M-серии, в серверах AWS Graviton, во встраиваемых системах. RISC-V — открытый претендент, набирающий вес в заказных чипах.

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

Регистры: верстак и склад

Вот самая важная ментальная модель для производительности. У процессора есть верстак: шестнадцать-тридцать два регистра общего назначения. Доступ к ним практически мгновенный — ноль тактов. Арифметика происходит только здесь. Процессор не может сложить два числа, лежащих в RAM. Сначала нужно притащить их со склада (RAM, в сотнях тактов отсюда) на верстак (регистры), поработать — и отнести обратно.

Работа компилятора Rust — через LLVM — удержать ваши переменные на этом верстаке как можно дольше и трогать склад как можно реже. Когда вы пишете тугой цикл по Vec<f64>, компилятор не просто итерирует. Он борется, чтобы удержать счётчик цикла, указатель и аккумулятор в регистрах, умоляя процессор не лезть в RAM.

Ассемблер: истина под синтаксисом

Ассемблер — это текстовое представление машинного кода один-к-одному. Вы его не пишете; вы его читаете. Когда Rust-код тормозит, вы не гадаете. Вы открываете Compiler Explorer (Godbolt), вставляете элегантную цепочку итераторов — и смотрите правде в глаза. Векторизовал ли компилятор цикл? Убрал ли проверки границ? Развернул ли итерации? Ассемблер не лжёт. Профайлер может ввести в заблуждение; ассемблер — нет.

Скрытая сложность: конвейеры и суперскалярность

Современные CPU не исполняют одну инструкцию за раз. Они исполняют пачками. Заглядывают вперёд в поток команд, находят операции, независимые друг от друга, и гонят их параллельно. Это внеочередное исполнение (Out-of-Order Execution). Конвейер глубок и прожорлив. Но конвейеры ненавидят ветвления. if/else — развилка, и процессор должен угадать, куда повернуть. Ошибочно предсказанное ветвление смывает конвейер, выкидывая десятки тактов. Поэтому в Rust итераторы часто быстрее ручных for-циклов: компилятор разворачивает их в линейный, дружелюбный предсказателю код.

А ещё есть SIMD — Single Instruction, Multiple Data. Массивные регистры, 128, 256, даже 512 бит в ширину, складывающие четыре, восемь или шестнадцать чисел одной командой. Rust, через LLVM, умеет автовекторизацию. Если вы пишете чистый код без побочных эффектов, компилятор тихо заменит скалярные операции на SIMD. В исходнике вы этого не увидите. В бенчмарках — увидите.

Почему это важно для Rust

Во-первых, usize и isize — это родной размер регистра. На 32-битной машине usize — четыре байта. На 64-битной — восемь. Если вы используете u64 на 32-битной системе, процессор вынужден раскалывать каждую операцию на два регистра, обрабатывая младшую и старшую части отдельно. Медленнее, и часто незаметно.

Во-вторых, ABI (Application Binary Interface). Rust не гарантирует стабильный порядок полей в памяти. Он может переупорядочивать их для плотности. Но когда вы разговариваете с внешним миром — вызываете C-библиотеку, обращаетесь к ОС — вы должны говорить на языке, который понимает железный контракт. #[repr(C)] и extern "C" — не украшения. Это приказы: Забудь свои оптимизации. Разложи байты точно так, как требует стандарт архитектуры.

В-третьих, и это самое коварное, порядок памяти (memory ordering). Процессоры x86 имеют сильную модель памяти: записи распространяются на другие ядра относительно предсказуемо. Процессоры ARM, вроде Apple M-серии, имеют слабую модель памяти: переупорядочивание агрессивно, и видимость не гарантируется без явных барьеров. std::sync::atomic в Rust даёт вам рычаги — Relaxed, Acquire, Release, SeqCst — чтобы контролировать это. Написать lock-free код, работающий и на Intel, и на Apple Silicon, требует понимания, что ISA — это не просто список инструкций. Это контракт о времени и видимости.


Глава 4. Великая иллюзия — Виртуальная память

Ни одна запущенная программа не знает, где на самом деле живут её данные. Это не метафора. Это фундаментальная ложь современных операционных систем, и она называется виртуальная память.

Когда ваш Rust-бинарник печатает адрес указателя вроде 0x7fff_5e4b_2c00, он лжёт. Это не адрес физического транзистора в планке RAM. Это координата в воображаемой карте, фикция, поддерживаемая железом и ядром в идеальном сговоре. Реальный адрес где-то совсем в другом месте, и добраться до него — целое путешествие.

MMU: переводчик в тени

Внутри вашего процессора сидит Блок Управления Памятью (MMU). Каждый раз, когда код обращается к переменной, MMU перехватывает доступ. Он берёт виртуальный адрес, проходит по многоуровневой структуре — таблице страниц — находит соответствующий физический фрейм, и лишь тогда разрешает запрос в память. Этот перевод происходит при каждом обращении к памяти. Было бы парализующе медленно, если бы не TLB.

Translation Lookaside Buffer — кэш недавних переводов. Маленький, драгоценный, быстрый. Попадание в TLB почти ничего не стоит. Промах TLB — когда перевода нет в кэше — вынуждает процессор идти по таблицам страниц в RAM, стоимостью в десяток-сотню тактов. В тугом цикле с разбросанными доступами к памяти промахи TLB могут доминировать во времени исполнения.

Страницы, изоляция и налог в 4 KiB

Память разбита на страницы, обычно по 4 KiB каждая. ОС использует страницы для изоляции. Если процесс пытается обратиться к виртуальному адресу, не отображённому в его таблице, MMU выбрасывает аппаратное исключение. Ядро ловит его и шлёт процессу SIGSEGV — segmentation fault. Процесс умирает, не успев испортить соседа или само ядро.

Но есть налог. Вы не можете выделить один байт физической памяти. ОС всегда выдаст целую страницу. Если вы аллоцируете тысячу структур по байту, вы сжигаете 4095 байт на каждое выделение. Кажется, пустяк — пока не сделаете это миллиард раз.

Ловушка Page Fault

Тут иллюзия становится опасной. Когда вы вызываете malloc или Box::new в Rust, ОС часто говорит: «Вот твой адрес, пользуйся». Но физическая память ещё не выделена (ленивое выделение). Физическая страница не закрепляется, пока вы не запишете в неё. В момент первой записи происходит page fault. Процессор останавливается, управление ныряет в ядро, ядро ищет свободную физическую страницу, обновляет таблицы, возвращает управление. Тысячи тактов — испарились.

Минорный page fault — когда ядро нашло свободную страницу. Мажорный page fault — хуже. Если RAM забита, ядро выгрузило данные в swap — на диск. Процесс замирает на миллисекунды, пока данные тащатся с SSD или, не дай бог, с вращающегося ржавого диска. Для процессора, бегущего на гигагерцах, миллисекунда — вечность.

Скрытые издержки многозадачности

Когда ОС переключается с одного процесса на другой, она не просто сохраняет регистры. Нужно инвалидировать TLB, потому что тот же виртуальный адрес в Процессе А указывает на совершенно другую физическую память, чем в Процессе Б. Этот TLB flush означает, что новый процесс начинает свой квант времени с холодным кэшем переводов, расплачиваясь полной стоимостью проходов по таблицам страниц, пока TLB не прогреется. Современные процессоры используют PCID (Process-Context Identifiers), маркируя записи TLB идентификатором процесса, чтобы часть записей пережила переключение. Но накладные расходы остаются реальными.

Huge Pages: секрет баз данных

Для больших приложений — PostgreSQL, Redis, тяжёлого Rust-компилятора — стандартные страницы по 4 KiB — кошмар. Таблица страниц раздувается до неприличия, TLB постоянно переполняется. Решение — Huge Pages: 2 MiB или даже 1 GiB. Меньше записей, меньше промахов TLB, прирост в десять-пятнадцать процентов. Это не серебряная пуля; это признание, что дефолт в 4 KiB был придуман в эпоху дефицита, а не для современных нагрузок.

Почему это важно для Rust

Во-первых, защита от переполнения стека. Rust знает, что стек переполнен, потому что ОС ставит Guard Page в конце виртуального диапазона, выделенного под стек — страницу без прав на чтение и запись. Когда рекурсия уходит слишком глубоко и упирается в неё, происходит page fault. Рантайм Rust ловит его и паникует с вежливым сообщением, вместо того чтобы тихо портить соседнюю память.

Во-вторых, zero-copy десериализация. Виртуальная память позволяет использовать mmap: отобразить файл прямо в адресное пространство процесса. В Rust библиотеки вроде rkyv или Cap'n Proto дают вам &[u8], указывающий прямо в файл. По мере чтения ОС прозрачно подгружает страницы с диска. Никакого read, копирующего байты из ядра в пользовательское пространство. Для высоконагруженных Rust-сервисов это разница между выживанием под нагрузкой и обрушением.

В-третьих, ASLR (Address Space Layout Randomization). Каждый запуск вашего Rust-бинарника — адреса функций, стека, кучи — разные (случайное смещение). Это защита от хакеров, возможная только потому, что виртуальная память — абстракция; физические адреса не меняются, но виртуальная карта тасуется.

Вывод инженера: Виртуальная память — необходимая ложь. Она защищает, изолирует, абстрагирует. Но у каждой лжи есть цена: page fault'ы, промахи TLB, налог в 4 KiB. Rust позволяет видеть сквозь иллюзию, когда это нужно.


Часть II. Где живут байты

«Память — не просто хранилище. Это иерархия скоростей, и то, куда вы кладёте данные, — самое важное решение после выбора алгоритма.»

Глава 5. Физика памяти: Стек, Куча и Статика

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

Статика: сегмент данных

Некоторые данные настолько неизменны, настолько предопределены, что их пекут прямо в исполняемый файл до запуска программы. Это статическая память, живущая в сегменте данных вашего бинарника. Когда вы запускаете программу, ОС делает простое memory-mapped копирование: кусок файла на диске становится куском RAM. Никакого выделения в рантайме. Данные просто есть.

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

В Rust static значения живут здесь. Они существуют всю жизнь программы. static mut тоже здесь, но это запретная дверь. Доступ к нему из нескольких потоков — гарантированная гонка данных, и современный Rust изгнал его в пользу OnceLock и LazyLock — безопасных абстракций, всё ещё живущих в статике, но защищающих себя.

Стек: горячая зона

Стек — самая быстрая память, до которой может дотянуться программист. Он быстр не из-за какого-то особого железа; он быстр из-за близости и простоты.

Стек — это предварительно выделенная плита, обычно 2–8 МБ на поток. Выделить память на стеке — одна инструкция процессора: вычесть из регистра указателя стека. Освободить — обратное: прибавить к указателю. Никакого поиска. Никакой фрагментации. Никакой бухгалтерии. Вершина стека почти всегда резидентна в L1-кэше. Локальные переменные читаются за горсть тактов, часто вообще не трогая RAM.

Но стек — строгий арендодатель. Он требует, чтобы размер каждого жильца был известен на этапе компиляции. В Rust это трейт Sized. Вы не можете положить str — динамически размеренный срез строки — прямо на стек, потому что компилятор не знает, сколько байт резервировать. Приходится использовать &str (указатель фиксированного размера) или String (структура на стеке, управляющая буфером в куче). Это не причуда языка. Это прямое следствие арифметики указателя стека, которая и делает стек таким быстрым.

Жёсткий лимит стека — его ахиллесова пята. Превысить его — и вы в stack overflow. Во встраиваемых системах или глубоко рекурсивных алгоритмах это обрыв, а не склон.

Куча: свобода за цену

Куча — туда, куда данные отправляются, когда перерастают жёсткие правила стека. Размер неизвестен на этапе компиляции? Куча. Время жизни должно пережить функцию? Куча. Сложные паттерны разделения? Куча.

Но свобода дорога. Куча управляется аллокатором — программой внутри вашей программы, ведущей карту свободных и занятых блоков. Когда вы запрашиваете память через Box::new или Vec::with_capacity, аллокатор сканирует свои структуры в поисках подходящего куска. Этот поиск стоит времени. Хуже, со временем куча превращается в швейцарский сыр: дырки между занятыми блоками, в которые уже ничего не влезает. Это фрагментация.

Но худший штраф — погоня за указателями (pointer chasing). Данные в куче разбросаны. LinkedList в Rust медленна не потому, что плох алгоритм; она медленна, потому что каждый узел живёт в своей кэш-линии, возможно, на своей странице памяти. Чтобы пройти по ней, процессор должен прыгать по случайным адресам, промахиваясь в кэш каждый раз, замирая, пока RAM догоняет. Vec, напротив, хранит элементы сплошным блоком. Процессор загружает кэш-линию и получает следующие пятнадцать элементов бесплатно. Это пространственная локальность, и именно поэтому Vec в Rust — коллекция по умолчанию почти для всего.

Под капотом: brk, mmap и стратегия аллокатора

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

По умолчанию Rust использует системный аллокатор, но под высокой нагрузкой инженеры часто подменяют его на jemalloc или mimalloc. Они лучше справляются с фрагментацией в многопоточной среде, снижая contention на блокировках и улучшая локальность кэша. Менять аллокатор — не вуду; это признание, что управление памятью — решение о политике, а не закон природы.

Escape Analysis: то, чего Rust отказывается делать

В Go или Java компилятор проводит escape analysis: решает, может ли переменная жить на стеке или должна быть отправлена в кучу. Программист не выбирает; компилятор угадывает. Rust занимает противоположную позицию. В Rust выбираете вы. Нет Box, Rc или Arc? Значит, на стеке. Используете умный указатель? Значит, в куче. Никаких скрытых аллокаций, никаких сюрпризов. Эта явность — контракт Rust с вами: вы платите за то, что просите, и видите счёт заранее.

Почему это важно для Rust

Трейт Sized управляет стеком. Box<T> и Vec<T> — ваши билеты в кучу, но они прозрачны в цене. Vec<T> — три поля на стеке: указатель, вместимость, длина. Когда вектор растёт за пределы вместимости, Rust просит аллокатор новый, больший кусок, копирует все элементы, освобождает старый. Это тяжёлая операция. Совет стар, как мир: если знаете размер, используйте Vec::with_capacity(n) и избегайте лишних реаллокаций.

Rust также даёт инструменты обмануть кучу, когда нужно. Компилятор агрессивно инлайнит функции и проводит собственный escape analysis — не чтобы скрыть аллокации, а чтобы уничтожить их. Маленькая структура, пропущенная через несколько функций, может никогда не коснуться памяти, прожив всю жизнь в регистрах. Ассемблер покажет правду: иногда самый быстрый код — тот, который никогда не аллоцирует.

Вывод инженера: Статика бесплатна, но заморожена. Стек — молниеносен, но негибок. Куча — гибка, но дорога. Rust заставляет выбирать осознанно, и это осознанность — есть производительность.


Часть III. Когда одного мало

«Современный процессор — не калькулятор. Это распределённая система, и самая трудная проблема в распределённых системах — коммуникация.»

Глава 6. Танки и пехота — Процессы и Потоки

Учебники любят говорить об изоляции. На практике инженеры говорят о накладных расходах. Разница между процессом и потоком — не просто семантическая граница; это налоговая декларация.

Процесс: тяжёлый танк

Процесс — крепость. У него своя таблица страниц, свои файловые дескрипторы, свои переменные окружения, своё виртуальное адресное пространство. Когда ОС создаёт процесс — через fork() на Unix или CreateProcess на Windows — она должна копировать или создавать эти структуры, строить новую таблицу страниц, устанавливать новую личность в ядре. Это дорого. Создание процесса — манёвр тяжёлого танка: мощно, изолированно, но медленно.

Когда процессор переключается с одного процесса на другой, цена жестока. Меняется весь контекст таблицы страниц, а значит, TLB должен быть смыт. Новый процесс начинает свой квант времени без кэшированных переводов, спотыкаясь о таблицы страниц, пока TLB не прогреется. Переключение контекста между процессами — самая дорогая рутинная операция, которую выполняет ОС.

Поток: лёгкая пехота

Поток — более лёгкое подразделение. Он делит с родителем таблицу страниц, кучу и глобальные переменные. То, чем он владеет сам, минимально: стек, набор регистров, указатель инструкций. Создание потока обходит налог таблицы страниц. Переключение между потоками одного процесса обходит смыв TLB, потому что адресное пространство не меняется. Общение между потоками — просто чтение общей переменной; нулевые накладные расходы, но нулевая защита.

Эта общая память — и суперсила потока, и его проклятие. Без синхронизации два потока, читающие и пишущие одну переменную, создают гонку данных: результат зависит от невидимого тайминга планировщика CPU, и программа становится недетерминированной. Компилятор не спасёт; железо активно работает против вас внеочередным исполнением и слабой когерентностью кэша.

Вывод инженера: Процесс — изоляция, за которую вы платите. Поток — разделение, за которое вы боретесь. Выбирайте исходя из налога, который можете позволить.


Глава 7. Диктатура и джентльменское соглашение — Многозадачность

Если у вас одно ядро CPU и тысяча задач, как их поделить? Цивилизация породила две политические системы для этой проблемы.

Вытесняющая многозадачность: диктатура ОС

Это стандартная модель для потоков ОС, включая std::thread в Rust. Аппаратный таймер процессора стреляет прерыванием каждые несколько миллисекунд. Ядро хватает управление, замирает текущий поток — сохраняя его регистры в стек ядра — и загружает регистры следующего. Поток не имел слова. Его вытеснили.

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

Кооперативная многозадачность: джентльменское соглашение

Это фундамент Rust-экосистемы async: tokio, async-std, вся механика Future. Здесь нет аппаратного таймера, отбирающего управление. Вместо этого задача сама говорит: «Я жду сетевой пакет; мне CPU не нужен; забирайте». Это точка .await. Задача уступает. Пользовательский планировщик — часть вашего Rust-рантайма — выбирает следующую задачу и запускает её.

Переключение контекста здесь стоит гроши. Нет перехода в режим ядра, нет полного сохранения регистров, нет TLB-драмы. Можно держать сто тысяч асинхронных задач на паре гигабайт RAM, потому что стек каждой задачи крошечный — иногда пара сотен байт — в сравнении с двухмегабайтным монстром, выделяемым каждому потоку ОС.

Но у джентльменского соглашения есть фатальный изъян. Если одна задача решит считать десятимиллионное число Фибоначчи, ни разу не позвав .await, она не уступит. Она захватит поток. Девяносто девять тысяч соседей голоднут. В async Rust блокировать поток — непростительный грех.

Скрытая сложность размещения

Переключение потоков дорого не только из-за регистров. Если поток мигрирует с Ядра 1 на Ядро 2, он оставляет свой L1 и L2 кэш позади. Все его горячие данные должны перезагружаться из L3 или RAM. В высоконагруженных системах инженеры используют привязку к CPU (CPU affinity) — жёстко пришпиливая потоки к конкретным ядрам, чтобы кэш оставался горячим. Это микрооптимизация, которая на масштабе становится макротребованием.

А ещё есть модель отображения. std::thread в Rust использует 1:1: один языковый поток — один поток ОС. Просто, надёжно, прожорливо по памяти. tokio использует M:N: тысячи асинхронных задач (M) мультиплексируются на небольшой пул потоков ОС (N). Это модель Go-горутин и Erlang-процессов. Экстремальная эффективность, но требует, чтобы ваш код играл по правилам кооперации.

Почему это важно для Rust

Rust — единственный мейнстримный язык, который кодирует потокобезопасность в системе типов. Трейты Send и Sync — не аннотации; это доказательства.

  • Send: Эти данные можно безопасно передать в другой поток. Rc<T> — не Send, потому что его счётчик ссылок не атомарен. Компилятор откажется компилировать код, пытающийся перекинуть Rc через границу потока. Ошибка — не паника в рантайме; это ошибка компиляции.
  • Sync: Эти данные можно безопасно смотреть из нескольких потоков одновременно через shared-ссылки. Cell<T> — не Sync, потому что позволяет внутреннюю мутацию без синхронизации.

Это Бесстрашный Параллелизм (Fearless Concurrency). Вы получаете ошибку до запуска. До деплоя. До того, как продакшн ляжет в три ночи.

Mutex<T> в Rust отличается от C++ или Java. В тех языках мьютекс — отдельный объект, который нужно помнить захватить. В Rust Mutex<T> владеет данными. Вы не можете обратиться к внутреннему T без блокировки. Система типов заставляет вас держать guard, и guard разблокирует мьютекс автоматически, когда выходит из области видимости через RAII. Вы не можете забыть разблокировать, потому что язык не даст вам тронуть данные без guard.

Наконец, если поток паникует, удерживая Mutex, мьютекс помечается отравленным (poisoned). Другие потоки, пытающиеся его захватить, получат ошибку, потому что данные могли остаться в частично модифицированном, несогласованном состоянии. Rust не скрывает провал. Он транслирует его.

Вывод инженера: Вытеснение безопасно, но дорого. Кооперация дешева, но хрупка. Система типов Rust делает хрупкость видимой, а безопасность — проверяемой.


Глава 8. Битва за данные — Многоядерность и Кэши

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

Физические и логические ядра

Физическое ядро — кусок кремния, содержащий АЛУ, FPU и свой приватный L1-кэш. Логическое ядро, в маркетинге Hyper-Threading или SMT (Simultaneous Multithreading), — фокус. Одно физическое ядро притворяется двумя. Оно хранит два набора регистров и два указателя инструкций, но делит исполнительные блоки, L1-кэш и L2-кэш.

SMT полезен только когда один логический поток застопорился — ждёт память, ждёт I/O — а второй готов работать. Если оба потока делают тяжёлую математику, они дерутся за один кремний и тормозят друг друга. std::thread::available_parallelism() в Rust возвращает число логических ядер. Для CPU-bound работы не стоит порождать больше тяжёлых потоков, чем физических ядер, иначе заплатите налог contention'а SMT.

Лестница латентности

Процессор работает на 3–5 ГГц. Такт — примерно треть наносекунды. DRAM отвечает за 60–100 наносекунд. Разрыв — пропасть, и мы перебрасываем через неё каскад кэшей.

  • L1-кэш: Приватный для каждого ядра. 32–64 КБ. 3–4 такта. Это максимально близко к мгновенности.
  • L2-кэш: Обычно приватный, больше. 256 КБ – 1 МБ. 10–12 тактов.
  • L3-кэш: Общий для всех ядер. 10–64 МБ. 40–70 тактов. Это Last Level Cache (LLC), и здесь сталкиваются ядра. Если одно ядро захлёбывает L3 своими данными, другие голодуют.
  • DRAM: Далёкая планета. 200–300 тактов. Каждый поход сюда — полный stall процессора.

Ваша цель как инженера — удержать данные в L1 и L2. Ваш враг — не алгоритм; это стена памяти.

Кэш-линия: 64 байта истины

Процессор никогда не читает один байт. Он читает кэш-линиями, обычно по 64 байта. Когда вы обращаетесь к одному элементу массива, CPU тащит элемент плюс пятнадцать соседей. Поэтому последовательный проход по Vec<T> — молниеносен: после первого промаха следующие пятнадцать обращений бесплатны. Это пространственная локальность.

Наоборот, связный список — кошмар кэш-линий. Каждый узел аллоцирован независимо, вероятно, в своей кэш-линии. Каждый шаг обхода — промах кэша, запрос в RAM, stall. Алгоритмическая сложность вставки в связный список — O(1), но машинная сложность — O(латентность RAM) за шаг.

Коварнейший враг: Ложное разделение (False Sharing)

Самый коварный баг производительности в многопоточном коде — не deadlock и не гонка. Это false sharing.

Представьте два потока на разных ядрах. Поток А инкрементирует counter_a. Поток Б инкрементирует counter_b. Независимые переменные. Должны масштабироваться идеально. Но если компилятор разместил их в памяти рядом, они могут попасть в одну кэш-линию. Когда Ядро 1 модифицирует counter_a, протокол когерентности кэша (MESI) объявляет всю линию грязной и инвалидирует копию в кэше Ядра 2. Ядро 2 модифицирует counter_b, инвалидируя копию Ядра 1. Ядра пинг-понгят эту линию туда-сюда, сериализуясь через L3. Производительность падает в 10–50 раз, хотя данные логически независимы.

Лечение — выравнивание (alignment). В Rust можно вогнать структуру в свою собственную кэш-линию:

#[repr(align(64))]
struct AlignedCounter {
    value: std::sync::atomic::AtomicUsize,
}

Крейт crossbeam предоставляет CachePadded<T>, который делает это за вас. Это не преждевременная оптимизация. Это знание физики машины.

Data-Oriented Design

Паттерны по умолчанию в Rust поощряют Data-Oriented Design (DOD). Vec<T> хранит элементы монолитным блоком. Аппаратный prefetcher процессора видит последовательный паттерн доступа и подгружает данные в L1 заранее, до того как вы попросите. Vec<Box<T>>, напротив, — вектор указателей на разбросанные куски кучи. Сами данные далеко от вектора, и prefetcher бессилен. Поэтому Rust-код часто обгоняет эквивалентный C++ или Java: язык подталкивает к кэш-дружественным структурам по умолчанию.

Вывод инженера: Ядра не делят память; они делят кэш-линии. Иерархия кэшей — не деталь реализации. Это доминирующая стоимость вычислений. Коллекции Rust спроектированы с уважением к этой физике.


Часть IV. Порядок в хаосе

«Последний враг — не компилятор, не железо, а само время — и порядок, в котором события кажутся происходящими.»

Глава 9. Синхронизация памяти и гонки данных

Когда несколько ядер работают с одной RAM, они существуют в разных часовых поясах. Запись Ядра 1 не появляется мгновенно для Ядра 2. Без синхронизации программа превращается в лотерею, а выигрышный билет определяется гонками в масштабе наносекунд, невозможными для воспроизведения в отладчике.

Проблема видимости

Современные CPU переупорядочивают инструкции. Буферизуют записи. Кэшируют значения локально и сбрасывают их лениво. Переменная, записанная Ядром 1, может сидеть в его L1-кэше десятки тактов, прежде чем дойдёт до L3, не говоря уже о том, чтобы попасть в поле зрения Ядра 2. Это не баг. Это цена производительности.

Аппаратные инструменты

На аппаратном уровне CPU предоставляют атомарные инструкции — операции, которые нельзя прервать на полпути. Load-link/store-conditional механизмы (особенно на ARM). Барьеры памяти — заборы, говорящие: «Ничто не пройдёт мимо этой точки». Это строительные блоки синхронизации.

Инструменты ОС

Над железом операционная система предоставляет мьютексы, фьютексы (быстрые мьютексы в пользовательском пространстве, эскалирующие в ядро только при contention'е), сигналы. Тяжелее, но проще в правильном использовании.

Гонка данных

Data race — когда два потока одновременно обращаются к одной ячейке памяти, хотя бы один из них пишет, и нет синхронизации. Результат неопределён. Не в академическом смысле. Неопределён в том смысле, что программа может идеально работать в тестах и испортить финансовую транзакцию в продакшене. Компилятор вправе предполагать, что гонок данных нет, и оптимизировать код в бессмыслицу, если они есть.

Почему Rust — другой

Для новичка в Rust эта тема — неожиданно приятная. Язык спроектирован так, что большинство ошибок синхронизации ловятся на этапе компиляции, а не в три ночи в продакшене.

Borrow checker и трейты Send/Sync формируют систему доказательств. Если ваш код компилируется и не использует unsafe, он свободен от гонок данных. Это не рантайм-проверка. Это статическая гарантия, навязанная системой типов. Вы всё ещё можете написать deadlock — это логическая ошибка, не типовая — но вы не можете написать data race, не явно отказавшись от безопасности через unsafe.

Когда атомики всё же нужны, Rust открывает полную мощь модели памяти C++20: Relaxed, Acquire, Release, AcqRel, SeqCst. Они напрямую отображаются на аппаратные барьеры памяти. Понять их требует понимания ISA — почему ARM нужны явные барьеры, а x86 — нет — но однажды изученные, они становятся точным инструментом, а не тайной.

Вывод инженера: Синхронизация — искусство лгать железу о времени, заставляя его согласиться на единую историю. Rust делает эту историю проверяемой до запуска первой строчки.


Глава 10. Детерминизм против хаоса — RAII и Drop

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

В языках со сборщиком мусора программист — ребёнок, разбрасывающий игрушки. За ним идёт няня и подбирает. Няня удобна, но непредсказуема. Иногда она останавливает всю игру — Stop the World — чтобы убрать, и всё замирает.

В Rust и C++ мы живём по другому договору: RAII (Resource Acquisition Is Initialization). Суровому, но справедливому.

Суть контракта

  • Рождение: Выделение ресурса — памяти, файлового дескриптора, сокета, блокировки мьютекса — неразрывно связано с созданием объекта на стеке. let file = File::open("log.txt")?; — не просто объявление переменной. Это обещание, что файл теперь открыт.
  • Жизнь: Объект живёт в области видимости — между фигурными скобками. Пока он жив, ресурс удерживается.
  • Смерть: Когда исполнение достигает закрывающей скобки, объект умирает. Вызывается деструктор. Файл закрывается. Память освобождается. Мьютекс разблокируется. Это не пожелание. Это детерминированное, предсказуемое событие, привязанное к физике разворачивания стека.

«Отслеживающая структура» — это объект на стеке. Box<T> — просто указатель. Vec<T> — три числа: указатель, вместимость, длина. Они живут на стеке. Актуальная память в куче, которой они управляют, освобождается в тот же миг, когда стековый фрейм рушится — без сборщика мусора, без финализатора, без задержки.

Детерминизм: настоящая суперсила

Разница со сборщиком мусора не только в скорости. Это предсказуемость. Вы точно знаете, когда ресурс освободится. Это позволяет RAII управлять не только памятью, но любым ресурсом: соединениями с базами данных, сетевыми сокетами, guard'ами мьютексов, коммитами транзакций. В языках с GC вам приходится писать finally или defer, чтобы приблизиться к этому. В Rust это автоматически, неизбежно и невидимо.

Семантика перемещения: уникальный поворот

C++ копирует при присваивании. Rust — перемещает. Когда вы пишете:

let a = Box::new(5);
let b = a;

Владение кучевой аллокацией переходит от a к b. Компилятор инвалидирует a. Вы больше не можете его использовать. Деструктор — вызов Drop — произойдёт ровно один раз, для b. Это решает проблему двойного освобождения (Double Free), которая преследовала C-программистов сорок лет. Это не соглашение о кодировании. Это математическая гарантия, навязанная системой типов.

Порядок уничтожения: ловушка для собеседований

Переменные внутри функции уничтожаются в обратном порядке создания — LIFO, как стек. Но поля внутри структуры — в порядке объявления. Это важно. Если ваша структура держит socket и context, и сокету нужен контекст для корректного закрытия, порядок полей в структуре — семантическое решение, а не стилистическое.

Утечки, которые Rust позволяет

RAII гарантирует безопасность, но не гарантирует освобождение. Rust считает утечки памяти «безопасными» — они не вызовут segfault, но будут потреблять память.

  • Циклические ссылки: Если вы создадите Rc, указывающий сам на себя, счётчик ссылок никогда не достигнет нуля. Память утечёт. Лечение — Weak-ссылки, которые не учитываются в счётчике.
  • std::mem::forget: Вы можете явно сказать компилятору пропустить деструктор. Используется в FFI при передаче владения в C, но злоупотребление — утечка.

Паттерн RAII Guard

MutexGuard в Rust — чистейшее воплощение RAII. Вы не вызываете mutex.unlock(). Вы вызываете mutex.lock().unwrap(), что возвращает guard. Пока guard жив, мьютекс заблокирован. Когда guard выходит из области видимости — может, потому что функция рано вернулась, может, потому что взяла ветвь, может, потому что паника развернула стек — реализация Drop guard'а разблокирует мьютекс. Вы не можете забыть. Вы не можете разблокировать дважды. Система типов делает это невозможным.

Для редких моментов, когда нужно сжульничать — кастомные структуры данных, Union, ручная раскладка памяти — Rust предоставляет ManuallyDrop<T>. Он оборачивает значение и подавляет автоматический деструктор. Вызов drop становится вашей ответственностью, и он требует unsafe. Rust здесь вам не доверяет. Он заставляет подписать отказ от ответственности.

Вывод инженера: Сборка мусора — взыскание долга с неопределённым таймингом. RAII — владение по контракту. Контракт Rust обеспечивается компилятором, исполняется стеком и верифицируется железом. Это не просто управление памятью. Это философия ответственности.


Эпилог: Путешествие байта

Мы начали с бесконечной ленты и теоретического предела. Прошли через общую память фон Неймана, раздвоенные кэши Гарварда, контракт ISA, иллюзию виртуальной памяти, районы стека и кучи, политику процессов и потоков, физику кэшей, войну за видимость — и, наконец, детерминированную смерть ресурсов.

Один байт в Rust-программе прожил всё это. Он мог начаться как константа в .data, быть скопированным в Vec на куче, разделённым между потоками через Arc, синхронизированным атомаром и, наконец, дропнутым, когда последний владелец вышел из области видимости. На каждом шагу железо предъявляло правила, операционная система — иллюзии, а Rust — доказательства.

Это системная инженерия. Она не в знании каждого регистра. Она в понимании, что каждая абстракция — кредит, каждое удобство — цена, и каждая строчка кода — разговор с металлом. Rust делает этот разговор явным, проверяемым и — однажды выучив грамматику — удивительно человечным.

Теперь вы знаете, где ваши байты. Идите и напишите что-нибудь быстрое.


Вклад

Эта книга — живой документ. Если вы нашли неточность, хотите добавить главу, улучшить аналогию или предложить перевод — присылайте PR. Лучшие технические тексты пишутся сообществом.

Что особенно приветствуется:

  • Уточнения по цифрам и архитектурам (новые CPU, изменения в ядрах Linux, эволюция ARM).
  • Практические примеры кода, которые можно запустить и измерить.
  • Истории из продакшена, где знание этих концепций спасло систему.
  • Переводы на другие языки.

Книга распространяется как есть, для обучения. Если она помогла вам понять Rust или железо глубже — она выполнила свою задачу.


Конец.