Technisch artikel

Een PDF-objectgraaf exact één keer vrijgeven met HotPDF

HotPDF Delphi Component geeft bij het sluiten of herladen van een document elk PDF-object vrij dat het document bezit: THotPDF.CloseIndirectObjects loopt door het objectregister, verzamelt elke eigenaarsrelatie in een pointerset, koppelt al die relaties los, en geeft pas daarna elk uniek knooppunt en elke streampayload exact één keer vrij. Die driefasenvolgorde is wat gedeelde kinderen, eigenaarscycli, dubbele registraties en wrapper/body-aliassen allemaal laat vallen zonder dubbele free en zonder iets achter te laten. Vóór v2.752.4 deed dezelfde routine iets veel eenvoudigers en veel slechters: hij gaf de lazy file stream-sources vrij, riep Clear aan op de lijst IndirectObjects, gaf de lijstcontainer vrij, en liet elk werkelijk PDF-object voor het afsluiten van het proces liggen. Het commentaar in die code was er eerlijk over. Objecten individueel vrijgeven veroorzaakte access violations, dus de "veilige aanpak" was ze helemaal niet vrijgeven. Dit artikel gaat erover waarom de individuele aanpak echt crashte, en hoe een teardown die werkt eruitziet in een taal met handmatig geheugenbeheer

Waarom kun je niet gewoon elk geregistreerd object Free aanroepen?

Omdat de destructors van de objectklassen het oneens zijn over wie wat bezit, en het register entries bevat op meerdere niveaus van dezelfde eigenaarsketen. De lijst doorlopen en Free aanroepen op elke entry geeft daarom sommig geheugen twee keer vrij en sommig geheugen nooit, afhankelijk van welke klassen toevallig naast elkaar staan

Drie asymmetrieën in HPDFObjs.pas en HPDFDoc.pas veroorzaken het probleem. THPDFDictionaryObject.Destroy loopt door zijn Items en geeft een waarde alleen vrij wanneer IsIndirect False is, in de veronderstelling dat indirecte kinderen van het register zijn en daar worden vrijgegeven. THPDFArrayObject.Destroy maakt dat onderscheid niet en geeft elk item vrij dat hij vasthoudt. En THPDFIndirectObject.Destroy, de wrapper die een objectnummer draagt, geeft zijn body InternalObject vrij. Stel je nu een register voor met een indirecte dictionary, een array die diezelfde dictionary in een van zijn slots noemt, en een wrapper waarvan de body ook als aparte root is geregistreerd, precies wat de parser op echte bestanden produceert. Geef de array eerst vrij en de dictionary is weg voordat het register hem bereikt. Geef de wrapper en de body vrij, in welke volgorde dan ook, en de tweede aanroep laat een destructor op een dangling pointer los. Geef alleen de dictionary vrij en elk indirect kind dat hij oversloeg blijft voor altijd gealloceerd. Geen enkele ordening van het register lost dit op, want het register is een platte lijst en de eigenaarsrelatie is een graaf, en over die graaf redeneren is de enige uitweg

Waarom elk HotPDF-registeritem vrijgeven crashte: THPDFDictionaryObject.Destroy slaat indirecte kinderen over terwijl THPDFArrayObject.Destroy alles vrijgeeft wat hij vasthoudt en THPDFIndirectObject.Destroy zijn InternalObject-body vrijgeeft, dus met een wrapper, een array en een gedeelde dictionary in één platte IndirectObjects-lijst gaat sommig geheugen twee keer dood en sommig nooit
De destructors zijn het oneens over wie wat bezit, en het register bevat entries op meerdere niveaus van dezelfde eigenaarsketen, dus geen enkele ordening van een platte lijst maakt van een naïeve Free per object een correcte teardown

Wat telt als eigenaarsrelatie in een PDF-objectgraaf?

Een eigenaarsrelatie is een pointer waarvan de bron verantwoordelijk is voor het vrijgeven van het doel; een verwijzing is al het andere, en de teardown moet het eerste soort volgen en het tweede negeren. In HotPDF geeft dat precies vier soorten relaties: de Items van een THPDFDictionaryObject, de Items van een THPDFArrayObject, het InternalObject achter een THPDFIndirectObject, en beide helften van een THPDFStreamObject, zijn Dictionary en zijn Stream-payload. De verwijzingssoorten zijn net zo belangrijk, want er één volgen verandert een graafwandeling in een oneindige lus of een use-after-free. Een THPDFLink bevat een objectnummer en generatie, precies zoals ISO 32000-1 §7.3.10 een indirecte verwijzing definieert: een naam voor een object dat elders woont, niet het object zelf. Dat nummer via het register oplossen levert een knooppunt op dat een andere relatie al bezit, dus CloseIndirectObjects derefereert links nooit. De FParent-back-pointer die dictionaries en arrays bijhouden is hetzelfde verhaal in de andere richting; de parent bezit het kind al, dus de pointer omhoog volgen zou alleen een knooppunt herbezoeken waar de wandeling al langs kwam. Beide blijven met rust, en het commentaar in de source zegt het in één regel: links en parentpointers zijn verwijzingen, geen eigenaarsrelaties

Eigenaarsrelaties versus verwijzingen in de HotPDF-objectgraaf: DictionaryObject Items, ArrayObject Items, IndirectObject InternalObject en beide helften van een StreamObject worden gevolgd en losgekoppeld, terwijl een THPDFLink-objectnummer en de FParent-back-pointer namen zijn voor objecten die elders wonen, dus CloseIndirectObjects derefereert ze nooit
Een eigenaarsrelatie is een pointer waarvan de bron het doel moet vernietigen; een verwijzing volgen zou de breadth-first wandeling in een oneindige lus of een use-after-free veranderen, dus links en parentpointers blijven met rust

Hoe werkt de teardown in drie fasen?

Fase één is een breadth-first verzameling. De routine zaait een werklijst met elke entry van IndirectObjects en voegt daarna voor elk knooppunt de doelen van de eigenaarsrelaties van dat knooppunt toe, waarbij alles wat al gezien is wordt overgeslagen. De seen-set is een open-addressing-array van ruwe pointers, gehasht met HPDFFastCacheHashInt64 over de pointerwaarde, met lineaire probing en een GrowSeen die verdubbelt wanneer hij halfvol raakt. Niets in die structuur alloceert per knooppunt, wat telt wanneer een document een paar honderdduizend objecten draagt. Streampayloads gaan naar een aparte lijst Streams, omdat het TStream-afstammelingen zijn en geen THPDFObject-knooppunten, en ze worden in hun eigen ronde vrijgegeven

De teardown van CloseIndirectObjects in drie fasen bij HotPDF: een breadth-first verzameling zaait de werklijst uit IndirectObjects en volgt alleen eigenaarsrelaties via een open-addressed seen-set die met HPDFFastCacheHashInt64 wordt gehasht, fase twee koppelt elke relatie los met MarkAsFreed en nil-toewijzing, en fase drie geeft elk knooppunt en elke streampayload exact één keer vrij
De relaties doorknippen voordat er een destructor loopt is wat de bestaande destructors veilig hergebruikbaar maakt: elk vindt dan niets meer om in te recurseren, zodat gedeelde kinderen, cycli en wrapper-body-aliassen allemaal vallen zonder dubbele free
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;   // 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;

// Fase één: zaai met het register en volg daarna alleen eigenaarsrelaties
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;

Fase twee is het deel dat de destructors veilig maakt om te draaien: elke eigenaarsrelatie wordt op nil gezet voordat er een destructor loopt. Een wrapper krijgt MarkAsFreed, dat FInternalObject wist en de flag zet die zijn destructor als eerste controleert. Van een streamobject worden Dictionary en Stream op nil gezet. Van elk dictionary-item wordt Item^.Value gewist en elke arrayslot wordt met nil overschreven. Na deze ronde heeft de graaf geen relaties meer, dus wanneer fase drie Free aanroept op elk knooppunt in Nodes en daarna elke payload in Streams, vindt elke destructor niets meer om in te recurseren en vernietigt hij alleen zichzelf

// Fase twee: koppel elke eigenaarsrelatie 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;

// Fase drie: elk uniek knooppunt en elke payload wordt exact één 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);

Kijk wat de splitsing oplevert. Een dictionary die door twee streamobjecten wordt gedeeld wordt één keer verzameld, van beide losgekoppeld en één keer vrijgegeven. Een cyclus waarin een array zijn eigen parent-dictionary noemt, eindigt omdat de seen-set het tweede bezoek weigert. Een wrapper en zijn body die beide als root zijn geregistreerd zijn twee verschillende pointers in de set, dus beide worden vrijgegeven, en de destructor van de wrapper probeert de body niet meer vrij te geven omdat MarkAsFreed die relatie al heeft weggenomen. Eén enkele TMemoryStream die als payload van twee streamobjecten is toegewezen staat exact één keer in Streams. Geen van die gevallen vraagt speciale behandeling, en dat is het teken dat het model klopt

Hoe onderscheid je een lek van wat de allocator vasthoudt?

Door te kijken of het aantal levende allocaties van de memory manager meebeweegt met de werklast, niet alleen zijn gereserveerde voetafdruk. Een Delphi memory manager houdt vrijgegeven grote blokken achter voor hergebruik, dus een proces dat na het sluiten van een document op 400 MiB blijft staan heeft niet per se een lek; een proces waarvan het aantal levende blokken per pagina per run met één stijgt wel. De probe die deze fix in gang zette was bewust klein: één THotPDF-writer die één pagina produceert, en dan drie readers die hem laden. Nadat alle vier waren vrijgegeven toonde het heaprapport precies vier levende allocaties van 512 KiB, één per instantie, en dat is de contentstreampayload die elk ervan bezat en nooit vrijgaf. Opschalen maakte hetzelfde patroon onmiskenbaar. De parallelle renderpipeline twee keer draaien verplaatste het cijfer voor gealloceerde grote blokken van 384 MiB naar 640 MiB, een stijging evenredig met het paginanummer die allocatorretentie niet kan verklaren. Na de herschrijving meldde de single-page-diagnostiek nul gealloceerde grote bytes en nul gereserveerde zodra de instanties weg waren. Als u in uw eigen proces op dezelfde soort groei jaagt, vertelt de object dependency graph met vastgehouden bytes welke objecten het geheugen vasthouden zolang het document open is; dit artikel gaat over hun gedrag bij het vrijgeven wanneer het document sluit

Geheugendrempels maken regressietests broos, dus de meegeleverde tests tellen in plaats daarvan destructoraanroepen. Een fixture bouwt de pathologische graaf met de hand op, met een gedeelde dictionary onder twee streams, een array die zowel de gedeelde dictionary als zijn eigen root bevat, één payload die aan beide streams is toegewezen, de root twee keer geregistreerd, en een wrapper waarvan de body apart is geregistreerd, geeft daarna het document vrij en stelt één destructie per uniek object vast: één payload, twee streams, twee dictionaries, één array, één wrapper, één nummer. Onder de oude code meldden alle drie de levensduurtests nul destructies, wat de meest directe formulering is van wat "laat het voor het afsluiten van het proces liggen" betekent

Wat moet er gebeuren voordat de graaf wordt afgebroken?

Elk achtergrondwerk dat objecten uit de graaf leent moet eerst stoppen, en elke cache die display lists of bitmaps bevat die uit die objecten zijn samengesteld moet worden weggegooid, anders leest een workerthread of een gecachte verwijzing vrijgegeven geheugen. CloseIndirectObjects opent daarom met CancelLoadedPagePrefetch en maakt daarna de gerenderde paginacache ongeldig voordat hij het register aanraakt. Het herlaadpad in LoadFromFile en LoadFromStream en de destructor van de component lopen er beide doorheen, dus dezelfde ordening geldt of u een document vervangt of de instantie opruimt; de regels voor het hergebruiken van één THotPDF over meerdere documenten leunen op die garantie. Twee details in die inleiding kwamen pas boven water door de tests te draaien. Ten eerste heeft de destructor de frequencysketches achter de render- en display-list-caches al opgeruimd tegen de tijd dat hij de graaf sluit, dus de invalidatie is bewaakt met een check of die velden niet nil zijn in plaats van onvoorwaardelijk aangeroepen. Ten tweede is InvalidateRenderedPageCache de routine die OnLoadedDocumentModified afvuurt met paginaindex -1, en een aanroeper die een bestand herlaadt hoort geen bewerkingsmelding te krijgen voor de interne teardown van het oude document. De handler wordt opgeslagen, rond de aanroep op nil gezet en in een finally hersteld, en de herlaadregressie stelt een meldingsteller van nul vast na de tweede LoadFromStream. Een geheugenfix die stilletjes een eventcontract verandert is een regressie met betere PR, dus die krijgt zijn eigen assertie. Als u de parallelle renderpipeline op een document draait en het daarna herlaadt, is de cancel stap wat de workerpool ervan weerhoudt de teardown voor te blijven

Het patroon hergebruiken in uw eigen Delphi-code

De techniek is niet specifiek voor PDF. Elk Delphi-objectmodel waarin destructors kinderen inconsistent bezitten, waarin hetzelfde kind vanaf meerdere parents bereikbaar is, of waarin back-pointers en forward pointers naast elkaar bestaan, crasht of lekt onder een naïeve Free per object. De fix heeft altijd dezelfde vorm: bepaal welke pointervelden eigenaars zijn en welke verwijzingen, verzamel de afsluiting van eigenaarsrelaties via een pointerset die herbezoeken tolereert, knip elke relatie door, en vernietig dan de platte lijst. De knipstap is degene die mensen overslaan, en het is degene die de bestaande destructors veilig hergebruikbaar maakt in plaats van een herschrijving van elke klasse in het model te forceren. De grenzen verdienen het wel ronduit te worden genoemd. De pointerset gebruikt het objectadres als identiteit, dus een object dat al is vrijgegeven en waarvan het adres door een nieuwe allocatie is hergebruikt zou niet te onderscheiden zijn; de ordening garandeert dat er tijdens het verzamelen geen destructor loopt, en dat sluit dat uit. De wandeling ziet alleen de vier soorten relaties die hij kent, dus een nieuwe klasse die een kind bezit via een veld dat de wandeling niet bekijkt laat dat kind lekken tot de wandeling erover is bijgebracht. En omdat links via het register worden opgelost in plaats van gevolgd, is een object dat alleen door een link wordt gerefereerd en nooit is geregistreerd helemaal niet bereikbaar voor deze teardown; in HotPDF garandeert de parser de registratie, maar een met de hand gebouwde graaf moet dezelfde regel respecteren

Dit alles zit binnenin de component, dus het zichtbare effect voor een applicatie is simpelweg dat het sluiten of herladen van een document zijn geheugen teruggeeft, zonder API-wijziging. HotPDF is een native VCL PDF-library voor Delphi en C++Builder met volledige source; de API-referentie en een trialbuild staan op de HotPDF Delphi PDF component-pagina