EnglishРусский Map

Безопасность в небезопасном { мире }

title
Безопасность в небезопасном { мире }
type
summary
summary
Доклад joshlf на RustConf о том, как обучить Rust свойствам безопасности, о которых язык сам рассуждать не умеет
tags
rust, type-system, concurrency, api-design
created
2026-07-29
updated
2026-07-29
lang
ru
source_updated
2026-07-29
translated
2026-09-01
translator
lllm/antigravity/gemini-3.7-flash-medium

Доклад Джошуа Либоу-Физера (Joshua Liebow-Feeser) на RustConf 2024, пересказанный практически дословно. Основной тезис: утверждение "программы с багами не компилируются" - это не свойство, которое даёт сам Rust; это свойство, которое даёт библиотека, а Rust из коробки предоставляет лишь один его частный случай (безопасность памяти и потоков).

Доказательство - Netstack3, сетевой стек Fuchsia, написанный полностью на Rust для замены Netstack2 на Golang. Шесть лет разработки, около десяти инженеров, 63 crate'а, 192 000 строк - больше кода, чем в десяти самых популярных crate'ах на crates.io вместе взятых. Сетевой код общеизвестно сложно тестировать, поэтому от переписанного с нуля сетевого стека обычно ожидают месяцы или годы полевого догфудинга, который выявит десятки или сотни багов, прежде чем код увидят реальные пользователи. Netstack3 прошёл через 11 месяцев программы догфудинга: на пике около 60 устройств работали почти 24/7 в домах разработчиков, и за это время нашлось всего три бага. В примечании, добавленном после доклада, сообщается, что сейчас стек работает на миллионах устройств, давая примерно в 20 раз меньше падений на миллион устройств в день и потребляя на 50% меньше памяти, чем Netstack2.

Одной только безопасностью памяти это не объяснить. Реализация того, что RFC 4614 называет "базовой функциональностью" для TCP, требует шести стандартов объёмом более 270 страниц; с рекомендованными расширениями это 18 стандартов на 476 страниц. И это лишь один протокол из списка, куда входят Ethernet, ARP, NDP, IPv4, IPv6, ICMP, IGMP, MLD, UDP и прочие, не говоря об их взаимодействии. Ошибки работы с памятью и многопоточностью - лишь малая часть того, что может пойти не так.

Фреймворк

В качестве примера рассматривается бинарное дерево, за инвариантом упорядоченности которого Rust следить не умеет:

struct Node<T> {
    // INVARIANT: All values in `left` are less than `value`.
    left: Option<Box<Node<T>>>,
    // INVARIANT: All values in `right` are greater than `value`.
    right: Option<Box<Node<T>>>,
    value: T,
}

Три составляющих. Определение (Definition): создать объект, о котором система типов способна рассуждать (Node), и привязать к нему свойство, о котором она рассуждать не может (порядок сортировки), зафиксированное в тексте документации, поскольку больше его поместить некуда. Обеспечение (Enforcement): сделать поля приватными, чтобы внешний код не мог нарушить это свойство, и заставить каждый метод, конструирующий или изменяющий Node, сохранять его. Использование (Consumption): написать код, корректность которого опирается именно на то, что свойство соблюдается - здесь это поиск позиции значения за O(log N) вместо обхода всего дерева.

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

Send - это библиотека, а не возможность языка

Первый разобранный пример: рекламируемая потокобезопасность Rust вообще не является встроенной возможностью языка. Функция std::thread::spawn вынуждена вызывать что-то вроде pthread_create, живущего вне языка, поэтому вызов помещается в блок unsafe - программист берёт на себя обязательство доказательства, которое Rust выполнить не может. Сам по себе такой вызов некорректен (unsound), ведь вызывающему коду ничто не мешает передать замыкание, захватывающее Rc. Поэтому стандартная библиотека определяет unsafe маркерный типаж (trait), чей смысл зафиксирован исключительно в комментарии документации:

/// # Safety
///
/// `Self` is thread-safe.
pub unsafe trait Send {}

Определение - это типаж плюс сопровождающий текст. Обеспечение - это unsafe impl (соврать можно, только явно написав unsafe), написанные вручную реализации для примитивов и для &Mutex<T>, а также derive-макрос, требующий реализации Send для каждого поля. Использование - это ограничение F: Send у функции spawn, которое и обосновывает вызов pthread_create. Настоящий Send ради эргономики опирается на механизм auto traits, но ничто в этой архитектуре не требовало обязательной поддержки компилятором - её мог бы написать и сторонний разработчик. См. rust-send-sync о том, что на самом деле значат Send и Sync и как &T: Send соотносится с T: Sync; rust-async-trait-sync-bound - пример того, как эти ограничения прорастают туда, где о них никто не просил.

memory-safety-absolutists переносит эту трактовку на претензию, будто наличие unsafe лишает Rust права называться безопасным по памяти языком. Если unsafe - это инструмент инкапсуляции доказательства, которое компилятор не может проверить сам, то само по себе его наличие ни о чём не говорит, и реальный вопрос в том, как часто эти доказательства оказываются ошибочными.

Отсутствие взаимных блокировок как свойство системы типов

Второй пример - работа Алекса Конради (Alex Konradi) над порядком захвата блокировок в Netstack3. Мелкогранулярные блокировки формируют граф порядка захвата, и отсутствие взаимных блокировок (deadlocks) означает, что этот граф ацикличен. В Netstack3 на 192 000 строк кода приходится 77 мьютексов, поэтому отслеживать порядок их взятия вручную невозможно - а у Netstack2 на Golang за плечами была длинная история дедлоков.

Каждый мьютекс получает имя в виде фантомного параметра типа:

struct Mutex<Id, T> {
    mtx: std::sync::Mutex<T>,
    _marker: PhantomData<Id>,
}

enum IpLock {}
enum DeviceLock {}

Рёбра графа превращаются в unsafe типажи LockAfter<M> и LockBefore<M>, генерируемые макросом impl_lock_after!(A => B), который также создаёт blanket-реализацию. В этой blanket-реализации и заключается трюк: циклическое применение макроса приводит к конфликтующим blanket-реализациям, из-за чего циклические графы не компилируются. Позиция в графе отслеживается структурой нулевого размера LockCtx<Id>, а метод lock передаёт её по цепочке:

pub fn lock<L>(&self, ctx: &mut LockCtx<L>) -> (MutexGuard<'_, T>, LockCtx<Id>)
where
    L: LockBefore<Id>,

Мутабельное заимствование блокирует использование старого контекста на всё время жизни гарда (guard), поэтому возвращённый LockCtx<Id> остаётся единственным доступным, а он разрешает захватывать только сущности, расположенные в графе ниже Id. Попытка заблокировать устройство, а затем IP (когда граф требует, чтобы IP шёл первым) обернётся ошибкой несоответствия типажа (trait bound) на втором вызове lock, а не зависанием в продакшене.

Результатом стал диф в одну строку. После нескольких лет протаскивания мьютексов и гардов через весь стек команда изменила количество worker'ов по умолчанию с 1 на 4 - и всё заработало без единого бага с первой же попытки.

Либоу-Физер честно признаёт, что показанная версия упрощена: ему известны два способа обойти эту защиту от дедлоков, и реальная библиотека сознательно соглашается на компромисс "достаточно сложно, чтобы не сделать случайно", а не "буквально невозможно". Этот компромисс остаётся открытым вопросом, а не окончательным решением.

Советы

Частично определённые функции (partial functions) стоит воспринимать как запах кода. Публичные функции не должны паниковать и не должны возвращать Option или Result, если только ошибка не заложена в саму природу моделируемого процесса: сбой ввода-вывода - нормальная ситуация, а вот отклонение недопустимого аргумента означает, что такой вызов вообще не должен был скомпилироваться.

Форма API должна повторять форму задачи. Ошибка парсинга IP в Netstack3 содержит parameter problem pointer - байтовое смещение некорректного поля, которое отправляется обратно в пакете с ошибкой. В IPv4 оно занимает один байт, в IPv6 - четыре, поэтому поле параметризовано версией IP (I::ParameterProblemPointer), а не задано единым u32. Использование u32 привело бы к потенциально сбойным конвертациям в других частях кодовой базы, где ошибка либо породила бы некорректный пакет с ошибкой, либо уронила стек в панику.

К внутренним API нужно подходить с той же строгостью, что и к публичным: соглашение о вызовах, очевидное прямо сейчас, перестанет быть очевидным тому, кто придёт в проект через четыре года и шесть рефакторингов. И не стоит ждать готовых рецептов: обнаружение циклов при взятии блокировок не следует из самого языка автоматически. Цитируя Роба Пайка (Rob Pike) о дизайне Golang, "простота - это искусство прятать сложность": внутри библиотека упорядочивания блокировок устроена некрасиво, но ментальная модель для её пользователей укладывается в одно предложение - циклические графы не компилируются.

У этой методологии есть заметная слепая зона, и tokio-progress-not-ordering служит хорошей иллюстрацией: инвариант в том случае (все задачи от одного события завершаются примерно во время их отправки) - это свойство приложения, а не какого-либо типа, видимого рантайму, так что никакая библиотечная дисциплина внутри Tokio не смогла бы его поймать.