Biblioteka async-trait wykorzystuje makro proceduralne do przekształcania metod async fn w metody synchronizowane, które zwracają Pin<Box<dyn Future<Output = T> + Send + 'static>>. To przekształcenie zrywa concrete future type generowany przez blok async, umożliwiając dynamiczne przekazywanie przez vtable i pozwalając na zachowanie bezpieczeństwa obiektów w trait. Konkretne koszty czasowe związane z uruchomieniem obejmują alokację na stercie dla Box przy każdym wywołaniu metody w celu przechowania przyszłości, oraz narzut związany z pośrednim wywołaniem funkcji w przypadku przekazania obiektu trait typu dyn. Dodatkowo, ograniczenie 'static zapobiega pożyczaniu danych nie-statycznych przez przyszłość, zmuszając wszystkie uchwycone referencje do bycia własnościowymi lub posiadającymi czas życia 'static.
Nasz zespół inżynieryjny budował serwer TCP o wysokiej wydajności wymagający architektury pluginów dla dynamicznego ładowania handlerów połączeń. Potrzebowaliśmy traitu ConnectionHandler z async fn handle(&mut self, stream: TcpStream) do przetwarzania operacji I/O, ale wersja Rust 1.70 nie wspierała natywnych async fn w traitach.
Zastosowanie ogólnych traitów z typami zwracającymi impl Future zamiast async fn oferowało abstrakcję o zerowym koszcie, bez alokacji na stercie i agresywnych optymalizacji kompilatora dzięki monomorfizacji. Jednakże podejście to zasadniczo uniemożliwiało dynamiczne przekazywanie, co sprawiało, że niemożliwe było przechowywanie heterogenicznych handlerów w Vec<Box<dyn ConnectionHandler>> lub ładowanie ich dynamicznie z bibliotek współdzielonych w czasie działania, co było kluczowe dla naszej architektury pluginów.
Zastosowanie biblioteki async-trait zapewniło czystą składnię identyczną z natywnymi async fn, wspierając dynamiczne przekazywanie przez Box<dyn ConnectionHandler>. Główną wadą była obowiązkowa alokacja na stercie dla każdego wywołania metody w celu opakowania przyszłości, a także wymaganie 'static dotyczące żywotności, które uniemożliwiało pożyczanie nie-statycznych danych w punktach await, co potencjalnie zmuszało do klonowania kolejnych danych.
Ręczna implementacja traitu przez zwracanie Pin<Box<dyn Future>> bez używania makra oferuje pełną kontrolę nad ograniczeniami Send i eliminuje narzut kompilacji makra proceduralnego. Niestety, wymagało to niezwykle rozbudowanego kodu szablonowego, ręcznych operacji unsafe przy użyciu Pin::new_unchecked, a także było wysoce podatne na błędy przy obsłudze skomplikowanych ograniczeń żywotności w punktach await, co znacznie spowolniło tempo rozwoju.
Ostatecznie wybraliśmy bibliotekę async-trait jako nasze rozwiązanie, ponieważ narzut alokacji na stercie przy każdym wywołaniu metody uznano za akceptowalny, biorąc pod uwagę, że serwer był w przeważającej części ograniczony przez I/O, a nie przez CPU, a korzyści ergonomiczne znacznie przyspieszyły tempo rozwoju. System pluginów działał płynnie z Box<dyn ConnectionHandler>, umożliwiając gorącą wymianę modułów bez konieczności ponownej kompilacji, co spełniało nasze wymagania architektoniczne.
Po migracji kodu do Rust 1.75 systematycznie zastępowaliśmy async-trait natywnym async fn w traitach, gdzie dynamiczne przekazywanie nie było wymagane, eliminując alokacje na stercie przy każdym wywołaniu, zachowując jednocześnie tę samą czystą powierzchnię API. Profilowanie wydajności potwierdziło, że chociaż narzut związany z opakowaniem istniał w wersji legacy, był nieznaczny w porównaniu do latencji sieciowej, co potwierdziło naszą początkową decyzję techniczną.
Dlaczego biblioteka async-trait wymaga, aby przyszłości były 'static, i w jaki sposób to ograniczenie wpływa na pożyczanie danych w punktach await?
Ograniczenie 'static wynika z tego, że async-trait oburza przyszłość do Box<dyn Future + Send + 'static>, a obiekty trait w Rust muszą mieć zdefiniowany czas życia, który obejmuje wszystkie możliwe konteksty wykonania. Ponieważ executor może trzymać przyszłość bezterminowo między wątkami lub przechowywać ją w wewnętrznych kolejkach, kompilator wymaga, aby przyszłość posiadała wszystkie uchwycone dane lub przechowywała tylko odniesienia 'static. To zapobiega pożyczaniu zmiennych lokalnych stosu w punktach await, ponieważ takie referencje miałyby nie-'static czasy życia związane z ramką stosu. Kandydaci często nie dostrzegają, że jest to fundamentalne ograniczenie wynikające z usunięcia typu dla obiektów trait, a nie jedynie arbitralne ograniczenie narzucone przez twórców biblioteki.
Jak typ zwracany Pin<Box<dyn Future>> współdziała z wymaganiem Send w wielowątkowych executorach, a jaki błąd kompilacji występuje, jeśli podłożona przyszłość nie jest Send?
async-trait automatycznie dodaje ograniczenia Send do opakowanej przyszłości (Pin<Box<dyn Future + Send + 'static>>), aby zapewnić zgodność z executorami typu kradnącego pracę, takimi jak Tokio, które mogą przenosić zadania między wątkami podczas wykonania. Aby przyszłość była Send, wszystkie dane uchwycone przez blok async muszą implementować Send. Jeśli przyszłość uchwyci typy nie-Send, takie jak Rc lub wskaźniki surowe, kompilator generuje błąd mówiący, że przyszłość nie może być bezpiecznie wysyłana między wątkami, ponieważ implementuje !Send. Kandydaci często umykają temu, że ograniczenie Send jest niezbędne dla bezpieczeństwa wątkowego w kontekstach wielowątkowych, a async-trait nakłada to ograniczenie domyślnie, aby zapobiec wyścigom danych w czasie wykonywania, nawet gdy executor teoretycznie może być jedno-wątkowy.
Jaka jest fundamentalna różnica architektoniczna między natywnym async fn w traitach (ustabilizowanym w Rust 1.75) a emulacją async-trait pod względem bezpieczeństwa obiektów i dynamicznego przekazywania?
Natywne async fn w traitach wykorzystuje Return Position Impl Trait In Traits (RPITIT), które zwraca nieprzezroczysty typ impl Future specyficzny dla każdej implementacji. To podejście jest zerokosztowe i statycznie przekazywane przez monomorfizację, ale sprawia, że trait nie jest bezpieczny pod względem obiektów, ponieważ impl Trait ukrywa konkretny typ wymagany do wpisu w vtable. W konsekwencji nie można tworzyć Box<dyn Trait> z natywnym async fn, chyba że ręcznie opakujesz zwroty w Box<dyn Future>>. Z drugiej strony, async-trait osiąga bezpieczeństwo obiektów poprzez natychmiastowe opakowanie przyszłości w Pin<Box<dyn Future>>, który ma znany rozmiar i może być przechowywany w vtable, co umożliwia dynamiczne przekazywanie kosztem alokacji na stercie. Kandydaci często mylą te dwa podejścia, zakładając, że natywne async fn automatycznie wspiera Box<dyn Trait> lub że async-trait jest jedynie cukrem składniowym bez architektonicznych różnic dotyczących bezpieczeństwa obiektów i strategii alokacji.