RustprogramowanieProgramista systemów Rust

Szczegółowo opisz wymagania dotyczące bezpieczeństwa przy konstruowaniu **RawWaker** z wskaźnika surowego i tabeli funkcji wirtualnych oraz zidentyfikuj konkretne niezdefiniowane zachowanie występujące, gdy wskaźniki funkcji **wake** lub **clone** naruszają oczekiwany kontrakt ABI.

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie

Historia tego pytania sięga stabilizacji std::task::Waker w Rust 1.36, która wprowadziła znormalizowany mechanizm dla executorów do powiadamiania przyszłości o gotowości. Przed tym, frameworki asynchroniczne polegały na zamknięciach w pudełku lub niestandardowych interfejsach powiadomień, które nakładały narzut alokacji i uniemożliwiały płynne połączenie z bibliotekami C. API RawWaker zostało zaprojektowane w celu wsparcia zerowych kosztów abstrahowania przez umożliwienie programistom konstruowania instancji Waker z wskaźników surowych i tabel funkcji wskaźników (RawWakerVTable), odzwierciedlając tabele wirtualne C++, ale z wymaganiami bezpieczeństwa Rust.

Problem pojawia się, ponieważ konstrukcja RawWaker całkowicie omija system własności i pożyczania Rust. Programista musi ręcznie zapewnić cztery krytyczne inwarianty: wskaźnik danych musi pozostać ważny przez całe życie wszystkich klonów Waker (nie tylko oryginalnego), cztery funkcje vtable (clone, wake, wake_by_ref, drop) muszą być bezpieczne dla wątków (Send i Sync), nawet jeśli executor jest jedno-wątkowy, a funkcja clone musi zwracać nowy RawWaker odwołujący się do tego samego stanu zadania. Dodatkowo, vtable musi używać ABI extern "C" aby zapewnić zgodność FFI i stabilne konwencje wywołań w różnych wersjach Rust.

Rozwiązanie wymaga ścisłego przestrzegania inwariantów unsafe. Wskaźnik danych powinien zazwyczaj odnosić się do danych 'static lub być opakowany w Arc, aby zarządzać współdzieloną własnością przez klony. Funkcje w vtable muszą poprawnie implementować semantykę liczenia odniesień: clone powinno zwiększać liczbę, drop powinno ją zmniejszać, a wake powinno zmniejszać po powiadomieniu (konsumując Waker). Naruszenie kontraktu ABI — takie jak użycie konwencji wywołań Rust zamiast extern "C" — skutkuje niezdefiniowanym zachowaniem, gdy executor wywołuje te wskaźniki, w tym uszkodzeniem stosu, niewłaściwym wyrównaniem argumentów lub skakaniem do nieprawidłowych adresów pamięci.

use std::sync::Arc; use std::task::{RawWaker, RawWakerVTable, Waker}; struct TaskState { id: u64, } unsafe fn clone_waker(data: *const ()) -> RawWaker { let arc = Arc::from_raw(data as *const TaskState); let _ = Arc::clone(&arc); let _ = Arc::into_raw(arc); // Wycieknij z powrotem, aby uniknąć drop RawWaker::new(data, &VTABLE) } unsafe fn wake_waker(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); drop(arc); // Zrzuć Arc, zwalniając odniesienie } unsafe fn wake_by_ref(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); // Logika budzenia tutaj, potem wyciek z powrotem let _ = Arc::into_raw(arc); } unsafe fn drop_waker(data: *const ()) { let _ = Arc::from_raw(data as *const TaskState); // Implicytne zwolnienie zwalnia pamięć } static VTABLE: RawWakerVTable = RawWakerVTable::new( clone_waker, wake_waker, wake_by_ref, drop_waker, ); fn create_waker(state: Arc<TaskState>) -> Waker { let ptr = Arc::into_raw(state) as *const (); unsafe { Waker::from_raw(RawWaker::new(ptr, &VTABLE)) } }

Sytuacja z życia

Rozważ rozwijanie systemu handlu wysokiej częstotliwości, w którym asynchroniczny runtime Rust musi integrować się z istniejącą biblioteką C++ do danych rynkowych. Biblioteka C++ oferuje funkcję rejestracji, która akceptuje kontekst void* i wskaźnik funkcji, wywołując funkcję zwrotną, gdy przychodzą aktualizacje cen. Wyzwaniem inżynieryjnym jest stworzenie Waker, który łączy przyszłości Rust z tym mechanizmem wywołania zwrotnego C++ bez wprowadzania narzutu alokacji na wiadomość, ponieważ wymagania dotyczące opóźnienia wymagają sub-mikrosekundowych czasów budzenia.

Jednym z rozwiązań było przechowywanie zamknięcia Box<dyn Fn() + Send> jako wskaźnika danych Waker. To podejście oferowało bezpieczeństwo pamięci dzięki systemowi własności Rust i prostą integrację. Jednak wprowadziło niedopuszczalne opóźnienie alokacji pamięci dla każdej subskrypcji danych rynkowych i narzut związany z wirtualnym wywołaniem, który naruszał architekturę bezkopiową systemu. Ponadto, zarządzanie czasem życia zamknięcia w pudełku na granicy FFI okazało się niebezpieczne, ponieważ asynchroniczne sprzątanie biblioteki C++ mogło pozostawić wiszące wskaźniki, jeśli strona Rust usunęła Waker przed zatrzymaniem wywoływania funkcji zwrotnych przez bibliotekę C++.

Alternatywne podejście wykorzystało globalną statyczną mapę hashującą mapującą identyfikatory całkowite na uchwyty zadań, przekazując identyfikator jako kontekst void*. To wyeliminowało alokacje i zapewniło O(1) wyszukiwanie podczas operacji budzenia. Jednak stwarzało to zagrożenie wycieku pamięci, jeśli zadania kończyły się bez wyrejestrowania z feedu, a statyczna mapa wymagała synchronizacji Mutex, co stało się wąskim gardłem pod dużym przepływem danych rynkowych, efektywnie serializując powiadomienia budzenia we wszystkich rdzeniach CPU.

Wybrane rozwiązanie zaimplementowało niestandardowy RawWaker, w którym wskaźnik danych przechowywał Arc<TaskState> zawierający kontekst wywołania zwrotnego C++ oraz flagę ukończenia. Funkcje RawWakerVTable były implementowane jako bezpieczne thunk extern "C", które bezpiecznie transmutowały void* z powrotem do wskaźników Arc, zapewniając poprawne liczenie odniesień na granicy FFI. Ten projekt wyeliminował alokacje na wiadomość, wykorzystując ponownie strukturę Arc, utrzymywał bezpieczeństwo wątków poprzez atomowe operacje Arc i zapewnił bezpieczeństwo pamięci, zmniejszając liczbę odniesień tylko wtedy, gdy ostatni klon Waker został usunięty. Rezultat osiągnął sub-mikrosekundowe opóźnienia budzenia, zachowując jednocześnie gwarancje bezpieczeństwa pamięci na granicy Rust/C++, przechodząc pomyślnie wykrywanie niezdefiniowanego zachowania Miri i testy obciążenia obejmujące miliony równoczesnych aktualizacji cen.

Co często pomijają kandydaci

Dlaczego funkcje RawWakerVTable muszą być bezpieczne dla wątków (Send + Sync) nawet jeśli executor jest jedno-wątkowy?

Typ Waker implementuje Clone, Send i Sync, co pozwala mu migrować przez granice wątków niezależnie od modelu wątkowego executor. Gdy przyszłość posiada Waker i przekazuje go do zadania spawn_blocking lub kanału std::sync::mpsc, Waker może być wywołany z wątku różnego niż ten, który go utworzył. Jeśli funkcje vtable zakładają dostęp w jedno-wątku — na przykład, używając Rc lub niesynchronizowanych zmiennych statycznych — tworzą wyścigi danych, gdy wake() jest wywoływane równocześnie. Ponadto, asynchroniczne runtimy jak Tokio czy async-std mogą migrować zadania pomiędzy wątkami roboczymi w celu zrównoważenia obciążenia, co oznacza, że Waker może być klonowany i usuwany w wątkach różniących się od miejsca jego stworzenia. Wymóg bezpieczeństwa wątków zapewnia, że mechanizm powiadamiania pozostaje ważny niezależnie od tego, jak Waker jest udostępniany w programie.

Jakie katastrofalne uszkodzenie wystąpi, jeśli funkcja clone zwróci RawWaker z innym vtable niż oryginalny?

Kontrakt Waker wymaga, aby wszystkie klony Waker reprezentowały to samo podstawowe zadanie i zachowywały się identycznie po wywołaniu. Jeśli clone zwraca RawWaker wskazujący na inną vtable — być może jedną związaną z innym zadaniem lub zawierającą wskaźniki funkcji null — executor może wywołać niewłaściwą logikę budzenia podczas powiadamiania zadania. Prowadzi to do obudzenia niezwiązanego zadania (logiczna korupcja) lub skoku do nieprawidłowej pamięci (błąd segmentacji). Konkretnie, executor zazwyczaj przechowuje klony Waker w wewnętrznych kolejkach; gdy występuje zdarzenie, wywołuje wake() na tych przechowywanych uchwytach. Niedopasowane vtable oznacza, że wskaźnik danych (kontekst zadania) jest interpretowany przez niewłaściwe sygnatury funkcji, co prowadzi do natychmiastowego niezdefiniowanego zachowania, gdy funkcje vtable rzutują wskaźnik na niewłaściwy typ lub uzyskują dostęp do pól w niewłaściwych przesunięciach.

Dlaczego ABI extern "C" jest obowiązkowe dla funkcji vtable zamiast domyślnego ABI Rust?

RawWakerVTable specyfikuje wskaźniki funkcji extern "C" w celu zapewnienia zgodności FFI i stabilności ABI. ABI Rust nie jest stabilne w różnych wersjach kompilatora lub poziomach optymalizacji; sygnatury funkcji mogą się zmieniać w zależności od wewnętrznych mechanizmów kompilatora, decyzji o inlining czy architekturze docelowej. Użycie extern "C" zapewnia, że konwencja wywołania podąża za standardem C danego systemu, co czyni vtable zgodnym z kodem C i zapobiega niezdefiniowanemu zachowaniu, gdy kompilator generuje kod dla wskaźników funkcyjnych. Dodatkowo, ABI extern "C" narzuca specyficzne zasady użycia rejestrów i sprzątania stosu, które pozwalają bezpiecznie przekazywać Waker między granicami języków. Bez tego ograniczenia, linkowanie z bibliotekami dynamicznymi lub aktualizacja kompilatora Rust mogą zmienić konwencję wywołania funkcji, co powoduje uszkodzenie stosu lub niewłaściwe wyrównanie argumentów, gdy executor wywołuje wake() lub clone().