Een Delphi-objectmodel dat een geparsen PDF-bestand spiegelt, eindigt meestal als een platte lijst van elk object plus een pointergraaf die eraan hangt. Loop die lijst af met een Free op elke entry en de run sterft halverwege met een access violation binnen een destructor, op een object dat een eerdere Free al via een andere ouder had vrijgegeven. Vervang de loop door List.Clear en de crash verdwijnt, maar het proces groeit nu met ruwweg een document bij elke herlaadbeurt. Beide symptomen hebben een oorzaak: de lijst is plat en eigendom is een graaf. De sloop die werkt, beslist welke pointers hun targets bezitten, verzamelt die closure een keer, knipt elke eigenaarsrand en geeft pas daarna elke node vrij, exact een keer
Waarom crasht het vrijgeven van elk geregistreerd object?
In HotPDF is de platte lijst de objectregistratie van het document, en de klassen die hij vasthoudt zijn het oneens over wie wat vernietigt. Elke destructor is op eigen voorwaarden redelijk. Zet ze samen boven een registratie die entries uit verschillende niveaus van dezelfde eigendomsketen bevat, en een Free per entry geeft wat geheugen twee keer vrij en wat geheugen nooit
Drie destructoren in HPDFObjs.pas en HPDFDoc.pas leggen dit vast. THPDFDictionaryObject.Destroy loopt zijn Items af en geeft een waarde alleen vrij als IsIndirect False is, onder de aanname dat indirecte kinderen bij de registratie horen. THPDFArrayObject.Destroy geeft elk item vrij dat hij vasthoudt, indirect of niet. THPDFIndirectObject, de wrapper voor objecten die uit objectstreams zijn getrokken (ISO 32000-1 §7.5.7), geeft zijn InternalObject-lichaam vrij. Stel je nu een registratie voor met een indirect dictionary, een array die hetzelfde dictionary in een slot noemt, en een wrapper waarvan het lichaam ook als root op zichzelf staat. Geef eerst de array vrij en het dictionary is weg voordat de registratie hem bereikt. Geef eerst de wrapper vrij en dan het lichaam, dan draait de tweede aanroep een destructor op geheugen dat bij niemand meer hoort. Geef alleen het dictionary vrij en elk indirect kind dat hij oversloeg, blijft toegewezen tot het proces eindigt
De registratie herschikken redt dit niet. Welke volgorde je ook kiest, een of ander paar entries belandt aan de verkeerde kant van een eigendomsrand, want een lijst kan niet zeggen dat entry 12 entry 40 bezit
Eigenaarsranden en verwijzingen in een PDF-objectgraaf
Een eigenaarsrand is een pointer waarvan de bron verantwoordelijk is voor het vernietigen van zijn target. Alles daarna is een verwijzing, en de sloop volgt alleen de eerste soort. In HotPDF zijn er exact vier soorten eigenaarsranden: de Items van een THPDFDictionaryObject, de Items van een THPDFArrayObject, het InternalObject achter een THPDFIndirectObject, en beide velden van een THPDFStreamObject — zijn Dictionary en zijn Stream-payload
Verwijzingen vragen dezelfde zorg, want er een volgen maakt van een graafwandeling een eindloze lus of een lezing van vrijgegeven geheugen. Een THPDFLink bevat een objectnummer en generatie, wat precies de indirecte verwijzing uit ISO 32000-1 §7.3.10 is: een naam voor een object dat ergens anders in het bestand staat. Dat nummer via de registratie resolveren belandt op een node die een andere rand al bezit, dus de sloop dereferentieert links helemaal niet. De FParent-terugwijzer die dictionaries en arrays bijhouden, loopt de andere kant op. De ouder bezit het kind al, en die pointer opklimmen betekent alleen een node opnieuw bezoeken die de wandeling al had. Beide soorten blijven de hele sloop onaangeraakt
Verzamelen, loskoppelen, vrijgeven: de sloop in drie passes
HotPDF geeft de objecten van een document vrij in drie passes binnen de protected-methode THotPDF.CloseIndirectObjects, en de volgorde van die passes is de hele truc. Pas een verzamelt. Een werklijst begint met elke registratie-entry, en per node voegt de wandeling de targets van zijn eigenaarsranden toe, waarbij alles wat hij al zag wordt overgeslagen. De gezien-set is een open-addressing-array van rauwe pointers met lineair sonderen die verdubbelt zodra hij halfvol is, dus er wordt per node niets toegewezen, wat ertoe gaat doen als een document honderdduizenden objecten draagt. Stream-payloads gaan naar een tweede lijst, want het zijn TStream-afstammelingen in plaats van THPDFObject-nodes
De twee listings hierna zijn vereenvoudigde fragmenten van de interne implementatie van de component, getoond zodat je de passes ziet in de eigen objecttypes van de bibliotheek. Toepassingscode roept hiervan niets aan; LoadFromFile, LoadFromStream en de destructor draaien het voor je. In het fragment is de adres-hash teruggebracht tot een shift en een masker, en de stap die de tabel van de gezien-set verdubbelt zodra hij vol raakt, is weggelaten
// Vereenvoudigd fragment van de interne sloop, geen aanroepbare API
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
// (de echte routine verdubbelt en rehashet de tabel zodra hij halfvol is)
Slot := Integer(NativeUInt(Value) shr 4) and (Length(Seen) - 1);
while Seen[Slot] <> nil do
begin
if Seen[Slot] = Pointer(Value) then Exit; // al verzameld
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;
// Pas een: begin met de registratie, volg daarna alleen eigenaarsranden
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-waarden en FParent-pointers worden nooit gevolgd
Inc(I);
end;
Pas twee knipt. Elke eigenaarsrand wordt op nil gezet voordat een enkele destructor draait. Een wrapper krijgt MarkAsFreed, dat InternalObject wist en de IsFreed-vlag zet die zijn destructor als eerste controleert. Een streamobject krijgt Dictionary en Stream op nil, elk dictionary-item krijgt zijn Value gewist, en elk array-slot wordt met nil overschreven. Pas drie wist de registratie, geeft elke node in Nodes vrij en daarna elke payload in Streams
// Vereenvoudigd fragment van de interne sloop, vervolg; geen aanroepbare API
// Pas twee: koppel elke eigenaarsrand los voordat er iets wordt vrijgegeven
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;
// Pas drie: elke unieke node en payload wordt exact een keer vrijgegeven
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);
De knip is wat de bestaande destructoren exact laat blijven zoals ze zijn. Zonder randen over vindt elke destructor niets om in te recursieren en vernietigt alleen zichzelf. De andere route naar hetzelfde resultaat is elke destructor de registratie leren kennen, zodat een dictionary, een array en een wrapper elk vragen of een ander pad hun kinderen zal vrijgeven. Dat spreidt graafkennis over vier klassen en elke klasse die er later bijkomt, en een verkeerd antwoord ergens brengt de double free terug. Het hele eigendomsmodel in een enkele slooproutine houden is de veiligere regeling
Loop de lastige gevallen erdoorheen en geen ervan heeft een speciale tak nodig. Een dictionary gedeeld door twee streamobjecten wordt een keer verzameld, van beide losgekoppeld en een keer vrijgegeven. Een cyclus waarin een array zijn eigen ouder-dictionary noemt, eindigt omdat de gezien-set het tweede bezoek weigert. Een wrapper en zijn lichaam als twee roots geregistreerd zijn twee verschillende pointers in de set, dus beide worden vrijgegeven, en de destructor van de wrapper laat het lichaam met rust omdat MarkAsFreed die rand al had verwijderd. Een TMemoryStream als payload van twee streamobjecten toegewezen, staat exact een keer in Streams
Wat moet stoppen voordat de graaf neerkomt?
Alles wat objecten uit de graaf leent, moet eerst stoppen. HotPDF kan pagina's op een workerthread renderen via PrefetchLoadedPages, en hij cachet bitmaps en displaylijsten opgebouwd uit de objecten van het document; een worker of cache-entry die de graaf overleefde, zou vrijgegeven geheugen lezen. De sloop opent daarom met CancelLoadedPagePrefetch en invalideert de gerenderde pagina-cache voordat hij de registratie aanraakt. LoadFromFile, LoadFromStream en de destructor van THotPDF leiden er allemaal doorheen, dus de volgorde is identiek of je nu een document vervangt of de component vrijgeeft, en de regels voor het hergebruiken van een THotPDF over documenten heen leunen daarop
Het notificatiecontract doet er net zo veel toe. InvalidateRenderedPageCache is de methode die OnLoadedDocumentModified afgaat met PageIndex = -1, en een viewer die een bestand herlaadt, hoort geen bewerkingsnotificatie te krijgen voor de interne opruiming van het oude document. Binnen de sloop wordt de handler bewaard, rond die aanroep op nil gezet en in een finally-blok hersteld. Een herlaad levert nog steeds een aanroep met PageIndex = -1 op, gegeven door de load zelf wanneer hij de cache voor het nieuwe document reset, en dat is degene waarop een viewer zou hertekenen. In de destructor zijn de caches al opgeruimd tegen de tijd dat de graaf sluit, dus daar slaat de sloop de invalidatie helemaal over
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 // je eigen viewer-methode
else
RepaintPage(PageIndex); // je eigen viewer-methode
end;
procedure TViewerForm.OpenDocument(const FileName: string);
begin
// LoadFromFile annuleert een lopende prefetch en geeft de objecten van het vorige
// document vrij voordat hij parsed; PdfModified ziet een -1-aanroep
FPdf.LoadFromFile(FileName);
FPdf.PrefetchLoadedPages('1-10', 150);
end;
Een expliciete CancelLoadedPagePrefetch-aanroep is niet nodig in OpenDocument, want het laden voert hem uit. Zelf aanroepen is nog steeds de juiste zet als je het geladen document gaat bewerken terwijl er mogelijk een prefetch draait; de declaratiecommentaar in HPDFDoc.pas vraagt daar exact om. Voor workerpool-rendering op grotere schaal behandelt het artikel over de parallelle render-pijplijn hoe achtergrondwerk wordt gereguleerd
Hoe onderscheid je een lek van wat de allocator vasthoudt?
Houd het aantal levende toewijzingen van de memory manager in de gaten over herhaalde loads heen, en negeer de working set van het proces. De Delphi memory manager houdt vrijgegeven geheugen gepoold voor hergebruik, dus een proces dat na het sluiten van een document op een hoog voetafdruk stabiliseert, heeft niet noodzakelijk iets gelekt. Een proces waarvan het aantal levende grote blokken bij elke herlaad stijgt, wel. De loop hieronder laadt hetzelfde bestand twintig keer op een instantie en print de grote-blok-cijfers na elke pas
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.
Lees de uitvoer als een vorm. Getallen die na de eerste pass stabiliseren zijn retentie of gewone staat per document. Getallen die per pass met dezelfde stap stijgen zijn een lek, en bij een PDF is de stap meestal de grootte van de grotere stream-payloads, paginacontent en ingebedde afbeeldingen, want dat zijn de toewijzingen groot genoeg voor de grote-blok-cijfers. Gebruik een bestand met een dozijn of meer pagina's zodat de stap tegen de ruis af te lezen is. Groei in verhouding tot het paginaaantal kan allocatieretentie niet verklaren. Om te vinden welke objecten geheugen vasthouden terwijl een document open staat, geeft de objectafhankelijkheidsgraaf met vastgehouden bytes antwoord op die kant van de vraag
Het patroon hergebruiken in je eigen Delphi-objectmodel
Niets hier is specifiek voor PDF. Elk Delphi-objectmodel waar destructoren kinderen inconsistent bezitten, waar een kind vanuit verschillende ouders bereikbaar is, of waar terugwijzers naast vooruitwijzers staan, crasht of lekt onder een Free per object. De oplossing heeft elke keer dezelfde vorm: classificeer elk pointerveld als bezittend of verwijzend, verzamel de closure van eigenaarsranden via een pointerset die herbezoeken tolereert, knip elke rand, vernietig de platte lijst. De knipstap is degene die mensen overslaan. Sla hem over en elke destructor moet uiteindelijk elke andere kennen
Het patroon komt met grenzen die je voordat je hem kopieert op papier wilt hebben. De pointerset gebruikt het objectadres als identiteit, wat alleen klopt omdat er tijdens het verzamelen geen destructor draait, zodat geen adres kan worden vrijgegeven en aan een nieuwe toewijzing gegeven tijdens de wandeling. De wandeling ziet alleen de randsoorten waarvoor hij is geschreven; een nieuwe klasse die een kind via een ander veld bezit, moet aan zowel de verzamelpas als de loskoppelpas worden toegevoegd. En omdat links worden geresolved in plaats van gevolgd, moet een object waarnaar alleen een link verwijst in de registratie staan om bereikt te worden. De HotPDF-parser registreert elk object dat hij leest, en een handgebouwde graaf in je eigen code moet dezelfde regel aanhouden
Test het door destructoraanroepen te tellen in plaats van bytes. Bouw de nare graaf met de hand: een dictionary gedeeld door twee streams, een array die zowel dat dictionary als zijn eigen ouder vasthoudt, een payload aan beide streams toegewezen, een root twee keer geregistreerd, en een wrapper waarvan het numerieke lichaam ook is geregistreerd. Sloop hem af en verwacht exact een vernietiging per uniek object — een payload, twee streams, twee dictionaries, een array, een wrapper, een getal. In je eigen klassen is een teller op klasseniveau die in elke destructor ophoogt genoeg hiervoor (een instrumentatiehaak die je zelf toevoegt), en een sloop die alleen de lijst wist meldt nul
In HotPDF zit dit alles achter LoadFromFile, LoadFromStream en de destructor, dus toepassingscode ziet alleen het resultaat: een document sluiten of herladen geeft zijn geheugen terug. HotPDF is een native VCL PDF-bibliotheek voor Delphi en C++Builder die met volledige bron wordt geleverd, dus de sloop die hier staat beschreven is regel voor regel leesbaar in HPDFDoc.pas; de API-referentie en een trial-build staan op de pagina van de HotPDF PDF component for Delphi