Historia pytania
Podczas powstawania Rust projektanci stanęli w obliczu krytycznego impasu: niezbędne struktury danych, takie jak grafy cykliczne i kontenery z kontrolą pożyczania w czasie wykonywania, wymagały mutacji przez referencje współdzielone, co bezpośrednio naruszało podstawową aksjomat języka o wyłącznym dostępie mutacyjnym. Aby rozwiązać ten problem bez kompromisów w zasadzie zerowego kosztu abstrahowania, wprowadzono UnsafeCell jako jedyny prymityw, który rezygnuje z gwarancji niemutowalności właściwej dla referencji współdzielonych &T, stanowiąc fundament wszystkich bezpiecznych abstrakcji mutowalności wewnętrznej.
Problem
Kompilator Rust wykorzystuje niemutowalność &T do przeprowadzania agresywnych optymalizacji, takich jak pamięć podręczna wartości i reorganizacja instrukcji, zakładając, że pamięć pod referencją nie może się zmieniać przez cały czas jej życia. UnsafeCell sygnalizuje kompilatorowi, że jego zawartość może się mutować nawet przy dostępie przez referencję współdzieloną, effectively disabling these optimizations for the enclosed data. Jednak ten wybór nie obejmuje referencji pochodzących z surowego wskaźnika uzyskanego za pomocą UnsafeCell::get(); w momencie, gdy wskaźnik ten jest konwertowany na &mut T, standardowe zasady aliasowania ponownie wchodzą w życie z absolutną sztywnością.
Rozwiązanie
Rozwiązanie wymaga od programisty przestrzegania inwariantu, że każda mutowalna referencja &mut T wygenerowana z surowego wskaźnika UnsafeCell musi być jedyną aktywną ścieżką dostępu do tej pamięci przez cały czas jej życia. Ta ekskluzywność zabrania jednoczesnych odczytów lub zapisów poprzez jakikolwiek inny wskaźnik, referencję lub kolejne wywołania do get() podczas istnienia referencji mutowalnej. UnsafeCell nie dezaktywuje kontrolera pożyczania; jedynie przenosi odpowiedzialność za zapewnienie czasowej ekskluzywności i zapobieganie wyścigom danych z kompilatora na programistę.
Opis problemu
Projektowaliśmy agregator metryk o wysokiej przepustowości dla systemu handlowego o niskiej latencji, w którym wiele wątków aktualizowało liczniki związane z konkretnymi instrumentami finansowymi. Wspólna mapa była niemutowalna po inicjalizacji, ale wartości metryk wymagały częstych inkwentacji. Zastosowanie Mutex<u64> wprowadziło nieakceptowalną kontestację, podczas gdy AtomicU64 okazało się niewystarczające dla złożonych typów metryk kompozytowych. Potrzebowaliśmy aktualizacji bez blokad, bez alokacji do struktur za wskaźnikami Arc bez kontroli pożyczania w czasie wykonywania.
Różne rozważane rozwiązania
Rozwiązanie 1: Rozdzielone Mutexy
Rozważaliśmy owinięcie każdej metryki w Mutex i rozdzielenie ich na 256 części, aby zredukować kontestację. To podejście oferowało prostą bezpieczeństwo i prosty, łatwy do utrzymania kod. Jednak profilowanie ujawniło, że nawet niekontestowane operacje Mutex zajmowały setki nanosekund z powodu wywołań systemowych futex i protokołów spójności pamięci podręcznej, co naruszało nasz surowy budżet latencji poniżej mikrosekundy.
Rozwiązanie 2: AtomicPtr z wartościami pudełkowymi
Inne podejście polegało na przechowywaniu wartości jako AtomicPtr<Metric> i wykorzystaniu pętli porównuj i zamień do aktualizacji. To wyeliminowało blokowanie, ale wymagało alokowania nowych instancji Box dla każdej inkrementacji, co prowadziło do poważnego nacisku pamięci i kontestacji alokatora. Ponadto skomplikowało to wykupywanie pamięci, wymagając wskaźników niebezpiecznych lub zbierania śmieci opartego na epoce, co znacznie zwiększało złożoność kodu i powierzchnię audytową.
Rozwiązanie 3: UnsafeCell z wyrównaniem do linii pamięci podręcznej
Zdecydowaliśmy się na przechowywanie metryk w UnsafeCell<Metric> w strukturach wyrównanych do linii pamięci podręcznej, zapewniając, że wątki piszące do różnych części nigdy nie dzielą linii pamięci podręcznej. Każdy wątek uzyskał surowy wskaźnik za pomocą UnsafeCell::get(), rzutował na &mut Metric podczas aktualizacji — co zostało zagwarantowane jako bezpieczne przez naszą logikę shardowania zapewniającą, że żaden inny wątek nie mógł uzyskać dostępu do tego konkretnego slotu — i wykonał mutację. Wymagało to bloków unsafe i formalnego dowodu, że nasze spójne haszowanie zapewniało brak kolizji podczas dostępu równoległego.
Które rozwiązanie zostało wybrane i dlaczego
Wybraliśmy rozwiązanie 3, ponieważ zapewniało zerowy koszt abstrahowania nad surową pamięcią, spełniając agresywne wymagania latencji. Gwarancja shardowania działała jako ręczny dowód ekskluzywnego dostępu, pozwalając na wykorzystanie UnsafeCell bez narzutu synchronizacji w czasie wykonywania. Zwalidowaliśmy bezpieczeństwo, używając MIRI i narzędzia sprawdzającego model współbieżności loom, aby szczegółowo zweryfikować, że nie wystąpiły naruszenia aliasowania w żadnym możliwym wpleczeniu wątków.
Wynik
Implementacja osiągnęła latencje aktualizacji poniżej 100 nanosekund z zerowymi alokacjami na gorącej ścieżce. Jednak podczas kolejnej refaktoryzacji pojawił się subtelny regres, gdy zadanie konserwacyjne przypadkowo iterowało po wszystkich shardach bez uzyskania implicit shard-lock, co stworzyło dwie mutowalne referencje do tej samej metryki. MIRI natychmiast zgłosiło to jako nieokreślone zachowanie podczas CI, podkreślając, że UnsafeCell wymaga rygorystycznej dyscypliny, nawet gdy projekt architektoniczny teoretycznie gwarantuje bezpieczeństwo.
Dlaczego nieokreślone jest trzymanie dwóch mutowalnych referencji pochodzących z UnsafeCell jednocześnie, mimo że UnsafeCell wyraźnie rezygnuje z standardowych reguł pożyczania?
UnsafeCell rezygnuje z gwarancji niemutowalności dla referencji współdzielonych na poziomie typu, ale nie łagodzi fundamentalnego inwariantu typu &mut T. Kiedy wywołujesz get(), otrzymujesz surowy wskaźnik *mut T, który nie niesie żadnych ograniczeń dotyczących cyklu życia ani aliasowania. Jednak w momencie, gdy dereferencjujesz ten wskaźnik na &mut T, zapewniasz kompilatorowi, że ta referencja jest ekskluzywna. Tworzenie dwóch takich referencji do pokrywającej się pamięci, nawet z tego samego UnsafeCell, narusza zasadę aliasing XOR mutation, która stanowi fundament modelu pamięci Rust, prowadząc do natychmiastowego nieokreślonego zachowania bez względu na to, jak te referencje zostały skonstruowane.
Jak MIRI wykrywa naruszenia inwariantów UnsafeCell i dlaczego kod może przechodzić testy produkcyjne, ale nie przechodzi pod MIRI?
MIRI implementuje model aliasowania Stacked Borrows (lub opcjonalnie Tree Borrows), który śledzi uprawnienia dostępu do pamięci przez abstrakcyjne "etykiety". Kiedy tworzysz referencję z UnsafeCell, MIRI przypisuje unikalną etykietę. Jakiekolwiek próby użycia innej etykiety do uzyskania dostępu do tej samej pamięci, gdy pierwsza referencja jest aktywna, stanowią naruszenie. Kod często przechodzi standardowe testy, ponieważ modele pamięci sprzętowej są wyrozumiałe, a łagodne wyścigi danych mogą nie manifestować się jako awarie w praktyce. MIRI, jednak, rygorystycznie egzekwuje teoretyczny model, wychwytując naruszenia takie jak unieważnienie mutowalnej referencji przez stworzenie referencji współdzielonej z tego samego UnsafeCell bez odpowiedniej synchronizacji, nawet jeśli asembler działa na danej architekturze CPU.
Wyjaśnij, dlaczego Cell<T> nie wymaga bloków unsafe do mutacji, podczas gdy UnsafeCell<T> tego wymaga, oraz wskaźnij konkretną gwarancję bezpieczeństwa, która umożliwia to rozróżnienie.
Cell<T> osiąga mutowalność wewnętrzną bez unsafe poprzez nigdy nie eksponowanie referencji do swoich danych wewnętrznych; pozwala jedynie na kopiowanie wartości (set) lub wydobywanie ich (get) dla typów implementujących Copy, lub ich przenoszenie (replace) dla typów nie-Copy. Ponieważ Cell nigdy nie wydaje &T lub &mut T do zawartej wartości, niemożliwe jest naruszenie reguł aliasowania — nie ma referencji do aliasowania. UnsafeCell natomiast dostarcza get(), które zwraca surowy wskaźnik *mut T, umożliwiając tworzenie referencji. Ta elastyczność jest niezbędna do złożonych mutacji in-place, ale przenosi ciężar zapewnienia ekskluzywności i zapobiegania wyścigom danych całkowicie na programistę, co wymaga bloków unsafe.