Как Rust обманывает процессор. Часть 2: niche сквозь крейты, dropck, Pin и провенанс указателей

Страницы:  1

Ответить
 

Professor Seleznov


В первой части мы обсуждали niche-оптимизацию, drop flags, MIR, Stacked Borrows и async-стейт-машины. В комментариях справедливо заметили (спасибо, Mingun): про niche рассказано в простой форме - Option<&T> и NonZeroU8. А что происходит, когда enum живёт в одном крейте, оборачивается в newtype в другом, и оба варианта внешнего enum хранят один и тот же внутренний? У такого внешнего типа всего четыре состояния, байта должно хватить. Хватит ли? Зависит от того, как rustc считает layout. Об этом и поговорим.
Во второй части идём глубже: niche сквозь границы крейтов, variance, Pin и самоссылающиеся футуры, dropck с #[may_dangle], Tree Borrows вместо Stacked Borrows и strict provenance. Без этого половина unsafe-кода в экосистеме держится на честном слове.
1. Niche сквозь границы крейтов
Возьмём пример из комментария:
// crate inner
pub enum Inner { A, B }
// crate outer (зависит от inner)
pub enum Outer {
Variant1(Inner),
Variant2(Inner),
}
У Outer ровно четыре состояния: (V1, A), (V1, B), (V2, A), (V2, B). Кажется, size_of::() обязан быть 1 байт: два бита под дискриминант плюс один бит под Inner. На практике компилятор выдаёт 1 байт, и -Zprint-type-sizes это подтверждает:
print-type-size type: `Outer`: 2 bytes, alignment: 1 bytes
print-type-size discriminant: 1 bytes
print-type-size variant `Variant1`: 1 bytes
print-type-size field `.0`: 1 bytes
print-type-size variant `Variant2`: 1 bytes
print-type-size field `.0`: 1 bytes
Стоп, тут 2 байта, а не 1. Первая ловушка: niche-алгоритм rustc не умеет «сжимать» дискриминант внешнего enum в неиспользуемые битовые паттерны внутреннего, если тип пришёл из другого крейта и не помечен как #[repr]-стабильный. Алгоритм консервативен: он смотрит на «дырки» в layout-е InnerInner { A, B } это значения 2…=255) и может туда положить дискриминант, но только в одном направлении - когда внешний enum имеет вариант без полей. Здесь оба варианта несут Inner целиком, и компилятор не складывает их в один байт.
Если переписать в enum Outer2 { V1, V2(Inner) }, layout схлопнется до 1 байта: V1 представлен значением 2 в байте Inner, а V2(_) - значениями 0 и 1. Если нужно держать обе формы и упаковать вручную, есть способ через #[repr(u8)] и явные дискриминанты, и этот способ работает на уровне ABI-контракта.
Контракт между крейтами устроен так: niche-разметка типа - часть его layout, и она нестабильна. Когда вы публикуете pub enum Inner без #[repr], вы не обещаете соседнему крейту, что у Inner останется текущий набор niche-значений. Завтра добавите вариант C, niche у внешнего Outer поедет, размер может вырасти, и какой-нибудь Vec через FFI окажется не таким, каким был.
Практический вывод: niche - мощный, но локальный приём. На него можно опираться внутри одного крейта (в духе NonZero*, Box, &T), а строить ABI или сериализацию поверх niche чужого типа - плохая идея. Побочный эффект: Option> занимает 1 байт за счёт того, что у bool есть niche 2..=255, внутренний Option забирает значение 2, внешний - значение 3. Уберите этот контракт у bool, и вся башня рассыплется.
2. Variance, или почему &mut T инвариантен
Вторая тема, без которой жить с unsafe тяжело - variance. Если коротко: при наличии параметра T тип F может быть ковариантен (подтип T даёт подтип F), контравариантен (наоборот) или инвариантен (никаких отношений). У ссылок Rust это распределяется так: &'a T ковариантен по 'a и по T, &'a mut T ковариантен по 'a, но инвариантен по T, fn(T) -> U контравариантен по T и ковариантен по U.
Почему &mut T инвариантен - классический вопрос на собеседовании. Ответ: потому что через &mut T можно записать. Если бы &mut T был ковариантен, можно было бы взять &mut Vec<&'static str>, привести к &mut Vec<&'short str> (формально подтип, ведь 'static: 'short) и записать туда короткоживущую ссылку. После выхода из области видимости остался бы Vec<&'static str> с висячей ссылкой внутри. Компилятор этого не разрешает, отсюда инвариантность.
fn extend_lt<'a>(v: &mut Vec<&'static str>, s: &'a str) {
// если бы &mut был ковариантен, тут бы прокатило приведение
// и v получил бы ссылку с временем жизни 'a < 'static
v.push(s); // не скомпилируется
}
Когда вы пишете обёртку вроде struct MyCell { ptr: *mut T }, компилятор не знает, что вы там делаете внутри, и по умолчанию выводит variance из полей. У сырого указателя *mut T инвариантность по T, у *const T ковариантность. Если нужен явный контроль, используют PhantomData:
use std::marker::PhantomData;
// Ковариантен по T, как &T
struct Covariant(*const T, PhantomData);
// Инвариантен по T, как &mut T или Cell
struct Invariant(*mut T, PhantomData<*mut T>);
// Контравариантен, нужен fn(T)
struct Contravariant(PhantomData);
Эти трюки видны во всём std: у Cell и RefCell поле UnsafeCell делает их инвариантными, иначе можно было бы через ковариантность подделать тип.
Отдельная тонкость - variance и 'static. Многие думают, что &'static T всегда сильнее любого &'a T, и это правда, но только до момента, когда тип попадает в инвариантную позицию. Простейший пример: Box инвариантен по 'a (внутри fat-pointer), и попытка передать Box туда, где ждут Box, не пройдёт без явного приведения.
3. Pin, !Unpin и самоссылающиеся async-футуры
Pin появился, чтобы решить одну задачу: разрешить типу хранить ссылки на самого себя. Без Pin это невозможно безопасно - при перемещении объекта внутренние ссылки превратятся в висячие. Async-блоки в Rust - главный потребитель Pin, потому что компилятор разворачивает async fn в стейт-машину, поля которой могут ссылаться на другие поля той же структуры.
Простейший пример самоссылочной футуры:
async fn read_buf() -> usize {
let buf = [0u8; 1024];
let slice = &buf[..]; // ссылка на стек
some_async_io(slice).await; // .await может вернуть управление
slice.len()
}
После .await стейт-машина должна сохранить и buf, и slice, причём slice указывает внутрь buf. Если такой объект сдвинуть в памяти, slice станет недействительным. Pin запрещает сдвигать.
Контракт Pin: Pin

, где P: Deref, гарантирует, что значение, на которое указывает P, не будет перемещено до конца своего жизненного цикла (точнее, до момента drop). Исключение - типы, реализующие Unpin, для них Pin семантически прозрачен. По умолчанию Unpin реализован для всех типов через автотрейт, но компилятор снимает реализацию у async-стейт-машин и у структур, явно содержащих PhantomPinned.

use std::pin::Pin;
use std::marker::PhantomPinned;
struct SelfRef {
data: String,
ptr: *const u8, // указывает в data
_pin: PhantomPinned, // снимает Unpin
}
impl SelfRef {
fn new(data: String) -> Pin> {
let mut boxed = Box::pin(Self {
data,
ptr: std::ptr::null(),
_pin: PhantomPinned,
});
let ptr = boxed.data.as_ptr();
// SAFETY: не двигаем self, только записываем поле
unsafe {
let mut_ref: Pin<&mut Self> = boxed.as_mut();
Pin::get_unchecked_mut(mut_ref).ptr = ptr;
}
boxed
}
}
Тонкое место - Pin::get_unchecked_mut. Это unsafe-метод, дающий &mut T из Pin<&mut T>. Контракт: вы обещаете, что не используете этот &mut T для перемещения значения (например, через mem::replace или mem::swap). Любая такая операция - UB, причём UB немедленный: библиотеки полагаются на этот инвариант и могут хранить наружу указатели на внутренности pin-нутого объекта.
Связь с async: когда Future::poll принимает Pin<&mut Self>, исполнитель обязан гарантировать, что после первого poll футура не двигается. Поэтому tokio::spawn принимает Future + Send + 'static и сразу прячет её в Box или в слот аренного аллокатора, чтобы адрес стабилизировался. Если в исполнителе или в ручной комбинаторной обвязке нарушить этот контракт, словите UB на следующем .await.
4. Dropck, #[may_dangle] и парадокс Vec<&'a T>
Dropck - третий сложный механизм Rust после borrow checker и trait resolution. Его задача: убедиться, что в момент вызова Drop::drop для значения T все ссылки внутри T ещё валидны. По умолчанию компилятор требует, чтобы любой 'a внутри T пережил сам T. Политика разумная, но она ломает популярный паттерн с коллекциями короткоживущих ссылок.
struct Foo<'a>(&'a str);
impl<'a> Drop for Foo<'a> {
fn drop(&mut self) { println!("{}", self.0); }
}
fn main() {
let s = String::from("hi");
let f = Foo(&s);
drop(s); // ошибка: s используется в f.drop()
}
Здесь dropck корректно ругается: Foo::drop читает self.0, ссылку на s. Тот же механизм по умолчанию запрещает компилироваться даже коду, где Drop ссылку не трогает, потому что компилятор не верит на слово.
fn main() {
let mut v: Vec<&str> = Vec::new();
let s = String::from("hi");
v.push(&s);
// v должен дропнуться после s, но Vec не читает &str в drop
}
Чтобы такое работало, у Vec есть unsafe impl
 
Loading...
Error