Стабилизация async/await в Rust 1.39, наряду с типом Pin, представленным в версии 1.33, обеспечила безопасные самоссылающиеся структуры, критически важные для асинхронных конечных автоматов. Эти структуры часто содержат внутренние указатели, ссылающиеся на данные, принадлежащие самой структуре, такие как буферы и активные представления этих буферов. При реализации ручных фьючерсов или сложных инвазивных структур данных разработчики должны получать доступ к отдельным полям через Pin<&mut Self>, что создает необходимость в безопасных механизмах проекции, которые сохраняют гарантии местоположения в памяти.
Когда структура зафиксирована через Pin, компилятор гарантирует, что ее адрес в памяти останется постоянным на время действия пиннинга, при условии, что тип не реализует Unpin. Если структура содержит самоссылающиеся указатели, такие как сырой указатель на внутренний вектор, перемещение структуры аннулирует эти указатели, создавая висячие ссылки. Наивный подход к проекции, который просто разыменовывает Pin<&mut Self> в &mut Self, подвержен доступу к полям в безопасном коде Rust, который может легально вызывать mem::swap или mem::replace на этих полях, тем самым перемещая их из их зафиксированных местоположений в памяти и нарушая фундаментальный контракт пиннинга.
Безопасная проекция требует небезопасного преобразования, которое сохраняет инвариант пиннинга: если родительская структура !Unpin, проекция поля должна возвращать Pin<&mut Field>, а не &mut Field, чтобы предотвратить перемещение. Реализация должна гарантировать, что поле структурно закреплено, что означает, что его статус пиннинга связан со статусом пиннинга родительской структуры, что обычно достигается через арифметику указателей или Pin::map_unchecked_mut. Для полей, которые реализуют Unpin, проекция может безопасно возвращать &mut Field, потому что эти типы разрешены на перемещение, даже когда они вложены в зафиксированные данные, хотя нужно быть осторожным, чтобы такие перемещения не аннулировали другие самоссылающиеся поля.
use std::pin::Pin; use std::marker::PhantomPinned; struct Buffer { data: [u8; 1024], cursor: *const u8, _pin: PhantomPinned, } impl Buffer { // Безопасная проекция к полю данных (Unpin) fn data_mut(self: Pin<&mut Self>) -> &mut [u8; 1024] { unsafe { &mut self.get_unchecked_mut().data } } // Проекция к полю курсора fn cursor(self: Pin<&mut Self>) -> *const u8 { unsafe { self.get_unchecked_mut().cursor } } }
Контекст
Мы создавали высокопроизводительный, безкопийный парсер для финансового протокола, где сообщения могли ссылаться на поддиапазоны повторно используемого внутреннего буфера. Состояние парсера необходимо было поддерживать во время асинхронных операций ввода-вывода, что означало, что структура должна была быть Pin для допуска самоссылающихся указателей в буфер.
Описание проблемы
Структура Parser содержала буфер Vec<u8> и срез &[u8], указывающий на этот буфер, представляющий текущее сообщение. При реализации Stream для этого парсера метод poll_next получает Pin<&mut Self>. Нам нужно было изменить буфер (чтобы считать больше данных), сохраняя при этом действительность ссылки на срез, что требовало осторожной проекции полей.
Рассмотренные решения
Решение A: Индексная адресация
Вместо хранения среза &[u8] мы хранили индексы (usize, usize) в векторе. Плюсы: Полностью безопасно, без сложности Pin, легко реализуется. Минусы: Появляется накладные расходы на проверку границ времени выполнения, менее удобное API, требующее ручного нарезания при каждом доступе, потенциальные ошибки некорректной синхронизации индексов.
Решение B: Небезопасная проекция Pin с использованием сырых указателей
Мы хранили сообщение как сырой указатель *const u8 и длину, реализуя методы ручной проекции с использованием Pin::map_unchecked_mut, чтобы получить доступ к буферу, сохранив поле указателя зафиксированным. Плюсы: Абстракция нулевой стоимости, хранит самоссылаемость, позволяет прямую арифметику указателей. Минусы: Требуются блоки кода unsafe, риск неопределенного поведения, если нарушаются инварианты Pin (например, неверная реализация Unpin).
Решение C: Использование крейта pin-project
Использование процедурных макросов для автоматической генерации безопасного кода проекции. Плюсы: Удобно, хорошо протестированные инварианты безопасности, сокращает количество шаблонного кода. Минусы: Дополнительная зависимость, код, сгенерированный макросами, может быть сложнее отлаживать, небольшая накладная стоимость на время компиляции.
Выбранное решение и результат
Мы выбрали Решение B, чтобы избежать внешних зависимостей в нашем контексте встроенных систем и сохранить явный контроль над макетом памяти. Мы внимательно следили за тем, чтобы структура не реализовывала Unpin, добавив PhantomPinned, и написали исчерпывающие тесты Miri, чтобы проверить инварианты пиннинга. Результатом стал парсер, достигающий семантики нулевой копии без аллокаций для каждого сообщения, поддерживающий пропускную способность 10Gbps без saturation CPU.
Почему небезопасно реализовывать Unpin для структуры, содержащей самоссылающиеся указатели?
Unpin специально сигнализирует о том, что тип безопасно перемещать даже при обертке в Pin, позволяя безопасному коду получать &mut T из Pin<&mut T> через такие методы, как Pin::into_inner. Для самоссылающейся структуры перемещение структуры изменяет адрес в памяти ее содержимого, нарушая любые внутренние указатели, ссылающиеся на эти содержимое. Реализация Unpin разрешила бы безопасному коду перемещать структуру, оставаясь пиннингованной, нарушая гарантии безопасности, которые предоставляет Pin для асинхронных рантаймов и ведя к уязвимостям использования после освобождения (use-after-free). Следовательно, такие структуры должны использовать PhantomPinned, чтобы явно исключить Unpin и предотвратить случайную автоматическую реализацию.
Как проекция отличается для вариантов перечисления по сравнению с полями структуры?
Многие кандидаты предполагают, что механика проекции идентична для перечислений и структур, но у перечислений есть уникальные проблемы, поскольку дискриминант определяет, какой вариант активен. Проецирование Pin<&mut Enum> на конкретный вариант требует обеспечения того, чтобы вариант оставался закрепленным, одновременно предотвращая изменение дискриминанта, поскольку переключение вариантов переместит основные данные. Rust не имеет стабильной встроенной поддержки проекции для вариантов, поскольку дискриминант и данные варианта разделяют соображения о компоновке памяти; безопасная проекция требует небезопасного кода, который утверждает активный вариант и гарантирует, что во время пиннинга не произойдет замены вариантов.
Какова роль PhantomPinned в предотвращении автоматических реализаций трейтов?
Начинающие разработчики часто упускают из виду, что Rust автоматически реализует Unpin для большинства типов, если они явно не содержат поля !Unpin, что сделало бы содержащий тип !Unpin по умолчанию. PhantomPinned — это тип с нулевым размером, который явно определяется как !Unpin, и служит отрицательным реализационным ограничением, когда включен в структуру. Без этого маркера, даже если разработчики пишут небезопасный код проекции, предполагая, что структура неподвижна, компилятор может автоматически реализовать Unpin, что позволит безопасному коду извлекать и перемещать структуру через Pin::into_inner_unchecked, тем самым нарушая небезопасные инварианты и вызывая неопределенное поведение.