Die HotPDF Delphi Component gibt jedes PDF-Objekt, das ein Dokument besitzt, frei, wenn das Dokument geschlossen oder neu geladen wird: THotPDF.CloseIndirectObjects läuft durch die Objekt-Registry, sammelt jede besitzende Kante in einer Pointer-Menge, trennt all diese Kanten und gibt erst dann jeden eindeutigen Knoten und jede Stream-Nutzlast genau einmal frei. Diese Drei-Phasen-Reihenfolge ist es, die geteilte Kinder, Besitz-Zyklen, doppelte Registrierungen und Wrapper/Body-Aliase alle zu Fall bringt, ohne einen Double Free und ohne etwas zurückzulassen. Vor v2.752.4 tat dieselbe Routine etwas viel Einfacheres und viel Schlimmeres: Sie gab die Lazy-File-Stream-Quellen frei, rief Clear auf der IndirectObjects-Liste auf, gab den Listen-Container frei und überließ jedes tatsächliche PDF-Objekt dem Prozessende. Der Kommentar in dem Code war dazu auch ehrlich: Die Objekte einzeln freizugeben verursachte Access Violations, also war der „sichere Ansatz“, sie gar nicht freizugeben. Dieser Beitrag handelt davon, warum der individuelle Ansatz tatsächlich gecrasht ist und wie ein Teardown, der funktioniert, in einer Sprache mit manueller Speicherverwaltung aussieht
Warum kann man nicht einfach jedes registrierte Objekt freigeben?
Weil die Destruktoren der Objektklassen uneins darüber sind, wer was besitzt, und die Registry Einträge auf mehreren Ebenen derselben Besitzkette enthält. Die Liste durchzulaufen und auf jedem Eintrag Free aufzurufen gibt also manchen Speicher zweimal frei und manchen nie – je nachdem, welche Klassen zufällig nebeneinander sitzen
Drei Asymmetrien in HPDFObjs.pas und HPDFDoc.pas erschaffen das Problem. THPDFDictionaryObject.Destroy läuft durch seine Items und gibt einen Wert nur frei, wenn IsIndirect False ist, in der Annahme, dass indirekte Kinder der Registry gehören und dort freigegeben werden. THPDFArrayObject.Destroy trifft keine solche Unterscheidung und gibt jeden Eintrag frei, den er hält. Und THPDFIndirectObject.Destroy, der Wrapper, der eine Objektnummer trägt, gibt seinen InternalObject-Body frei. Nehmen Sie nun eine Registry, die ein indirektes Dictionary hält, ein Array, das dasselbe Dictionary in einem seiner Slots listet, und einen Wrapper, dessen Body zusätzlich als separates Root registriert ist – genau das produziert der Parser auf echten Dateien. Geben Sie zuerst das Array frei, ist das Dictionary weg, bevor die Registry es erreicht. Geben Sie Wrapper und Body frei, in welcher Reihenfolge auch immer, führt der zweite Aufruf einen Destruktor auf einem hängenden Zeiger aus. Geben Sie allein das Dictionary frei, bleibt jedes übersprungene indirekte Kind für immer alloziert. Keine Ordnung der Registry behebt das, denn die Registry ist eine flache Liste, und die Besitzbeziehung ist ein Graph – über den Graphen nachzudenken ist der einzige Ausweg
Was zählt als besitzende Kante in einem PDF-Objektgraphen?
Eine besitzende Kante ist ein Zeiger, dessen Ziel die Quelle für die Zerstörung verantwortet; eine Referenz ist alles andere, und der Teardown muss der ersten Art folgen und die zweite ignorieren. In HotPDF ergibt das exakt vier Kantenarten: die Items eines THPDFDictionaryObject, die Items eines THPDFArrayObject, das InternalObject hinter einem THPDFIndirectObject und beide Hälften eines THPDFStreamObject, sein Dictionary und seine Stream-Nutzlast. Die Referenzarten sind genauso wichtig, denn einer zu folgen macht aus einem Graphdurchlauf eine Endlosschleife oder einen Use-after-free. Ein THPDFLink hält eine Objektnummer und Generation – so definiert ISO 32000-1 §7.3.10 eine indirekte Referenz: ein Name für ein Objekt, das anderswo lebt, nicht das Objekt selbst. Diese Nummer über die Registry aufzulösen liefert einen Knoten, den schon eine andere Kante besitzt, also dereferenziert CloseIndirectObjects Links überhaupt nie. Der FParent-Rückwärtszeiger, den Dictionarys und Arrays halten, ist dieselbe Geschichte in die andere Richtung; das Elternteil besitzt das Kind bereits, dem Zeiger nach oben zu folgen würde nur einen Knoten erneut besuchen, den der Durchlauf schon hatte. Beide bleiben unangetastet, und der Kommentar im Quelltext sagt das in einer Zeile: Links und Parent-Pointer sind Referenzen, keine Besitzkanten
Wie funktioniert der Drei-Phasen-Teardown?
Phase eins ist eine Breadth-first-Sammlung. Die Routine sät eine Worklist mit jedem Eintrag von IndirectObjects und hängt dann für jeden Knoten die Ziele dessen besitzender Kanten an, wobei alles bereits Gesehene übersprungen wird. Die Seen-Menge ist ein Open-Addressing-Array roher Zeiger, gehasht mit HPDFFastCacheHashInt64 über den Zeigerwert, mit linearer Sondierung und einem verdoppelnden GrowSeen, wenn es halb voll ist. Nichts in dieser Struktur alloziert pro Knoten, was zählt, wenn ein Dokument ein paar hunderttausend Objekte trägt. Stream-Nutzlasten landen in einer separaten Streams-Liste, denn sie sind TStream-Nachfahren statt THPDFObject-Knoten und werden in ihrem eigenen Durchgang freigegeben
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
if (SeenCount + 1) * 2 >= Length(Seen) then GrowSeen;
Slot := PointerSlot(Pointer(Value), Length(Seen));
while Seen[Slot] <> nil do
begin
if Seen[Slot] = Pointer(Value) then Exit; // bereits gesammelt
Slot := (Slot + 1) and (Length(Seen) - 1);
end;
Seen[Slot] := Pointer(Value);
Inc(SeenCount);
if Payload then Streams.Add(Value) else Nodes.Add(Value);
end;
// Phase eins: mit der Registry säen, dann nur besitzenden Kanten folgen
for I := 0 to IndirectObjects.Count - 1 do
Collect(TObject(IndirectObjects[I]), False);
I := 0;
while I < Nodes.Count do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
Collect(THPDFIndirectObject(Obj).InternalObject, False)
else if Obj is THPDFStreamObject then
begin
Collect(THPDFStreamObject(Obj).Dictionary, False);
Collect(THPDFStreamObject(Obj).Stream, True);
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
Collect(PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value, False)
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
Collect(TObject(THPDFArrayObject(Obj).Items[J]), False);
Inc(I);
end;
Phase zwei ist der Teil, der die Destruktoren sicher ausführbar macht: Jede besitzende Kante wird auf nil gesetzt, bevor irgendein Destruktor ausgeführt wird. Ein Wrapper bekommt MarkAsFreed, was FInternalObject leert und den Flag setzt, den sein Destruktor zuerst prüft. Ein Stream-Objekt bekommt Dictionary und Stream auf nil zugewiesen. Jedes Dictionary-Item bekommt Item^.Value geleert, und jeder Array-Slot wird mit nil überschrieben. Nach diesem Durchgang hat der Graph keine Kanten mehr, also findet, wenn Phase drei auf jedem Knoten in Nodes und dann auf jeder Nutzlast in Streams Free aufruft, jeder Destruktor nichts mehr zum Rekursieren und zerstört nur sich selbst
// Phase zwei: jede besitzende Kante trennen, bevor irgendetwas freigegeben wird
for I := 0 to Nodes.Count - 1 do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
THPDFIndirectObject(Obj).MarkAsFreed
else if Obj is THPDFStreamObject then
begin
THPDFStreamObject(Obj).Dictionary := nil;
THPDFStreamObject(Obj).Stream := nil;
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value := nil
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
THPDFArrayObject(Obj).Items[J] := nil;
end;
// Phase drei: jeder eindeutige Knoten und jede Nutzlast wird genau einmal freigegeben
IndirectObjects.Clear;
for I := 0 to Nodes.Count - 1 do TObject(Nodes[I]).Free;
for I := 0 to Streams.Count - 1 do TObject(Streams[I]).Free;
FreeAndNil(IndirectObjects);
Sehen Sie an, was die Aufspaltung einbringt. Ein Dictionary, das von zwei Stream-Objekten geteilt wird, wird einmal gesammelt, von beiden getrennt und einmal freigegeben. Ein Zyklus, in dem ein Array sein eigenes Parent-Dictionary listet, terminiert, weil die Seen-Menge den zweiten Besuch verweigert. Ein Wrapper und sein Body, beide als Roots registriert, sind zwei verschiedene Pointer in der Menge, also werden beide freigegeben, und der Destruktor des Wrappers versucht nicht mehr, den Body freizugeben, weil MarkAsFreed diese Kante schon abgetrennt hat. Ein einzelner TMemoryStream, der als Nutzlast zweier Stream-Objekte zugewiesen ist, sitzt exakt einmal in Streams. Keiner dieser Fälle braucht Sonderbehandlung – das ist das Zeichen, dass das Modell stimmt
Wie unterscheidet man ein Leak von Allocator-Retention?
Indem man prüft, ob die Zahl der Live-Allokationen des Memory Managers mit der Last mitwandert, nicht nur die reservierte Speicherfläche. Ein Delphi-Memory-Manager hält freigegebene große Blöcke zur Wiederverwendung vor, also hat ein Prozess, der nach dem Schließen eines Dokuments bei 400 MiB bleibt, nicht unbedingt geleakt; ein Prozess, dessen Live-Block-Zahl pro Durchgang um eins pro Seite steigt, schon. Die Sonde, die diesen Fix angetrieben hat, war bewusst klein: ein THotPDF-Writer, der eine einzige Seite produziert, dann drei Reader, die sie laden. Nachdem alle vier freigegeben waren, zeigte der Heap-Report exakt vier Live-Allokationen von 512 KiB, eine pro Instanz – die Content-Stream-Nutzlast, die jede besaß und nie freigab. Hochskaliert machte dasselbe Muster sich unübersehbar. Die Parallel-Render-Pipeline zweimal laufen zu lassen verschob die Large-Block-Allocated-Zahl von 384 MiB auf 640 MiB, ein Anstieg proportional zur Seitenzahl, den Allocator-Retention nicht erklären kann. Nach dem Umschreiben meldete die Einzelseiten-Diagnostik null große Bytes alloziert und null reserviert, sobald die Instanzen weg waren. Wenn Sie in Ihrem eigenen Prozess derselben Art Wachstum nachjagen, sagt Ihnen der Objekt-Dependency-Graph mit zurückgehaltenen Bytes, welche Objekte den Speicher halten, solange das Dokument offen ist; dieser Beitrag handelt von ihrem Freigabeverhalten, wenn es sich schließt
Speicher-Schwellwerte machen spröde Regressionstests, also zählen die ausgelieferten Tests stattdessen Destruktoraufrufe. Ein Fixture baut den pathologischen Graphen von Hand: ein geteiltes Dictionary unter zwei Streams, ein Array, das sowohl das geteilte Dictionary als auch sein eigenes Root enthält, eine Nutzlast, die beiden Streams zugewiesen ist, das Root doppelt registriert und ein Wrapper, dessen Body separat registriert ist; dann gibt es das Dokument frei und behauptet eine Zerstörung pro eindeutigem Objekt: eine Nutzlast, zwei Streams, zwei Dictionarys, ein Array, ein Wrapper, eine Zahl. Unter dem alten Code meldeten alle drei Lifetime-Tests null Zerstörungen – die unmittelbarstmögliche Aussage dessen, was „für das Prozessende liegen lassen“ bedeutet
Was muss passieren, bevor der Graph zu Fall kommt?
Jegliche Hintergrundarbeit, die Objekte aus dem Graphen ausleiht, muss zuerst stoppen, und jeder Cache, der Display-Listen oder Bitmaps hält, die aus diesen Objekten kompiliert wurden, muss fallen gelassen werden, sonst liest ein Worker-Thread oder eine Cache-Referenz freigegebenen Speicher. CloseIndirectObjects eröffnet deshalb mit CancelLoadedPagePrefetch und invalidiert danach den gerenderten Seiten-Cache, bevor es die Registry anfasst. Der Reload-Pfad in LoadFromFile und LoadFromStream und der Komponenten-Destruktor laufen beide darüber, also gilt dieselbe Reihenfolge, ob Sie ein Dokument ersetzen oder die Instanz entsorgen; die Regeln zum Wiederverwenden eines THotPDF über Dokumente hinweg stützen sich auf diese Garantie. Zwei Details in dieser Präambel sind erst beim Testlauf aufgetaucht. Erstens hat der Destruktor die Frequency-Sketches hinter dem Render- und Display-Listen-Cache bereits entsorgt, bis er den Graphen schließt, also ist die Invalidierung daran gebunden, dass diese Felder nicht nil sind, statt bedingungslos aufgerufen zu werden. Zweitens ist InvalidateRenderedPageCache die Routine, die OnLoadedDocumentModified mit Seitenindex -1 auslöst, und ein Aufrufer, der eine Datei neu lädt, sollte keine Edit-Benachrichtigung für den internen Teardown des alten Dokuments erhalten. Der Handler wird gesichert, um den Aufruf herum auf nil gesetzt und in einem finally wiederhergestellt, und die Reload-Regression behauptet nach dem zweiten LoadFromStream eine Benachrichtigungszahl von null. Ein Speicher-Fix, der stillschweigend einen Event-Kontrakt ändert, ist eine Regression mit besserer PR – also bekommt er seine eigene Assertion. Wenn Sie die Parallel-Render-Pipeline gegen ein Dokument laufen lassen und es dann neu laden, hält der Cancel-Schritt den Worker-Pool davon ab, mit dem Teardown zu rasen
Das Muster in Ihrem eigenen Delphi-Code wiederverwenden
Die Technik ist nicht PDF-spezifisch. Jedes Delphi-Objektmodell, in dem Destruktoren Kinder uneinheitlich besitzen, dasselbe Kind von mehreren Eltern erreichbar ist oder Rückwärts- und Vorwärtszeiger koexistieren, crasht oder leakt unter einem naiven Free pro Objekt. Der Fix hat immer dieselbe Form: Entscheiden, welche Pointer-Felder besitzend sind und welche Referenzen, den Abschluss der besitzenden Kanten durch eine Pointer-Menge sammeln, die Wiederbesuche toleriert, jede Kante trennen und dann die flache Liste zerstören. Der Trennschritt ist der, den Leute auslassen, und derjenige, der die bestehenden Destruktoren sicher wiederverwendbar macht, statt einen Umschrieb jeder Klasse im Modell zu erzwingen. Die Grenzen sind allerdings es wert, klar benannt zu werden. Die Pointer-Menge nutzt die Objektadresse als Identität, also wäre ein bereits freigegebenes Objekt, dessen Adresse von einer frischen Allokation wiederverwendet wurde, nicht zu unterscheiden; die Reihenfolge garantiert, dass während der Sammlung kein Destruktor läuft, und genau das räumt diesen Fall ab. Der Durchlauf sieht nur die vier Kantenarten, die er kennt, also leakt eine neue Klasse, die ein Kind über ein Feld besitzt, das der Durchlauf nicht inspiziert, dieses Kind, bis man es dem Durchlauf beibringt. Und weil Links über die Registry aufgelöst statt verfolgt werden, ist ein Objekt, auf das nur ein Link verweist und das nie registriert wurde, für diesen Teardown überhaupt nicht erreichbar; in HotPDF garantiert der Parser die Registrierung, aber ein von Hand gebauter Graph muss dieselbe Regel einhalten
Das alles steckt in der Komponente, der sichtbare Effekt für eine Anwendung ist also schlicht, dass das Schließen oder Neuladen eines Dokuments dessen Speicher zurückgibt, ohne API-Änderung. HotPDF ist eine native VCL-PDF-Bibliothek für Delphi und C++Builder mit vollem Quelltext; die API-Referenz und ein Trial-Build finden sich auf der HotPDF Delphi PDF Component-Seite