Ein Delphi-Objektmodell, das eine geparste PDF-Datei spiegelt, endet meist als eine flache Liste jedes Objekts plus ein daran hängender Pointer-Graph. Diese Liste durchzugehen und auf jedem Eintrag Free aufzurufen lässt den Lauf auf halbem Weg mit einer Access Violation in einem Destruktor sterben, an einem Objekt, das ein früheres Free schon über einen anderen Parent freigegeben hatte. Ersetzt man die Schleife durch List.Clear, geht der Crash weg, aber der Prozess wächst nun bei jedem Neuladen um ungefähr ein Dokument. Beide Symptome haben eine Ursache: Die Liste ist flach, und die Ownership ist ein Graph. Der Teardown, der funktioniert, entscheidet, welche Pointer ihre Ziele besitzen, sammelt diese Hülle einmal, trennt jede besitzende Kante und gibt erst dann jeden Knoten frei, genau einmal
Warum crasht das Freigeben jedes registrierten Objekts?
In HotPDF ist die flache Liste die Objekt-Registry des Dokuments, und die Klassen, die sie hält, sind sich uneins darüber, wer was zerstört. Jeder Destruktor ist für sich genommen vernünftig. Setzt man sie über eine Registry zusammen, die Einträge aus mehreren Ebenen derselben Besitzkette enthält, gibt ein Free pro Eintrag manchen Speicher zweimal frei und manchen nie
Drei Destruktoren in HPDFObjs.pas und HPDFDoc.pas legen das an. 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. THPDFArrayObject.Destroy gibt jeden Eintrag frei, den er hält, indirect oder nicht. THPDFIndirectObject, der Wrapper für Objekte aus Objekt-Streams (ISO 32000-1 §7.5.7), gibt seinen InternalObject-Body frei. Stellen Sie sich nun eine Registry vor, die ein indirektes Dictionary hält, ein Array, das dasselbe Dictionary in einem Slot listet, und einen Wrapper, dessen Body zusätzlich als eigener Root registriert ist. Gibt man zuerst das Array frei, ist das Dictionary weg, bevor die Registry es erreicht. Gibt man den Wrapper und dann den Body frei, läuft der zweite Aufruf einen Destruktor auf Speicher, der niemandem mehr gehört. Gibt man allein das Dictionary frei, bleibt jedes indirekte Kind, das es übersprungen hat, bis zum Prozessende alloziert
Die Registry umzuordnen kann das nicht retten. Welche Reihenfolge Sie auch wählen, irgendein Paar von Einträgen landet auf der falschen Seite einer Ownership-Kante, denn eine Liste hat keine Möglichkeit zu sagen, dass Eintrag 12 Eintrag 40 besitzt
Besitzende Kanten und Referenzen in einem PDF-Objektgraphen
Eine besitzende Kante ist ein Zeiger, dessen Ziel die Quelle für die Zerstörung verantwortet. Alles andere ist eine Referenz, und der Teardown folgt nur der ersten Art. In HotPDF gibt es exakt vier besitzende Kantenarten: die Items eines THPDFDictionaryObject, die Items eines THPDFArrayObject, das InternalObject hinter einem THPDFIndirectObject und beide Felder eines THPDFStreamObject — sein Dictionary und seine Stream-Nutzlast
Referenzen verdienen dieselbe Sorgfalt, denn einer zu folgen macht aus einem Graph-Durchlauf eine Endlosschleife oder ein Lesen von freigegebenem Speicher. Ein THPDFLink hält eine Objektnummer und Generation, was exakt die indirekte Referenz aus ISO 32000-1 §7.3.10 ist: ein Name für ein Objekt, das anderswo in der Datei gespeichert ist. Diese Nummer über die Registry aufzulösen landet auf einem Knoten, den eine andere Kante bereits besitzt, also dereferenziert der Teardown Links überhaupt nie. Der FParent-Rückwärtszeiger, den Dictionarys und Arrays halten, läuft in die entgegengesetzte Richtung. Der Parent besitzt das Kind bereits, und diesem Pointer nach oben zu klettern würde nur einen Knoten erneut besuchen, den der Durchlauf passiert hat. Beide Arten bleiben für den gesamten Teardown unangetastet
Sammeln, trennen, freigeben: der Teardown in drei Durchgängen
HotPDF gibt die Objekte eines Dokuments in drei Durchgängen innerhalb der geschützten Methode THotPDF.CloseIndirectObjects frei, und die Reihenfolge dieser Durchgänge ist der ganze Trick. Durchgang eins sammelt. Eine Worklist startet mit jedem Registry-Eintrag, und für jeden Knoten hängt der Walk die Ziele seiner besitzenden Kanten an, wobei alles bereits Gesehene übersprungen wird. Die Seen-Menge ist ein Open-Addressing-Array roher Pointer mit linearer Sondierung, das sich verdoppelt, sobald es halb voll ist, also wird pro Knoten nichts alloziert, was zu zählen beginnt, wenn ein Dokument Hunderttausende von Objekten trägt. Stream-Nutzlasten gehen in eine zweite Liste, denn sie sind TStream-Nachfahren statt THPDFObject-Knoten
Die beiden folgenden Listings sind vereinfachte Auszüge der internen Implementierung der Komponente, gezeigt, damit Sie die Durchgänge in den eigenen Objekttypen der Bibliothek sehen. Anwendungscode ruft nichts davon auf; LoadFromFile, LoadFromStream und der Destruktor fahren das für Sie. Im Auszug ist der Adress-Hash auf eine Shift und eine Maske reduziert, und der Schritt, der die Seen-Menge-Tabelle verdoppelt, wenn sie vollläuft, ist ausgelassen
// Vereinfachter Auszug des internen Teardowns, keine aufrufbare API
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
// (die echte Routine verdoppelt und rehasht die Tabelle, sobald sie halb voll ist)
Slot := Integer(NativeUInt(Value) shr 4) and (Length(Seen) - 1);
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;
// Durchgang 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);
// THPDFLink-Werte und FParent-Pointer werden nie verfolgt
Inc(I);
end;
Durchgang zwei trennt. Jede besitzende Kante wird auf nil gesetzt, bevor ein einziger Destruktor läuft. Ein Wrapper bekommt MarkAsFreed, was InternalObject leert und das IsFreed-Flag setzt, das sein Destruktor zuerst prüft. Ein Stream-Objekt bekommt Dictionary und Stream auf nil zugewiesen, jedes Dictionary-Item bekommt sein Value geleert, und jeder Array-Slot wird mit nil überschrieben. Durchgang drei leert die Registry, gibt jeden Knoten in Nodes und dann jede Nutzlast in Streams frei
// Vereinfachter Auszug des internen Teardowns, Fortsetzung; keine aufrufbare API
// Durchgang 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;
// Durchgang 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);
Der Trennschritt ist es, der die bestehenden Destruktoren exakt so bleiben lässt, wie sie sind. Ohne restliche Kanten findet jeder Destruktor nichts, in das er rekursieren könnte, und zerstört nur sich selbst. Der andere Weg zum selben Ergebnis wäre, jeden Destruktor die Registry lehren zu lassen, sodass ein Dictionary, ein Array und ein Wrapper jeder fragen, ob ein anderer Pfad ihre Kinder freigibt. Das verteilt Graph-Wissen über vier Klassen und jede später hinzugekommene, und eine einzige falsche Antwort irgendwo bringt den Double Free zurück. Das ganze Ownership-Modell in einer einzigen Teardown-Routine zu halten ist die sicherere Anordnung
Die unhandlichen Fälle durchlaufen und keiner braucht einen Sonderzweig. Ein von zwei Stream-Objekten geteiltes Dictionary wird einmal gesammelt, von beiden getrennt und einmal freigegeben. Ein Zyklus, in dem ein Array sein eigenes Parent-Dictionary listet, endet, weil die Seen-Menge den zweiten Besuch verweigert. Ein Wrapper und sein Body, als zwei Roots registriert, sind zwei verschiedene Pointer in der Menge, also werden beide freigegeben, und der Destruktor des Wrappers lässt den Body in Ruhe, weil MarkAsFreed diese Kante bereits entfernt hat. Ein einzelner TMemoryStream, der als Nutzlast zweier Stream-Objekte zugewiesen ist, sitzt exakt einmal in Streams
Was muss stoppen, bevor der Graph zu Fall kommt?
Alles, was Objekte aus dem Graphen ausleiht, muss zuerst stoppen. HotPDF kann Seiten auf einem Worker-Thread über PrefetchLoadedPages rendern, und es cached Bitmaps und Display-Listen, die aus den Objekten des Dokuments kompiliert wurden; ein Worker oder ein Cache-Eintrag, der den Graphen überlebt hätte, hätte freigegebenen Speicher gelesen. Der Teardown eröffnet deshalb mit CancelLoadedPagePrefetch und invalidiert den gerenderten Seiten-Cache, bevor er die Registry anfasst. LoadFromFile, LoadFromStream und der THotPDF-Destruktor laufen alle darüber, also ist die Reihenfolge identisch, ob Sie ein Dokument ersetzen oder die Komponente freigeben, und die Regeln zum Wiederverwenden eines THotPDF über Dokumente hinweg stützen sich darauf
Der Notification-Kontrakt zählt genauso. InvalidateRenderedPageCache ist die Methode, die OnLoadedDocumentModified mit PageIndex = -1 auslöst, und ein Viewer, der eine Datei neu lädt, hat keinen Grund, eine Edit-Benachrichtigung für den internen Cleanup des alten Dokuments zu bekommen. Im Teardown wird der Handler gesichert, um den Aufruf herum auf nil gesetzt und in einem finally-Block wiederhergestellt. Ein Reload erzeugt weiterhin einen PageIndex = -1-Aufruf, ausgelöst vom Laden selbst, wenn es den Cache für das neue Dokument zurücksetzt, und das ist der, auf den ein Viewer neu zeichnen sollte. Im Destruktor sind die Caches bereits entsorgt, bis der Graph schließt, also überspringt der Teardown die Invalidierung dort komplett
procedure TViewerForm.FormCreate(Sender: TObject);
begin
FPdf := THotPDF.Create(Self);
FPdf.OnLoadedDocumentModified := PdfModified;
end;
procedure TViewerForm.PdfModified(Sender: TObject; PageIndex: Integer);
begin
if PageIndex = -1 then
RepaintAllPages // Ihre eigene Viewer-Methode
else
RepaintPage(PageIndex); // Ihre eigene Viewer-Methode
end;
procedure TViewerForm.OpenDocument(const FileName: string);
begin
// LoadFromFile bricht einen laufenden Prefetch ab und gibt die Objekte des
// vorherigen Dokuments frei, bevor es parst; PdfModified sieht einen -1-Aufruf
FPdf.LoadFromFile(FileName);
FPdf.PrefetchLoadedPages('1-10', 150);
end;
Kein expliziter CancelLoadedPagePrefetch-Aufruf ist in OpenDocument nötig, denn das Laden führt ihn aus. Ihn selbst aufzurufen ist trotzdem der richtige Move, wenn Sie dabei sind, das geladene Dokument zu ändern, während ein Prefetch laufen könnte; der Deklarationskommentar in HPDFDoc.pas verlangt genau das. Für Worker-Pool-Rendering im größeren Maßstab behandelt der Artikel zur Parallel-Render-Pipeline, wie Hintergrundarbeit gedrosselt wird
Wie unterscheidet man ein Leak von Allocator-Retention?
Beobachten Sie die Zahl der Live-Allokationen des Memory Managers über wiederholte Ladevorgänge hinweg und ignorieren Sie den Process Working Set. Der Delphi-Memory-Manager hält freigegebenen Speicher zur Wiederverwendung gepoolt, also hat ein Prozess, der nach dem Schließen eines Dokuments stabil bei hohem Footprint bleibt, nicht unbedingt geleakt. Ein Prozess, dessen Live-Large-Block-Zahl bei jedem Reload steigt, schon. Die Schleife unten lädt dieselbe Datei zwanzigmal auf einer Instanz und druckt die Large-Block-Zahlen nach jedem Durchgang
procedure ReportLargeBlocks(const Tag: string);
var
State: TMemoryManagerState;
begin
GetMemoryManagerState(State);
Writeln(Tag, ': ', State.AllocatedLargeBlockCount, ' large blocks, ',
State.TotalAllocatedLargeBlockSize, ' bytes');
end;
var
Pdf: THotPDF;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
for I := 1 to 20 do
begin
Pdf.LoadFromFile('sample.pdf');
ReportLargeBlocks('pass ' + IntToStr(I));
end;
finally
Pdf.Free;
end;
ReportLargeBlocks('after Free');
end.
Lesen Sie die Ausgabe als Form. Zahlen, die sich nach dem ersten Durchgang einpendeln, sind Retention oder gewöhnlicher pro-Dokument-Zustand. Zahlen, die pro Durchgang um denselben Schritt steigen, sind ein Leak, und bei einem PDF ist der Schritt meist die Größe der größeren Stream-Nutzlasten, Seiteninhalte und eingebettete Bilder, denn das sind die Allokationen, die groß genug sind, in den Large-Block-Zahlen zu landen. Nehmen Sie eine Datei mit einem Dutzend oder mehr Seiten, damit der Schritt gegen das Rauschen gut zu sehen ist. Wachstum proportional zur Seitenzahl kann Allocator-Retention nicht erklären. Um herauszufinden, welche Objekte Speicher halten, solange ein Dokument offen ist, beantwortet der Objekt-Dependency-Graph mit zurückgehaltenen Bytes diese Seite der Frage
Das Muster in Ihrem eigenen Delphi-Objektmodell wiederverwenden
Nichts hier ist PDF-spezifisch. Jedes Delphi-Objektmodell, in dem Destruktoren Kinder uneinheitlich besitzen, ein Kind von mehreren Parents erreichbar ist oder Rückwärtszeiger neben Vorwärtszeigern sitzen, crasht oder leakt unter einem Free pro Objekt. Der Fix hat jedes Mal dieselbe Form: Jedes Pointer-Feld als besitzend oder Referenz klassifizieren, die Hülle der besitzenden Kanten durch eine Pointer-Menge sammeln, die Wiederbesuche toleriert, jede Kante trennen, die flache Liste zerstören. Der Trennschritt ist der, den Leute auslassen. Ihn auszulassen bedeutet, dass jeder Destruktor am Ende jeden anderen kennen muss
Das Muster kommt mit Grenzen, die man sich aufschreiben sollte, bevor man es kopiert. Die Pointer-Menge nutzt die Objektadresse als Identität, was nur deshalb stimmt, weil während der Sammlung kein Destruktor läuft, also keine Adresse freigegeben und mitten im Walk an eine frische Allokation vergeben werden kann. Der Walk sieht nur die Kantenarten, für die er geschrieben wurde; eine neue Klasse, die ein Kind über ein anderes Feld besitzt, muss sowohl in den Sammel- als auch in den Trenndurchgang aufgenommen werden. Und weil Links aufgelöst statt verfolgt werden, muss ein Objekt, auf das nur ein Link verweist, in der Registry sein, um erreicht zu werden. Der HotPDF-Parser registriert jedes Objekt, das er liest, und ein von Hand gebauter Graph in Ihrem eigenen Code muss dieselbe Regel einhalten
Testen Sie es, indem Sie Destruktoraufrufe statt Bytes zählen. Bauen Sie den fiesen Graphen von Hand: ein von zwei Streams geteiltes Dictionary, ein Array, das sowohl dieses Dictionary als auch seinen eigenen Parent hält, eine Nutzlast, die beiden Streams zugewiesen ist, ein doppelt registrierter Root und ein Wrapper, dessen numerischer Body ebenfalls registriert ist. Trennen Sie ihn ab und erwarten Sie exakt eine Zerstörung pro eindeutigem Objekt — eine Nutzlast, zwei Streams, zwei Dictionarys, ein Array, ein Wrapper, eine Zahl. In Ihren eigenen Klassen genügt dafür ein klassenweiter Zähler, den jeder Destruktor erhöht (ein Instrumentierungs-Hook, den Sie selbst ergänzen), und ein Teardown, der nur die Liste leert, meldet null
In HotPDF sitzt das alles hinter LoadFromFile, LoadFromStream und dem Destruktor, Anwendungscode sieht also nur das Ergebnis: Das Schließen oder Neuladen eines Dokuments gibt dessen Speicher zurück. HotPDF ist eine native VCL-PDF-Bibliothek für Delphi und C++Builder, die mit vollem Quelltext kommt, also lässt sich der hier beschriebene Teardown Zeile für Zeile in HPDFDoc.pas lesen; API-Referenz und ein Trial-Build finden sich auf der Seite zur HotPDF PDF component for Delphi