Koncepcja Pin powstała z potrzeby Rust wspierania programowania asynchronicznego bez kompromisów w zakresie bezpieczeństwa pamięci. Historycznie, języki systemowe, takie jak C++, pozwalały na użycie struktur samonawigujących, ale cierpiały z powodu błędów związanych z użyciem po przeniesieniu, gdy obiekty były relokowane w pamięci. Główny problem pojawia się, gdy struktura zawiera wskaźniki do własnych pól; jeśli struktura zostanie skopiowana bitowo pod nowy adres, te wewnętrzne wskaźniki stają się dangling references do zwolnionych obszarów stosu. Pin rozwiązuje to, opakowując typy wskaźników (Box, Rc, odniesienia) i gwarantując, że wartość leżąca u podstawy nigdy nie zostanie przeniesiona z jej lokalizacji w pamięci, chyba że typ implementuje Unpin, wskazując, że jest bezpieczne do przeniesienia. Tworzy to umowę, w której struktury samonawigujące mogą polegać na stabilnych adresach, umożliwiając maszynom stanowym async/await utrzymanie referencji przez punkty zawieszenia.
Musieliśmy zaimplementować parser protokołu sieciowego zero-copy w serwisie async Rust, który przetwarzał miliardy pakietów na sekundę. Struktura Parser przechowywała bufor Vec<u8> oraz sparsowany nagłówek Header zawierający fragmenty bajtowe odnoszące się do tego bufora. Kiedy funkcja async oddała kontrolę w punkcie await, executor mógł przenieść przyszłość pomiędzy wątkami roboczymi, co skutkowałoby unieważnieniem wskaźników do fragmentów i natychmiastowym wprowadzeniem undefined behavior podczas wznowienia.
Jednym z rozważanych podejść było użycie indeksów bajtowych zamiast fragmentów, przechowując przesunięcia usize w buforze zamiast odniesień &[u8]. To podejście oferowało pełne bezpieczeństwo bez złożoności Pin, ponieważ liczby całkowite są trywialnie kopiowalne i relokowalne. Jednak wiązało się to z istotnym narzutem czasowym z powodu stałych kontroli granic oraz arytmetyki wskaźników, które pogarszały wydajność naszej pętli parsującej o około piętnaście procent.
Inna alternatywa polegała na alokowaniu bufora na stercie osobno z użyciem Box::pin i przechowywaniu surowych wskaźników (*const u8) wewnątrz parsera. Choć zapobiegło to unieważnieniu wskaźników, wprowadziło bloki kodu unsafe do dereferencjonowania wskaźników. Wymagało to również ręcznego zarządzania pamięcią, co zwiększyło ryzyko wystąpienia błędów i uniemożliwiło kompilatorowi Rust weryfikację naszych gwarancji dotyczących czasu życia.
Wybraliśmy podejście Pin, przypinając całą przyszłość Parser z wykorzystaniem pin_project_lite, aby bezpiecznie projektować przypięcia do wewnętrznych pól. To rozwiązanie utrzymywało odniesienia do fragmentów o zerowym koszcie bez narzutu alokacji na stercie, zapewniając, że struktura pozostała niemobilna podczas wykonania async. Serwis teraz przetwarza pakiety z bezpośrednimi odwołaniami do pamięci przez granice await bez awarii lub mierzalnych opóźnień spowodowanych przez ściganie wskaźników.
Dlaczego typy implementujące Unpin mogą być przenoszone nawet gdy są opakowane w Pin?
Unpin jest automatycznym cechą w Rust, która działa jako negatywny znacznik dla semantyki przypinania. Kiedy typ implementuje Unpin, wyraźnie wskazuje, że nie polega na stabilnych adresach pamięci, co pozwala Pin zezwolić na bezpieczne wydobycie leżącej u podstawy wartości. Programiści często błędnie wierzą, że Pin zapewnia absolutne gwarancje niemobilności; jednak Pin<Ptr<T>> ogranicza ruch tylko wtedy, gdy T: !Unpin, ponieważ typy Unpin mogą być wydobywane za pomocą Pin::into_inner lub bezpiecznie przenoszone po odpinaniu. Ta różnica jest kluczowa przy pisaniu ogólnego kodu async, w którym należy ograniczyć typy z PhantomData lub explicite bound, aby upewnić się, że wymagania samonawigujące są rzeczywiście egzekwowane.
Jak trait Drop wchodzi w interakcję z przypiętymi zasobami, i jakie są wymagania dotyczące bezpieczeństwa?
Kiedy przypięta wartość jest niszczona, wywoływane jest Drop, gdy wartość pozostaje w swojej przypiętej lokalizacji w pamięci, co oznacza, że wskaźniki samonawigujące pozostają ważne podczas niszczenia. W stabilnym Rust napisanie niestandardowej implementacji Drop dla przypiętej struktury wymaga ostrożnej projekcji z użyciem crate'ów takich jak pin_utils lub pin-project, ponieważ self w Drop::drop(&mut self) otrzymuje nieprzypięte odniesienie, nawet jeśli wartość była przypięta. Stwarza to zagrożenie dla bezpieczeństwa, jeśli destruktor próbuje uzyskać dostęp do pól samonawigujących, które były utrzymywane w gwarancjach Pin, potencjalnie powodując use-after-free, jeśli destruktor nieumyślnie przenosi dane. Kandydaci muszą zrozumieć, że zrzucanie przypiętych wartości wymaga albo implementacji Unpin (zrzucając gwarancje pinning) albo użycia niebezpiecznej projekcji w celu uzyskania dostępu do przypiętych pól podczas zniszczenia.
Co odróżnia Pin<Box<T>> od przypinania wartości na stosie, i kiedy konieczne jest przypinanie na stercie?
Pin<Box<T>> alokuje wartość na stercie i przypina ją tam, zapewniając stabilny adres na całe życie programu obiektu. Jest to kluczowe dla struktury samonawigujących, które muszą przeżyć aktualną ramkę stosu. Przypinanie na stosie z użyciem pin_utils::pin_mut! lub crate'a pin-project tworzy tymczasowe Pin, które wygasa, gdy ramka stosu wraca, co jest odpowiednie dla bloków async, które pozostają w obrębie jednego zakresu funkcji. Kandydaci często mylą te podejścia, próbując zwrócić przypięte wartości stosu z funkcji lub zakładając, że Box jest wymagany dla wszystkich operacji Pin. Zrozumienie, że Pin jest umową dotyczącą zachowania wskaźnika, a nie czasu przechowywania, zapobiega błędom związanym z czasem życia w zakładaniu zadań async i kompozycjach Future.