Модель владения Rust: безопасность памяти без сборщика мусора

Black-and-white photograph by Matt Artz
Photograph by Matt Artz, via Unsplash.

Около 70 % тяжёлых уязвимостей в C и C++ — это нарушения безопасности памяти. Система владения Rust устраняет их во время компиляции, без рантайм-сборщика — и индустрия наконец движется.

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

Цена, сделавшая идею необходимой

Ошибки памяти — не эстетическая претензия. Это доминирующий источник удалённо эксплуатируемых уязвимостей в коде на C и C++. Microsoft Security Response Center привёл цифру:

«Около 70 % уязвимостей, которым Microsoft ежегодно присваивает CVE, по-прежнему относятся к ошибкам памяти.» — Мэтт Миллер, Trends, Challenges, and Shifts in Software Vulnerability Mitigation, BlueHat IL, февраль 2019.

Команда Chromium в Google независимо сообщает ту же долю:

«Около 70 % наших серьёзных ошибок безопасности — это проблемы памяти.» — The Chromium Projects, Memory safety, текущее издание.

В феврале 2024 Office of the National Cyber Director Белого дома пошёл дальше констатации:

«Уязвимости памяти — это класс уязвимостей, связанных с тем, как память читается, записывается, выделяется или освобождается. Главное — они предотвратимы. Наиболее эффективный способ устранить весь этот класс — переход производителей ПО к языкам с безопасной работой с памятью.» — ONCD, Back to the Building Blocks: A Path Toward Secure and Measurable Software, февраль 2024.

Эмпирическое подтверждение пришло от Android. Когда команда Google начала писать новый платформенный код на Rust, не трогая старый C/C++, доля CVE, связанных с памятью, упала с 76 % всех годовых уязвимостей Android в 2019 году до 24 % в 2024 году:

«Доля уязвимостей памяти в Android снизилась с 76 % в 2019 году до 24 % в 2024 году — заметно ниже 70 % индустриальной нормы.» — Джефф Вандер Ступ, Eliminating Memory Safety Vulnerabilities at the Source, Google Security Blog, сентябрь 2024.

Старый код продолжает деградировать; новый код просто не производит новых ошибок этого класса.

Именно на этом фоне существует владение. Это не возможность языка. Это аргумент о безопасности.

Три правила

Каждое значение в Rust подчиняется трём правилам:

  1. У каждого значения ровно один владелец (привязка).
  2. Когда владелец покидает область видимости, значение уничтожается — запускается его деструктор, память освобождается.
  3. Ссылки на значение подчиняются правилу алиасинг XOR изменяемость: в любой момент существует либо одна изменяемая ссылка, либо любое число неизменяемых, но никогда обе разновидности одновременно.

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

Формально для двух живых ссылок на одно значение:

Компилятор доказывает это свойство для каждой ссылки в программе. Никаких проверок во время выполнения.

Полное доказательство того, что правила непротиворечивы — что безопасный Rust действительно не способен породить неопределённое поведение, — было дано проектом RustBelt, формализовавшим существенное подмножество языка и стандартной библиотеки в системе доказательств Coq.

«RustBelt — это первая формальная верификация языка Rust, проверяющая не только систему типов, но и репрезентативный набор библиотек из стандартной библиотеки Rust.» — Юнг, Журдан, Креббер, Дрейер, RustBelt: Securing the Foundations of the Rust Programming Language, POPL 2018.

Семантика перемещения и RAII

fn main() {
    let s1 = String::from("привет");
    let s2 = s1; // s1 ПЕРЕМЕЩАЕТСЯ в s2

    // println!("{}", s1); // error[E0382]: использование перемещённого значения

    println!("{}", s2); // владельцем стал s2; при выходе из области уничтожен ровно раз
}

Присваивание let s2 = s1 не копирует буфер в куче — оно передаёт владение. Дальше компилятор отслеживает, что s1 неинициализирован. Когда s2 покидает область, Drop::drop запускается ровно один раз. Нет ни подсчёта ссылок, ни трассирующего сборщика, ни ручного free.

Это та же модель RAII, что C++ имеет с 1985 года, — только обеспеченная компилятором. В C++ небрежный конструктор копирования или забытый std::move приводят к двойному освобождению или утечке. В Rust borrow-checker отказывается компилировать и то, и другое.

Заимствование: алиасинг XOR изменяемость

Ссылка в Rust бывает двух видов:

ФормаЗаписьВозможность
Общая (неизменяемая)&TСколько угодно одновременно; только чтение
Эксклюзивная (изменяемая)&mut TНе более одной одновременно; чтение и запись

Borrow-checker обеспечивает эти свойства как статический type-state каждой привязки. Выигрыш — не только безопасность памяти: оптимизатор вправе предположить, что общие ссылки &T не алиасятся ни с какой &mut T на ту же область памяти. Это та самая аннотация noalias, которую LLVM всегда хотел получить от C, но не мог надёжно. Rust выдаёт её по построению.

Платой служит знаменитая кривая обучения. Шаблоны, тривиально компилирующиеся в Java или Go, — двусвязный список, граф с указателями на родителя, замыкание, захватывающее изменяемую ссылку за пределы своей области, — не выразимы без unsafe, Rc<RefCell<T>> или редизайна. Сообщество сошлось на том, что это особенность, а не недостаток: такие шаблоны обычно сами были ошибкой.

Времена жизни

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

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

Сигнатура объявляет, что возвращаемая ссылка заимствует из той же области 'a, что и входные. Отношение между временами жизни задаётся подтипизацией: 'a: 'b означает «'a живёт дольше, чем 'b». Большинство аннотаций выводится по правилам элизии; явные аннотации появляются лишь там, где связь между входом и выходом действительно неоднозначна.

Времена жизни стираются на этапе компиляции. Они не порождают кода и не занимают памяти во время выполнения. Они существуют только как обязательство доказательства для проверки типов.

Send, Sync и бесстрашная конкурентность

Маркерные трейты Send и Sync распространяют рассуждения о владении на потоки:

  • T: Send — значения типа T можно передавать через границы потоков.
  • T: Sync — &T можно разделять между потоками (эквивалентно: &T: Send).

Эти трейты выводятся автоматически по структурному составу типа. Rc<T> (не-атомарный счётчик ссылок) — !Send, потому что одновременное декрементирование счётчика из двух потоков образовало бы гонку. Arc<T> — Send + Sync, потому что счётчик атомарный. Компилятор на этапе компиляции отказывается запускать поток, захватывающий значение, не реализующее Send.

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

Чего это стоит

У модели владения есть издержки, которые любой честный разбор обязан перечислить.

  • Async и времена жизни. Заимствования через точки await упираются в проблему Pin и самореферентных генераторов, окончательно не решённую в языке. Async-функции в трейтах, стабилизированные в конце 2023 года, по-прежнему имеют пробелы в эргономике, которые продвинутые паттерны обходят крейтами вроде async-trait.
  • Связные структуры данных. Классические графы и деревья без unsafe требуют арена-аллокаторов или индексов вместо указателей. Это корректно, но не то, чего ждёт программист на C.
  • Время компиляции. Мономорфизация плюс анализ заимствований делают Rust медленным компилятором. Чистая сборка нетривиального воркспейса — минуты, не секунды.
  • Кривая обучения. Программистам, свободно владеющим другим системным языком, обычно требуется несколько месяцев, прежде чем они перестают бороться с borrow-checker’ом.
  • unsafe. Корпусное исследование 2020 года показало, что около 29 % крейтов на crates.io содержат хотя бы один блок unsafe, а стандартная библиотека внутри обширно unsafe. Владение — это гарантия про безопасный Rust; unsafe — люк, где гарантия не действует и где её должен подхватывать человеческий аудит.

Ни одна из этих издержек не смертельна. Все они реальны.

Где сейчас находится Rust

ПоверхностьСостояние
Ядро LinuxПоддержка Rust влилась в 6.1, октябрь 2022; подсистемы драйверов и rust-for-linux растут
WindowsМодули на Rust в DWriteCore, движке регионов Win32 GDI/GDI+, частях win32k (2023–24)
Платформа AndroidНовый нативный код на Rust; доля CVE памяти упала с 76 % (2019) до 24 % (2024)
ChromiumRust разрешён для сторонних библиотек с января 2023; first-party-внедрение растёт
AWS Firecracker, BottlerocketПромышленный VMM и ОС для AWS Lambda и Fargate, написаны на Rust
Криптография и TLSrustls, ring, dalek-cryptography, RustCrypto; проаудированы и в продакшене
Встраиваемые системыЭкосистема embedded-hal стабильна; Tock OS в эксплуатации, Hubris в железе Oxide Cloud Computer

Наиболее цитируемое индустриальное одобрение пришло от технического директора Microsoft Azure:

«Что касается языков, пора прекратить начинать новые проекты на C/C++ и использовать Rust там, где требуется язык без сборки мусора. Ради безопасности и надёжности индустрия должна объявить эти языки устаревшими.» — Марк Руссинович, сентябрь 2022.

CISA, АНБ и ONCD США опубликовали рекомендации в пользу языков с безопасной работой с памятью для нового кода в критических системах. Rust — единственный язык системного уровня в этой категории с подтверждённым промышленным развёртыванием.

Тихая часть

Модель владения часто описывают как ловкий трюк. Это не так. Это операционное следствие куда более старой идеи — линейных типов, восходящих к линейной логике Жирара (1987) и к работе Уодлера «Linear Types Can Change the World!» (1990): системам типов, отслеживающим использование ресурсов так, чтобы значения нельзя было молча дублировать или отбросить. Rust изобрёл не теорию. Он сделал теорию достаточно эргономичной, чтобы её приняли системные программисты.

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

«Криптографию редко взламывают; её обходят.» — Ади Шамир

То же верно и для безопасности в системном коде. C и C++ небезопасны не потому, что их системы типов слабы. Они небезопасны потому, что безопасный путь требует бдительности, которую человек не выдерживает на миллионе строк. Владение снимает это требование.

Источники

  1. Miller, M. Trends, Challenges, and Shifts in Software Vulnerability Mitigation. BlueHat IL, Microsoft Security Response Center, февраль 2019. github.com/Microsoft/MSRC-Security-Research
  2. The Chromium Projects. Memory safety. www.chromium.org/Home/chromium-security/memory-safety
  3. Office of the National Cyber Director. Back to the Building Blocks: A Path Toward Secure and Measurable Software. Белый дом, февраль 2024. bidenwhitehouse.archives.gov/…/Final-ONCD-Technical-Report.pdf
  4. Vander Stoep, J. Eliminating Memory Safety Vulnerabilities at the Source. Google Security Blog, сентябрь 2024. security.googleblog.com
  5. NSA. Software Memory Safety Cybersecurity Information Sheet. ноябрь 2022. media.defense.gov
  6. Jung, R.; Jourdan, J.-H.; Krebbers, R.; Dreyer, D. RustBelt: Securing the Foundations of the Rust Programming Language. POPL 2018. plv.mpi-sws.org/rustbelt
  7. Astrauskas, V.; Matheja, C.; Poli, F.; Müller, P.; Summers, A. How Do Programmers Use Unsafe Rust? OOPSLA 2020. doi.org/10.1145/3428204
  8. Girard, J.-Y. Linear Logic. Theoretical Computer Science 50(1), 1987.
  9. Wadler, P. Linear Types Can Change the World! In Programming Concepts and Methods, North-Holland, 1990.
  10. Klabnik, S.; Nichols, C. The Rust Programming Language. No Starch Press / rust-lang.org. doc.rust-lang.org/book
  11. Torvalds, L. Merge начальной поддержки Rust, Linux 6.1, октябрь 2022. git.kernel.org
  12. Russinovich, M. Публичное заявление о принятии Rust, 19 сентября 2022. twitter.com/markrussinovich/status/1571995117233504257