Przed stabilizacją Nielekcyjnych Czasów Życia (NLL) w Rust 2018, kompilator egzekwował surowe zakresy leksykalne dla pożyczek, co sprawiało, że wyrażenia takie jak vec.push(vec.len()) były nielegalne, ponieważ mutowalna pożyczka wymagana przez push wydawała się kolidować z niemutowalną pożyczką wymaganą przez len. Społeczność uznała to ograniczenie za zbyt konserwatywne, ponieważ mutowalny dostęp nie jest faktycznie wykorzystywany, aż do wykonania ciała metody, co tworzy teoretyczne okno, w którym niemutowalna inspekcja pozostaje bezpieczna. Doprowadziło to do wprowadzenia pożyczek dwufazowych, udoskonalenia kontrolera pożyczek, które rozróżnia rezerwację mutowalnej pożyczki od jej faktycznej aktywacji.
Podstawowym wyzwaniem jest pogodzenie Rust’s gwarancji dot. aliasingu XOR mutacji z ergonomicznym projektowaniem API, szczególnie gdy wywołanie metody wymaga &mut self, a jej argumenty potrzebują &self na tym samym obiekcie. Bez specjalnego traktowania, kontroler pożyczek oznaczy to jako naruszenie drugiej zasady mutowalnych pożyczek, zmuszając deweloperów do ręcznego sekwencjonowania operacji za pomocą zmiennych tymczasowych. Problem wymaga mechanizmu, który opóźnia egzekwowanie mutacyjnej ekskluzywności do momentu rzeczywistej mutacji, zapewniając jednocześnie, że pośrednie niemutowalne dostępności nie mogą przeżyć przejścia ani stworzyć wiszących odniesień.
Pożyczki dwufazowe działają poprzez traktowanie mutowalnej pożyczki w wywołaniu metody jako "rezerwacji" podczas oceny argumentów, a "aktywują" się jako pełna mutowalna pożyczka dopiero po zakończeniu oceny i wejściu do ciała metody. W fazie rezerwacji kompilator zezwala na ograniczone niemutowalne pożyczki (konkretnie te pochodzące z autoref na odbiorcy), śledząc, że mutacyjna aktywacja jest oczekiwana. Jest to wdrażane w ramach kontrolowania pożyczek MIR (Średni Poziom Reprezentacji Pośredniej), gdzie kompilator weryfikuje, że nie istnieją konflikujące użycia pomiędzy punktem rezerwacji a punktem aktywacji, zapewniając bezpieczeństwo poprzez analizę statyczną zamiast instrumentacji czasowej.
Rozważ menedżera bufora sieciowego, odpowiedzialnego za agregację pakietów przed transmisją. System musi dodać nagłówek, którego rozmiar zależy od aktualnej długości bufora: buffer.append_header(buffer.current_len()). Tutaj append_header wymaga dostępu mutowalnego, aby rozszerzyć bufor, podczas gdy current_len potrzebuje jedynie niemutowalnej inspekcji.
Deweloper mógłby wyodrębnić długość do osobnego wiązania przed mutacją: let len = buffer.current_len(); buffer.append_header(len);. To podejście działa w wszystkich edycjach Rust i całkowicie unika złożonych zasad kontrolera pożyczek. Jednak wprowadza to zawirowania i tworzy okno, w którym długość może teoretycznie stać się przestarzała, jeśli kod zostanie przebudowany na obsługę współbieżności, chociaż w kontekście jednowątkowym jest to czysto kwestia stylu. Główną wadą jest zmniejszona ergonomika i potencjalne, że zmienna tymczasowa przeżyje swoją konieczność, zaśmiecając zakres.
Owinięcie bufora w RefCell pozwoliłoby na jednoczesne pożyczki niemutowalne i mutowalne w czasie wykonania za pomocą metod borrow() i borrow_mut(). Eliminowałoby to konflikty w czasie kompilacji, przesuwając sprawdzanie do czasu wykonania, potencjalnie wywołując panikę w przypadku naruszeń. Chociaż elastyczne, wprowadza to narzut związany z liczeniem referencji i weryfikacją w czasie wykonania, naruszając zasadę zerowego kosztu abstrakcji, krytyczną dla kodu sieciowego o wysokiej przepustowości. Dodatkowo, przenosi błędy z gwarancji kompilacji na potencjalne błędy w czasie wykonania, redukując niezawodność.
Zespół wykorzystał pożyczki dwufazowe, strukturalizując append_header jako metodę przyjmującą &mut self, ufając, że kontroler pożyczek NLL automatycznie obsłuży rezerwację. To pozwoliło na naturalne wyrażenie logiki bez zmiennych tymczasowych czy narzutu czasowego. Kompilator zweryfikował, że current_len kończy się przed aktywacją mutowalnej pożyczki, zapewniając bezpieczeństwo. To rozwiązanie zostało wybrane, ponieważ utrzymywało zerowe koszty abstrakcji, zapewniając jednocześnie czystą i łatwą do utrzymania składnię, która dokładnie odzwierciedlała zamierzony przepływ danych.
Implementacja skompilowała się bez błędów na Rust 1.63+, osiągając optymalny poziom wydajności identyczny jak w przypadku ręcznie sekwencjonowanego kodu. Menedżer bufora skutecznie przetwarzał 10Gbps ruchu bez nadmiaru alokacji, pokazując, że pożyczki dwufazowe rozwiązują problem ergonomiki bez kompromisów w kwestii gwarancji bezpieczeństwa Rust. Kod pozostał wolny od złożoności wewnętrznej mutowalności, upraszczając przyszłe audyty w zakresie bezpieczeństwa pamięci.
Jak pożyczki dwufazowe współdziałają z wyraźnymi operacjami dereferencji i przeciążeniem operatorów?
Wiele osób zakłada, że pożyczki dwufazowe stosują się uniwersalnie do wszystkich mutowalnych referencji, ale są one szczególnie ograniczone do sytuacji autoref w odbiorcach wywołań metod. Gdy wyraźnie dereferencjonuje się za pomocą *vec lub używa cech operatorów, takich jak IndexMut, kontroler pożyczek nie stosuje logiki dwufazowej, natychmiast aktywując mutowalną pożyczkę. To ograniczenie istnieje, ponieważ autoref metody zapewnia wyraźny punkt rezerwacji (miejsce wywołania metody), w którym kompilator może śledzić przejścia stanu, podczas gdy arbitralne operacje dereferencji nie mają tej semantycznej granicy. Zrozumienie tego rozróżnienia zapobiega zamieszaniu, gdy podobnie wyglądający kod nie kompiluje się.
Dlaczego kompilator zabrania pożyczek dwufazowych, gdy odbiorca implementuje Drop?
Kandydaci często pomijają to, że typy implementujące Drop mają semantykę destruktora, która komplikuje fazę rezerwacji. Jeśli podczas działania destruktora (na przykład przez paniki lub złożony przepływ sterowania) istnieje mutowalna rezerwacja, częściowo zainicjowany stan może naruszać oczekiwania Drop w zakresie poprawnego działania. Dlatego kompilator ogranicza pożyczki dwufazowe do typów z niestandardowymi destruktorami, chyba że są one Copy, zapewniając, że aktywacja mutowalnej pożyczki nie może zakłócać wykonania kodu do usuwania. To zapobiega subtelnym błędom, w których faza rezerwacji może zaobserwować częściowo przeniesiony lub unieważniony stan w trakcie wycofywania stosu.
Co różni fazę "rezerwacji" od fazy "aktywacji" pod względem dozwolonych operacji?
W fazie rezerwacji kompilator zezwala jedynie na niemutowalne użycia odbiorcy, które pochodzą z wywołania metody z autoref, umożliwiając w szczególności ocenę argumentów. Jednak kandydaci często przeoczają, że tworzenie dodatkowych nazwanych referencji do odbiorcy lub przekazywanie go do innych funkcji podczas oceny argumentów jest zabronione. Faza aktywacji zaczyna się dokładnie w momencie, kiedy kontrola wchodzi do ciała metody, w którym wszystkie niemutowalne pożyczki z oceny argumentów muszą już się zakończyć. Tworzy to surową liniową oś czasu: rezerwacja → ocena niemutowalnych argumentów → aktywacja → wykonanie metody. Naruszenie tej sekwencji, takie jak przechowywanie odniesienia w zmiennej, która przeżyje punkt aktywacji, skutkuje błędem kompilacji w celu utrzymania gwarancji ekskluzywności.