Stabilizacja async/await w Rust 1.39, obok typu Pin wprowadzonego w wersji 1.33, umożliwiła bezpieczne struktury samoodwołujące się, które są kluczowe dla asynchronicznych maszyn stanowych. Struktury te często zawierają wskaźniki wewnętrzne wskazujące na dane będące własnością samej struktury, takie jak bufory i aktywne widoki na te bufory. Przy implementacji ręcznych przyszłości lub złożonych intruzywnych struktur danych, programiści muszą uzyskiwać dostęp do poszczególnych pól przez Pin<&mut Self>, co stwarza potrzebę bezpiecznych mechanizmów projekcji, które zachowują gwarancje lokalizacji pamięci.
Gdy struktura jest zablokowana za pomocą Pin, kompilator zapewnia, że jej adres pamięci pozostaje stały przez czas trwania pinowania, pod warunkiem, że typ nie implementuje Unpin. Jeśli struktura przechowuje wskaźniki samoodwołujące się, takie jak surowy wskaźnik do wewnętrznego wektora, przeniesienie struktury unieważni te wskaźniki, tworząc dangling references. Naiwne podejście do projekcji, które po prostu dereferencjuje Pin<&mut Self> na &mut Self, naraża pola na bezpieczny kod Rust, który może legalnie wywołać mem::swap lub mem::replace na tych polach, a tym samym przenieść je z ich zablokowanych lokalizacji pamięci, naruszając fundamentalną umowę pinowania.
Bezpieczna projekcja wymaga niebezpiecznej konwersji, która zachowuje inwariant pinowania: jeśli struktura nadrzędna jest !Unpin, projekcja pola musi zwracać Pin<&mut Field> zamiast &mut Field, aby zapobiec przenoszeniu. Implementacja musi gwarantować, że pole jest strukturalnie zablokowane, co oznacza, że jego stan pinowania jest powiązany z stanem pinowania struktury nadrzędnej, co zazwyczaj osiąga się przez arytmetykę wskaźników lub Pin::map_unchecked_mut. Dla pól, które implementują Unpin, projekcja może bezpiecznie zwracać &mut Field, ponieważ te typy mogą być przenoszone nawet gdy są zagnieżdżone w zablokowanych danych, chociaż należy zachować ostrożność, aby takie przemiany nie unieważniły innych pól samoodwołujących się.
use std::pin::Pin; use std::marker::PhantomPinned; struct Buffer { data: [u8; 1024], cursor: *const u8, _pin: PhantomPinned, } impl Buffer { // Bezpieczna projekcja do pola danych (Unpin) fn data_mut(self: Pin<&mut Self>) -> &mut [u8; 1024] { unsafe { &mut self.get_unchecked_mut().data } } // Projekcja do pola kursora fn cursor(self: Pin<&mut Self>) -> *const u8 { unsafe { self.get_unchecked_mut().cursor } } }
Kontekst
Budowaliśmy parser wysokiej wydajności, zero-copy dla protokołu finansowego, gdzie wiadomości mogły odnosić się do podzakresów wewnętrznego bufora. Stan parsera musiał być utrzymywany podczas asynchronicznych operacji I/O, co oznacza, że struktura musiała być Pined, aby umożliwić samoodwołujące się wskaźniki w buforze.
Opis problemu
Struktura Parser przechowywała bufor Vec<u8> i &[u8] slice wskazujący na ten bufor, reprezentując bieżącą wiadomość. Podczas implementacji Stream dla tego parsera, metoda poll_next otrzymuje Pin<&mut Self>. Musieliśmy zmodyfikować bufor (aby odczytać więcej danych), jednocześnie utrzymując ważność referencji slice, co wymagało starannej projekcji pola.
Rozważane rozwiązania
Rozwiązanie A: Indeksowe adresowanie
Zamiast przechowywać slice &[u8], przechowywaliśmy indeksy (usize, usize) w wektorze. Plusy: Całkowicie bezpieczne, brak złożoności Pin, łatwe do zaimplementowania. Minusy: Przeciążenie sprawdzania granic czasowych, mniej ergonomiczne API wymagające ręcznego krojenia przy każdym dostępie, potencjalne błędy synchronizacji indeksów.
Rozwiązanie B: Niebezpieczna projekcja Pin z wykorzystaniem surowych wskaźników
Przechowywaliśmy wiadomość jako surowy wskaźnik *const u8 i długość, implementując ręczne metody projekcji za pomocą Pin::map_unchecked_mut, aby uzyskać dostęp do bufora, zachowując pole wskaźnika zablokowane. Plusy: Abstrakcja zero-kosztowa, utrzymuje samoodwołalność, pozwala na bezpośrednią arytmetykę wskaźników. Minusy: Wymaga bloków kodu unsafe, ryzyko nieokreślonego zachowania, jeśli inwarianty Pin są naruszone (np. błędna implementacja Unpin).
Rozwiązanie C: Użycie crate pin-project
Wykorzystanie makr proceduralnych do automatycznego generowania bezpiecznego kodu projekcji. Plusy: Ergonomiczne, dobrze przetestowane inwarianty bezpieczeństwa, redukuje powtarzalność. Minusy: Dodatkowa zależność, kod generowany przez makra może być trudniejszy do debugowania, niewielki koszt kompilacji.
Wybrane rozwiązanie i wynik
Wybraliśmy Rozwiązanie B, aby uniknąć zewnętrznych zależności w kontekście naszych systemów wbudowanych oraz aby zachować wyraźną kontrolę nad układem pamięci. Starannie upewniliśmy się, że struktura nie implementuje Unpin, dodając PhantomPinned i napisaliśmy wyczerpujące testy Miri, aby zweryfikować inwarianty pinowania. Rezultatem był parser osiągający semantykę zero-copy bez alokacji na wiadomość, utrzymujący przez 10Gbps przepustowość bez saturacji CPU.
Dlaczego niestabilne jest implementowanie Unpin dla struktury zawierającej wskaźniki samoodwołujące się?
Unpin wskazuje, że typ może być bezpiecznie przenoszony nawet gdy jest owinięty w Pin, co pozwala bezpiecznemu kodowi uzyskać &mut T z Pin<&mut T> za pomocą metod takich jak Pin::into_inner. Dla struktury samoodwołującej się, przeniesienie struktury zmienia adres pamięci jej zawartości, unieważniając wszelkie wewnętrzne wskaźniki odniesienia do tych zawartości. Implementacja Unpin zezwoliłaby bezpiecznemu kodowi na przeniesienie struktury, gdy jest zablokowana, naruszając gwarancje bezpieczeństwa, które Pin zapewnia dla asynchronicznych runtime'ów i prowadząc do podatności na użycie po zwolnieniu pamięci. Dlatego takie struktury muszą używać PhantomPinned, aby wyraźnie zrezygnować z Unpin i zapobiec przypadkowemu automatycznemu zaimplementowaniu.
Jak różni się projekcja dla wariantów enum w porównaniu do pól struktury?
Wielu kandydatów zakłada, że mechanika projekcji jest identyczna dla enum i struktur, ale enumy przedstawiają unikalne wyzwania, ponieważ dyskryminant określa, który wariant jest aktywny. Projekcja Pin<&mut Enum> do konkretnego wariantu wymaga zapewnienia, że wariant pozostaje zablokowany, jednocześnie zapobiegając zmianie dyskryminanta, ponieważ przełączanie wariantów przeniosłoby underlying dane. Rust nie ma stabilnego wbudowanego wsparcia dla projekcji wariantów, ponieważ dyskryminant i dane wariantu dzielą zasady układu pamięci; bezpieczna projekcja wymaga niebezpiecznego kodu, który potwierdza aktywny wariant i gwarantuje, że żaden przełącznik wariantów nie wystąpi, gdy enum pozostaje zablokowany.
Jaka jest rola PhantomPinned w zapobieganiu automatycznym implementacjom traitów?
Początkujący często nie zauważają, że Rust automatycznie implementuje Unpin dla większości typów, chyba że wyraźnie zawierają one pola !Unpin, co sprawiłoby, że typ zawierający byłby domyślnie !Unpin. PhantomPinned to typ markera o zerowej wielkości, wyraźnie zdefiniowany jako !Unpin, służący jako negatywne ograniczenie implementacji, gdy jest zawarty w strukturze. Bez tego markera, nawet jeśli deweloperzy napiszą niebezpieczny kod projekcji zakładający, że struktura jest niemobilna, kompilator mógłby automatycznie zaimplementować Unpin, co pozwala bezpiecznemu kodowi na wyodrębnienie i przeniesienie struktury przez Pin::into_inner_unchecked, łamiąc w ten sposób niebezpieczne inwarianty i wywołując nieokreślone zachowanie.