RustprogramowanieRust Developer

Jak wariancja typu &mut T zapobiega prawidłowemu przypisaniu &mut &'long str do &mut &'short str, a jaki problem z bezpieczeństwem pamięci by to umożliwiło, gdyby było dozwolone?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie

Historia pytania

Wariancja w systemach typów określa, jak relacje podtypów między parametrami ogólnymi wpływają na ogólny typ. Podejście Rust było w dużej mierze inspirowane badaniami nad zarządzaniem pamięcią opartym na regionach oraz potrzebą zapobiegania lukom bezpieczeństwa typu use-after-free. Kiedy Rust wprowadził referencje mutowalne (&mut T), projektanci musieli zdecydować, czy powinny być one kowe, przeciwne, czy niewariantowe. Wybór niewariantowości dla &mut T w porównaniu do T był kluczowy dla zachowania bezpieczeństwa pamięci bez wymogu kontroli czasów wykonania.

Problem

Gdyby &mut T był kowe względem T, można byłoby zastąpić &mut U tam, gdzie oczekiwano &mut V, jeśli U jest podtypem V. W kontekście czasów życia, ponieważ 'long jest podtypem 'short (ponieważ 'long przetrwa 'short), oznaczałoby to, że można by przypisać &mut &'long str do &mut &'short str. To wydaje się nieszkodliwe, ale tworzy lukę w poprawności.

Rozwiązanie

&mut T jest niewariantowe względem T. Oznacza to, że &mut &'a str i &mut &'b str są typami niezwiązanymi, chyba że 'a dokładnie równa się 'b, niezależnie od relacji podtypów między czasami życia. Kompilator odrzuca kod, który próbuje dokonać przekształcenia między nimi, zapobiegając przypisywaniu danych o krótkim czasie życia do miejsc oczekujących referencji o dłuższym czasie życia poprzez mutowalne pośrednictwo.

Przykład kodu:

fn demonstrate_invariance() { let mut long_lived: &'static str = "static string"; // To by się skompilowało, gdyby &mut T było kowe: // let short_ref: &mut &'short str = &mut long_lived; // Ale ponieważ &mut T jest niewariantowe, to się nie uda: // error: lifetime mismatch // let short_ref: &mut &'_ str = &mut long_lived; let local = String::from("temporary"); // Gdyby to było dozwolone, moglibyśmy zrobić: // *short_ref = &local; // Teraz long_lived wskazuje na usunięte dane (UAF!) } // local usunięty tutaj

Sytuacja z życia wzięta

Zespół budował menedżera konfiguracji dla wydajnej stosu sieciowego. Główna struktura musiała przechowywać mutowalną referencję do konfiguracji protokołu, którą można było wymieniać w czasie działania bez przejmowania własności.

Problem: Początkowy projekt API używał &mut &'a Config, gdzie 'a był czasem życia sesji sieciowej. Programiści próbowali zainicjować to za pomocą &mut &'static Config (dla globalnych domyślnych konfiguracji), a następnie przekazać to do funkcji oczekujących &mut &'session Config. Kompilator odrzucił to, powodując zamieszanie, ponieważ niemutowalne referencje (& &'static Config) działały dobrze.

Rozważane rozwiązania:

1. Niebezpieczne Transmute do wymuszenia konwersji Zespół rozważał użycie std::mem::transmute, aby przekształcić &mut &'static Config na &mut &'session Config. To ominęłoby kontrole wariancji kompilatora. Jednakże, mogłoby to umożliwić zapisanie referencji do krótkoterminowej konfiguracji w miejscu, które może przetrwać obecną zakres, prowadząc do natychmiastowego niezdefiniowanego zachowania, jeśli konfiguracja byłaby uzyskiwana po jej usunięciu. Ryzyko use-after-free w kodzie produkcyjnym uczyniło to nieakceptowalnym.

2. Zmiana na niemutowalne referencje Rozważali zmianę API na użycie & &'a Config zamiast &mut &'a Config. Ponieważ wspólne referencje są kowe, & &'static Config mogłoby być przekształcone na & &'session Config. Jednak usunięcie możliwości atomowej wymiany konfiguracji podczas aktualizacji w czasie rzeczywistym stanowiło kluczowy wymóg do hot-reloadowania ustawień bez ponownego uruchamiania połączeń.

3. Użycie Cell<&'a Config> dla wewnętrznej mutowalności Ta opcja umożliwiłaby mutację poprzez wspólną referencję. Jednak Cell<T> również jest niewariantowe względem T z tych samych powodów bezpieczeństwa, więc nie rozwiązało to problemu wariancji. Dodatkowo, Cell nie zapewnia synchronizacji dla dostępu wielowątkowego, a nadmiar kontroli wypożyczeń w czasie wykonania za pomocą RefCell uznano za zbyt kosztowny w ścieżce krytycznej.

4. Przeprojektowanie z typami własności i pośrednictwem Wybrane rozwiązanie całkowicie wyeliminowało wzorzec referencja-do-referencji. Zamiast przechowywać &mut &'a Config, struktura przechowywała &'a mut ConfigHolder, gdzie ConfigHolder był opakowaniem z własnością. Przeniosło to mutowalność na poziom holdera, a nie referencji, unikając pułapki wariancji przy jednoczesnym zachowaniu możliwości wymiany konfiguracji. API stało się bardziej ergonomiczne, ponieważ użytkownicy nie musieli już zarządzać podwójnymi referencjami.

Rezultat: Przeprojektowanie przyniosło bezpieczniejsze API, które skompilowało się bez niebezpiecznego kodu. Niewariantowy charakter &mut T zmusił zespół do dostrzegania potencjalnej wady architektonicznej, gdzie asumptory czasów życia mogłyby zostać naruszone. Ostateczny system zapobiegał kategoriom błędów, gdzie wskaźniki do przestarzałych konfiguracji mogły przetrwać poza ich okresem ważności.

Co kandydaci często pomijają

Dlaczego Cell<T> jest niewariantowe względem T, a jak to się odnosi do wariancji &mut T?

Cell<T> zapewnia wewnętrzną mutowalność, umożliwiając mutację przez wspólne referencje. Gdyby Cell<T> był kowy względem T, można byłoby dokonać rzutowania podwyższającego Cell<&'short str> na Cell<&'static str>. Następnie można byłoby przechować krótkoterminową referencję do ciągu w środku i później odczytać ją przez typ Cell<&'static str>, traktując dane tymczasowe jako statyczne. To stworzyłoby lukę we władzy use-after-free. Dlatego, tak jak &mut T, Cell<T> (i UnsafeCell<T>) muszą być niewariantowe względem T, aby zapobiec zapisaniu danych o krótkim czasie życia do slotu, który twierdzi, że przechowuje dane o dłuższym czasie życia. Ta niewariantowość propaguje się do RefCell, Mutex i innych typów wewnętrznej mutowalności.

Jak PhantomData<T> wpływa na wariancję struktury, która nie zawiera rzeczywistego T, i dlaczego użyłbyś PhantomData<fn(T)>, aby osiągnąć kontrawariancję?

PhantomData<T> informuje kompilator, aby potraktował strukturę tak, jakby posiadała T w celach wariancji i sprawdzania zrzucania. Domyślnie, PhantomData<T> nadaje strukturze tę samą wariancję co T. Jednak wskaźniki funkcyjne mają specjalną wariancję: fn(A) -> B jest kontrawariantne w A (argument) i kowe w B (powrót). Jeśli potrzebujesz, aby struktura była kontrawariantna względem czasu życia (co oznacza, że Struct<'long> jest podtypem Struct<'short>, gdy 'long przetrwa 'short), używasz PhantomData<fn(T)>. To jest kluczowe dla budowania typów bezpiecznych zwrotnych wywołań lub porównywaczy, gdzie relacja między czasami życia musi zostać odwrócona.

W kodzie niebezpiecznym, podczas implementacji struktury samoodniesienia przy użyciu wskaźników surowych, dlaczego struktura musi być oznaczona jako niewariantowa względem swoich parametrów czasu życia?

Kiedy struktura zawiera surowy wskaźnik, który wskazuje na inne dane w ramach tej samej struktury (samoodniesienie), czas życia tej struktury określa ważność wskaźnika. Gdyby struktura była kowe względem swojego czasu życia 'a, mogłoby to sprowadzić 'a do krótszego czasu życia 'b, skutecznie twierdząc, że struktura żyje tylko przez 'b. Jednak surowy wskaźnik wewnętrzny został utworzony, gdy struktura żyła dłużej i może wskazywać na dane, które nie są już ważne w krótszym zakresie. Niewariantowość zapewnia, że struktura nie może być rzucona do krótszego czasu życia, co zachowuje nienaruszalny bezpieczeństwa, że samoodniesienie pozostaje ważne przez cały czas życia zakodowany w systemie typów. Dlatego Pin jest często łączony z wyraźnymi znacznikami wariancji w niebezpiecznych implementacjach samoodniesienia.