Geschichte der Frage: Der Drop Check (dropck) Algorithmus wurde eingeführt, um ein Sicherheitsproblem in frühen Rust-Versionen zu schließen, bei dem generische Zerstörer auf Daten zugreifen konnten, die bereits deallokiert waren. Vor dropck konnte man eine Struktur konstruieren, die eine Referenz auf stapelspeicherte Daten hielt, Drop implementieren, um sie dereferenzieren, und die referenzierten Daten vor dem Container abzuwerfen, was zu einem Use-after-free führte. Dieses Problem wurde kritisch bei generischen Sammlungen, die geliehene Daten enthalten könnten, was eine konservative Analyse erforderte, um die Sicherheit des Zerstörers zu gewährleisten.
Das Problem:
Wenn ein generischer Typ Container<T> Drop implementiert, muss der Compiler sicherstellen, dass T das Container-Lebenszeitbedingt überdauert, um zu verhindern, dass der Zerstörer auf ungültigen Speicher zugreift. Bei Typen, die Rohzeiger verwenden (z. B. *const T), fehlen dem Compiler Lebenszeitinformationen, da Rohzeiger vom Borrow Checker nicht verfolgt werden. Ohne explizite Lebenszeitmarker kann der Compiler nicht überprüfen, ob der Zerstörer einen Zeiger auf Daten dereferenzieren könnte, die sich im aktuellen Gültigkeitsbereich befinden und zuerst freigegeben werden könnten.
Die Lösung:
PhantomData fungiert als ein nullgroßer Marker, der den Besitz oder das Ausleihen eines Typs T oder einer Lebenszeit 'a simuliert. Durch das Einfügen von PhantomData<&'a T> in eine Struktur, die einen Rohzeiger enthält, informierst du den Compiler, dass die Struktur logisch eine Referenz hat, die an die Lebenszeit 'a gebunden ist. Der Drop Check Algorithmus nutzt dies, um durchzusetzen, dass die Struktur nicht länger leben kann als 'a. Wenn die Struktur Drop implementiert und möglicherweise ihre Referenz überdauern könnte, schlägt die Kompilierung fehl, was undefiniertes Verhalten verhindert.
Du baust einen Zero-Copy-Netzwerkprotokoll-Parser, der einen Byte-Puffer umschließt. Du definierst Packet<'a>, das einen Rohzeiger *const u8 in ein temporäres Vec<u8> enthält, das von dem Netzwerk-Stack empfangen wurde. Du versuchst, Drop für Packet zu implementieren, um die Parsing-Statistiken durch das Lesen über den Rohzeiger zu aktualisieren. Die Gefahr besteht darin, dass das Vec<u8> gelöscht wird, wenn die Empfangsfunktion endet, aber Packet könnte in einer Warteschlange für eine spätere Verarbeitung gespeichert werden, was zu einem Use-after-free führt, wenn Drop ausgeführt wird.
Zuerst überlegst du, anstelle eines Rohzeigers &'a [u8] zu verwenden. Dies nutzt den Borrow Checker, um sicherzustellen, dass der Puffer lange genug lebt. Dies schränkt jedoch die API erheblich ein, da du das Paket nicht frei bewegen oder in Sammlungen speichern kannst, die 'static Grenzen erfordern, und es verhindert selbstverweisende Muster, die in Parsern üblich sind.
Zweitens überlegst du, Rc<Vec<u8>> zu verwenden, um das Eigentum am Puffer zu teilen. Dies stellt sicher, dass die Daten gültig bleiben, solange ein Paket existiert. Der Nachteil sind die Leistungsnachteile von Referenzzählung und Heap-Zuweisung, die die Zero-Copy und Null-Overhead-Anforderungen der Hochdurchsatz-Netzwerkverarbeitung verletzen.
Drittens überlegst du, PhantomData<&'a ()> hinzuzufügen, um die Lebenszeitanforderung zu kennzeichnen, während du den Rohzeiger für die Leistung beibehältst. Dies zeigt jedoch, dass die Implementierung von Drop hier grundsätzlich unsicher ist, da der Compiler nicht garantieren kann, dass der Puffer das Paket überdauert. Du entscheidest dich, die Drop-Implementierung zu entfernen und stattdessen eine manuelle Bereinigungsmethode einzuführen, die vor der Freigabe des Puffers aufgerufen wird, oder wechselst zu Cow<'a, [u8]>, um sowohl geliehene als auch eigene Daten zu unterstützen.
Du wählst den Ansatz Cow<'a, [u8]>, der Rohzeiger und die Notwendigkeit für unsichere Drop-Logik eliminiert. Das Ergebnis ist ein Parser, der erfolgreich mit strengen Lebenszeitsicherheiten kompiliert, und sicherstellt, dass kein Paket länger leben kann als der zugrunde liegende Puffer und gleichzeitig die Leistung für den geliehenen Fall aufrechterhält.
Warum erlaubt der Compiler die Implementierung von Drop für eine Struktur, die PhantomData<&'static T> enthält, lehnt sie jedoch für PhantomData<&'a T> ab, wenn 'a nicht statisch ist?
Wenn die Lebenszeit 'static ist, lebt die referenzierte Daten während der gesamten Programmausführung, sodass es keine Möglichkeit gibt, dass sie vor der Ausführung des Zerstörers deallokiert wird. Wenn 'a eine lokale Lebenszeit ist, könnten die Daten gelöscht werden, während die Struktur noch existiert, was zu einem Zugriff auf eine hängende Referenz in Drop führt. Der Compiler lehnt den Fall mit lokaler Lebenszeit ab, weil er nicht beweisen kann, dass der Zerstörer nicht auf die Daten zugreift, nachdem sie freigegeben wurden, während 'static dieses Garanties intrinsisch bietet.
Wie unterscheidet sich PhantomData<T> (besitzende Semantik) von PhantomData<&'a T> (ausleihende Semantik) im Kontext von dropck, und warum verhindert letzterer nicht, dass die Struktur ihren Gültigkeitsbereich verlässt?
PhantomData<T> zeigt an, dass die Struktur so behandelt wird, als würde sie einen T besitzen, was die Varianz und den Dropcheck beeinflusst, indem davon ausgegangen wird, dass die Struktur einen T abwerfen könnte, aber es verbindet die Lebenszeit der Struktur nicht mit einer bestimmten geliehenen Lebenszeit 'a. Daher geht der Compiler davon aus, dass die Struktur jede lokale Daten überdauern könnte, es sei denn, T selbst enthält Lebenszeiten. Im Gegensatz dazu schränkt PhantomData<&'a T> die Struktur ausdrücklich auf die Lebenszeit 'a ein, so dass sie nicht länger leben kann als das Ausleihen und damit ein Use-after-free in den Zerstörern verhindert.