Odborný článok

Uvoľnenie PDF objektového grafu práve raz v Delphi

HotPDF Delphi Component uvoľní každý PDF objekt, ktorý dokument vlastní, keď sa ten dokument zatvorí alebo znovu načíta: THotPDF.CloseIndirectObjects prejde registrom objektov, pozbiera každú vlastnícku hranu do množiny pointerov, odpojí všetky tie hrany a až potom uvoľní každý unikátny uzol a každý stream payload práve raz. Práve toto trojfázové poradie dovoľuje, aby zdieľané potomky, vlastnícke cykly, duplicitné registrácie aj aliasy wrapper/telo všetky spadli bez dvojitého free a bez toho, aby po nich niečo zostalo. Pred verziou v2.752.4 tá istá rutina robila niečo oveľa jednoduchšie a oveľa horšie: uvoľnila lazy zdroje file streamov, zavolala Clear na zozname IndirectObjects, uvoľnila kontajner zoznamu a každý skutočný PDF objekt nechala na exit procesu. Aj komentár v tom kóde bol o tom poctivý. Uvoľňovať objekty jednotlivo spôsobovalo access violations, takže ten "bezpečný postup" bol neuvoľniť ich vôbec. Tento článok je o tom, prečo individuálny prístup naozaj padal, a ako vyzerá teardown, ktorý funguje, v jazyku s manuálnou správou pamäte

Prečo nemôžete jednoducho Free každý registrovaný objekt?

Pretože destruktory objektových tried sa nezhodujú v tom, kto čo vlastní, a register obsahuje záznamy na niekoľkých úrovniach toho istého vlastníckeho reťazca. Prejsť zoznam a zavolať Free na každom zázname teda uvoľní nejakú pamäť dvakrát a nejakú nikdy, podľa toho, ktoré triedy náhodou sedia vedľa seba

Problém vytvárajú tri asymetrie v HPDFObjs.pas a HPDFDoc.pas. THPDFDictionaryObject.Destroy prejde svoje Items a hodnotu uvoľní len vtedy, keď je IsIndirect False, s predpokladom, že nepriame potomky patria registru a uvoľnia sa tam. THPDFArrayObject.Destroy žiadny taký rozdiel nerobí a uvoľní každú položku, ktorú drží. A THPDFIndirectObject.Destroy, teda wrapper, ktorý nesie číslo objektu, uvoľní svoje telo InternalObject. Teraz si predstavte register, ktorý drží nepriamy slovník, pole, ktoré ten istý slovník vypisuje v jednom zo svojich slotov, a wrapper, ktorého telo je tiež registrované ako samostatný koreň, čo je presne to, čo parser produkuje na reálnych súboroch. Uvoľnite najprv pole a slovník je preč skôr, než sa k nemu register dostane. Uvoľnite wrapper a telo, v hocijakom poradí, a druhé volanie spustí destruktor na visiacom pointeri. Uvoľnite slovník samotný a každý nepriamy potomok, ktorý preskočil, zostane alokovaný navždy. Žiadne poradie registra to nespraví, pretože register je plochý zoznam a vlastnícky vzťah je graf, a jediná cesta von je uvažovať o grafe

Prečo uvoľnenie každého záznamu registra HotPDF padalo: THPDFDictionaryObject.Destroy preskakuje nepriame potomky, kým THPDFArrayObject.Destroy uvoľní všetko, čo drží, a THPDFIndirectObject.Destroy uvoľní svoje telo InternalObject, takže s wrapperom, poľom a zdieľaným slovníkom v jednom plochom zozname IndirectObjects nejaká pamäť umrie dvakrát a nejaká nikdy
Destruktory sa nezhodujú v tom, kto čo vlastní, a register drží záznamy na niekoľkých úrovniach toho istého vlastníckeho reťazca, takže žiadne poradie plochého zoznamu nespraví z naivného Free na objekt správny teardown

Čo sa v PDF objektovom grafe počíta ako vlastnícka hrana?

Vlastnícka hrana je pointer, ktorého cieľ je zdroj povinný zničiť; referencia je čokoľvek iné a teardown musí sledovať prvý druh a ten druhý ignorovať. V HotPDF to dáva presne štyri druhy hrán: Items v THPDFDictionaryObject, Items v THPDFArrayObject, InternalObject za THPDFIndirectObject a obe polovice THPDFStreamObject, teda jeho Dictionary a jeho Stream payload. Referenčné druhy sú rovnako dôležité, pretože sledovať jednu z nich zmení prechod grafom na nekonečnú slučku alebo use-after-free. THPDFLink drží číslo objektu a generáciu, čo je spôsob, akým ISO 32000-1 §7.3.10 definuje nepriamu referenciu: meno pre objekt, ktorý žije inde, nie samotný objekt. Vyriešenie toho čísla cez register vráti uzol, ktorý už vlastní nejaká iná hrana, takže CloseIndirectObjects linky nikdy nedereferencuje. Back-pointer FParent, ktorý si slovníky a polia držia, je ten istý príbeh opačným smerom; rodič už dieťa vlastní, takže sledovať pointer smerom nahor by len znovu navštívilo uzol, ktorým prechod už prešiel. Obe zostávajú nedotknuté a komentár v zdrojáku to hovorí jedným riadkom: linky a parent pointery sú referencie, nie vlastnícke hrany

Vlastnícke hrany verzus referencie v objektovom grafe HotPDF: Items v DictionaryObject, Items v ArrayObject, InternalObject v IndirectObject a obe polovice StreamObject sa sledujú a odpojujú, kým číslo objektu v THPDFLink a back-pointer FParent sú mená pre objekty žijúce inde, takže CloseIndirectObjects ich nikdy nedereferencuje
Vlastnícka hrana je pointer, ktorého cieľ musí zdroj zničiť; sledovanie referencie namiesto nej by z prechodu do šírky spravilo nekonečnú slučku alebo use-after-free, takže linky a parent pointery zostávajú nedotknuté

Ako funguje trojfázový teardown?

Prvá fáza je zber do šírky. Rutina nasype do worklistu každý záznam z IndirectObjects a potom pre každý uzol pripojí ciele jeho vlastníckych hrán a preskočí všetko, čo už videl. Množina videných je pole s otvoreným adresovaním raw pointerov hashovaných cez HPDFFastCacheHashInt64 z hodnoty pointera, s lineárnym probingom a zdvojnásobujúcim GrowSeen, keď sa naplní do polovice. Nič v tej štruktúre nealokuje na uzol, čo je dôležité, keď dokument nesie niekoľko stotisíc objektov. Stream payloady idú do samostatného zoznamu Streams, pretože sú to potomkovia TStream, nie uzly THPDFObject, a uvoľňujú sa vo vlastnom prechode

Trojfázový teardown CloseIndirectObjects v HotPDF: zber do šírky nasype worklist z IndirectObjects a sleduje len vlastnícke hrany cez otvorene adresovanú množinu videných hashovanú cez HPDFFastCacheHashInt64, druhá fáza odpojí každú hranu cez MarkAsFreed a priradenie nil a tretia fáza uvoľní každý uzol a stream payload práve raz
Prerezať hrany skôr, než sa spustí akýkoľvek destruktor, je to, čo robí existujúce destruktory bezpečne znovupoužiteľnými: každý potom nemá do čoho rekurzovať, takže zdieľané potomky, cykly aj aliasy wrapper-telo spadnú bez dvojitého 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;   // už pozbierané
    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;

// Prvá fáza: nasypané registrom, potom sleduj len vlastnícke hrany
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;

Druhá fáza je tá časť, ktorá robí destruktory bezpečnými na spustenie: každá vlastnícka hrana sa nastaví na nil skôr, než sa spustí akýkoľvek destruktor. Wrapper dostane MarkAsFreed, ktorý vyčistí FInternalObject a nastaví príznak, ktorý jeho destruktor kontroluje ako prvý. Stream objekt má Dictionary a Stream priradené na nil. Každá položka slovníka má Item^.Value vyčistené a každý slot poľa je prepísaný na nil. Po tomto prechode nemá graf žiadne hrany, takže keď tretia fáza zavolá Free na každý uzol v Nodes a potom na každý payload v Streams, každý destruktor nemá do čoho rekurzovať a zničí len seba

// Druhá fáza: odpoj každú vlastnícku hranu skôr, než čokoľvek uvoľníš
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;

// Tretia fáza: každý unikátny uzol a payload sa uvoľní práve raz
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);

Pozrite sa, čo to rozdelenie prináša. Slovník zdieľaný dvoma stream objektmi sa pozbiera raz, odpojí od oboch a uvoľní raz. Cyklus, kde pole vypisuje svoj vlastný rodičovský slovník, skončí, pretože množina videných odmietne druhú návštevu. Wrapper a jeho telo, oba registrované ako korene, sú v množine dva odlišné pointery, takže sa uvoľnia oba, a destruktor wrappera už neskúša uvoľniť telo, pretože MarkAsFreed tú hranu už odobral. Jediný TMemoryStream priradený ako payload dvoch stream objektov sedí v Streams presne raz. Ani jeden z tých prípadov nepotrebuje špeciálne spracovanie, a to je znak, že model je správny

Ako odlíšite leak od retencie alokátora?

Tak, že sledujete, či sa počet živých alokácií správcu pamäte hýbe s pracovnou záťažou, a nielen jeho rezervovanú stopu. Správca pamäte v Delphi si uvoľnené veľké bloky drží na opätovné použitie, takže proces, ktorý po zatvorení dokumentu zostane na 400 MiB, ešte nemusí leakovať; proces, ktorému počet živých blokov stúpne o jeden na stránku na beh, leakuje. Sonda, ktorá túto opravu spustila, bola zámerne malá: jeden writer THotPDF produkujúci jednu stránku a potom traja čitatelia, ktorí ju načítajú. Po uvoľnení všetkých štyroch ukázal report haldy presne štyri živé alokácie po 512 KiB, jednu na inštanciu, čo je content stream payload, ktorý každá vlastnila a nikdy neuvoľnila. Zväčšenie spravilo ten istý vzor neprehliadnuteľným. Dvojité spustenie paralelnej render pipeline posunulo číslo alokovaných veľkých blokov z 384 MiB na 640 MiB, teda nárast úmerný počtu stránok, ktorý retencia alokátora nevysvetlí. Po prepísaní hlásila jednostranová diagnostika nula alokovaných veľkých bajtov a nula rezervovaných, len čo inštancie zmizli. Ak lovíte ten istý druh rastu vo vlastnom procese, object dependency graph s retained bytes vám povie, ktoré objekty držia pamäť, kým je dokument otvorený; tento článok je o ich release behavior, keď sa zatvorí

Pamäťové prahy robia regresné testy krehkými, takže dodané testy počítajú volania destruktorov. Fixtura postaví patologický graf ručne: zdieľaný slovník pod dvoma streammi, pole obsahujúce aj zdieľaný slovník, aj svoj vlastný koreň, jeden payload priradený obom streamom, koreň registrovaný dvakrát a wrapper, ktorého telo je registrované samostatne, potom dokument uvoľní a overí jednu deštrukciu na unikátny objekt: jeden payload, dva streamy, dva slovníky, jedno pole, jeden wrapper, jedno číslo. Pod starým kódom všetky tri lifetime testy hlásili nula deštrukcií, čo je najpriamejšie možné vyjadrenie toho, čo znamená "nechaj to na exit procesu"

Čo sa musí stať, než graf spadne?

Každá práca na pozadí, ktorá si požičiava objekty z grafu, musí najprv skončiť, a každá cache, ktorá drží display listy alebo bitmapy skompilované z tých objektov, musí byť zhodená, inak worker vlákno alebo cachovaná referencia číta uvoľnenú pamäť. CloseIndirectObjects preto začína s CancelLoadedPagePrefetch a potom zneplatní cache vykreslených stránok, až potom sa dotkne registra. Cesta znovunačítania v LoadFromFile a LoadFromStream aj deštruktor komponenty idú obe cez toto miesto, takže rovnaké poradie platí, či nahradzujete dokument alebo likvidujete inštanciu; pravidlá pre opätovné použitie jedného THotPDF naprieč dokumentmi sa o tú záruku opierajú. Dva detaily v tej preambule sa vynorili až z behu testov. Po prvé, deštruktor už v čase, keď zatvára graf, zlikvidoval frequency sketches za cache renderu a display listov, takže zneplatnenie je strážené tým, že tie polia nie sú nil, a nevolá sa bezpodmienečne. Po druhé, InvalidateRenderedPageCache je tá rutina, ktorá spúšťa OnLoadedDocumentModified s indexom stránky -1, a volajúci, ktorý súbor znovu načíta, nemá dostať notifikáciu o úprave za interný teardown starého dokumentu. Handler sa uloží, okolo volania sa nastaví na nil a vo finally sa obnoví, a regresia znovunačítania overuje počet notifikácií nula po druhom LoadFromStream. Pamäťová oprava, ktorá potichu zmení kontrakt udalostí, je regresia s lepším PR, takže dostane vlastný assert. Ak proti dokumentu pustíte paralelnú render pipeline a potom ho znovu načítate, práve ten cancel krok drží worker pool od pretekania s teardownom

Znovupoužitie vzoru vo vašom vlastnom Delphi kóde

Technika nie je špecifická pre PDF. Každý objektový model v Delphi, kde destruktory vlastnia potomkov nekonzistentne, kde sa k tomu istému potomkovi dá dostať z niekoľkých rodičov alebo kde koexistujú back-pointery a dopredné pointery, spadne alebo leakuje pri naivnom Free na objekt. Oprava má vždy ten istý tvar: rozhodnite, ktoré pointerové polia sú vlastnícke a ktoré sú referencie, pozbierajte uzáver vlastníckych hrán cez množinu pointerov, ktorá toleruje opakované návštevy, prerežte každú hranu a potom zničte plochý zoznam. Krok prerezania je ten, ktorý ľudia preskakujú, a je to ten, ktorý robí existujúce destruktory bezpečne znovupoužiteľnými namiesto vynútenia prepisu každej triedy v modeli. Hranice však stoja za to povedať priamo. Množina pointerov používa adresu objektu ako identitu, takže objekt, ktorý už bol uvoľnený a ktorého adresu znovu použila nová alokácia, by bol nerozoznateľný; poradie zaručuje, že počas zberu nebeží žiadny destruktor, a to to vylučuje. Prechod vidí len tie štyri druhy hrán, ktoré pozná, takže nová trieda, ktorá vlastní potomka cez pole, ktoré prechod neinšpektuje, toho potomka leakuje, dokým sa prechod oň nenaučí. A pretože linky sa riešia cez register a nie sledovaním, objekt, na ktorý odkazuje len link a ktorý nebol nikdy registrovaný, nie je pre tento teardown dosiahnuteľný vôbec; v HotPDF registráciu garantuje parser, ale ručne postavený graf musí dodržať to isté pravidlo

Všetko z toho je vnútri komponenty, takže viditeľný efekt pre aplikáciu je jednoducho to, že zatvorenie alebo znovunačítanie dokumentu vráti jeho pamäť, a to bez zmeny API. HotPDF je natívna VCL PDF knižnica pre Delphi a C++Builder s plným zdrojovým kódom; referenciu API a trial build nájdete na stránke HotPDF Delphi PDF komponenty