Technický článek

Uvolnění grafu PDF objektů právě jednou v Delphi: HotPDF

HotPDF Delphi Component uvolní každý PDF objekt, který dokument vlastní, když se dokument zavře nebo znovu načte: THotPDF.CloseIndirectObjects projde registr objektů, posbírá každou vlastnící hranu do pointer setu, odpojí všechny ty hrany a teprve pak uvolní každý unikátní uzel a každý stream payload právě jednou. Tohle třífázové pořadí je to, co dovolí sdíleným dětem, vlastnickým cyklům, duplicitním registracím i aliasům wrapper/tělo sestoupit všechny bez double free a bez něčeho pozabayvaného. Před v2.752.4 dělala tatáž rutina něco mnohem jednoduššího a mnohem horšího: uvolnila lazy file stream zdroje, zavolala Clear na listu IndirectObjects, uvolnila kontejner listu a každý skutečný PDF objekt nechala na exit procesu, aby je vyzvedl. Komentář v tom kódu byl v tom upřímný. Uvolňování objektů jednotlivě způsobovalo access violations, takže „bezpečný přístup" bylo je neuvolňovat vůbec. Tenhle postup je o tom, proč individuální přístup doopravdy padal, a jak vypadá teardown, který funguje, v jazyce s manuální správou paměti

Proč nemůžete prostě zavolat Free na každý registrovaný objekt?

Protože destruktory tříd objektů se neshodnou v tom, kdo vlastní co, a registr obsahuje položky na několika úrovních téhož vlastnického řetězce. Projít listu a zavolat Free na každou položku proto uvolní nějakou paměť dvakrát a nějakou nikdy, podle toho, které třídy se zrovna dostanou vedle sebe

Tři asymetrie v HPDFObjs.pas a HPDFDoc.pas problém vytvářejí. THPDFDictionaryObject.Destroy projde své Items a uvolní hodnotu jen tehdy, když IsIndirect je False, v domnění, že nepřímé děti patří do registru a tam se uvolní. THPDFArrayObject.Destroy žádné takové rozlišení nedělá a uvolní každou položku, kterou drží. A THPDFIndirectObject.Destroy, wrapper nesoucí číslo objektu, uvolní své tělo InternalObject. Teď si představte registr, který drží nepřímý slovník, pole, které v jednom ze svých slotů odkazuje na tentýž slovník, a wrapper, jehož tělo je zaregistrované taky jako samostatný kořen, což je přesně to, co parser produkuje na reálných souborech. Uvolněte nejdřív pole a slovník je pryč, než se k němu registr dostane. Uvolněte wrapper a tělo, v jakémkoli pořadí, a druhé volání pustí destruktor na visící pointer. Uvolníte-li jen slovník, jakékoli nepřímé dítě, které přeskočil, zůstane alokované navždy. Žádné pořadí registru to neopraví, protože registr je plochá list a vlastnická relace je graf a uvažování o grafu je jediná cesta ven

Proč uvolnění každé položky registru HotPDF padlo: THPDFDictionaryObject.Destroy přeskočí nepřímé děti, zatímco THPDFArrayObject.Destroy uvolní všechno, co drží, a THPDFIndirectObject.Destroy uvolní tělo InternalObject, takže s wrapperem, polem a sdíleným slovníkem v jedné ploché listě IndirectObjects umírá nějaká paměť dvakrát a nějaká nikdy
Destruktory se neshodnou v tom, kdo vlastní co, a registr drží položky na několika úrovních téhož vlastnického řetězce, takže žádné pořadí ploché listy nemůže z naivního per-object Free udělat správný teardown

Co se počítá jako vlastnící hrana v grafu PDF objektů?

Vlastnící hrana je pointer, za jehož cíl je zdroj odpovědný zničením; reference je cokoli jiného a teardown musí sledovat první druh a ignorovat druhý. V HotPDF to dává přesně čtyři druhy hran: Items THPDFDictionaryObject, Items THPDFArrayObject, InternalObject za THPDFIndirectObject a obě poloviny THPDFStreamObject, jeho Dictionary a jeho payload Stream. Druhy referencí mají stejnou váhu, protože jejich sledování změní průchod grafem na nekonečnou smyčku nebo use-after-free. THPDFLink drží číslo objektu a generaci, což je způsob, jakým ISO 32000-1 §7.3.10 definuje nepřímou referenci: jméno objektu, který bydlí jinde, ne samotný objekt. Rozřešení toho čísla přes registr dá uzel, který už nějaká jiná hrana vlastní, takže CloseIndirectObjects linky vůbec nedereferencuje. Zpětný pointer FParent, který slovníky a pole drží, je tatáž story opačným směrem; rodič už dítě vlastní, takže sledování pointeru nahoru by jen znovu navštívilo uzel, kterým průchod už prošel. Obojí se nechává na pokoji a komentář ve zdroji to říká na jedné řádce: linky a parent pointery jsou reference, ne vlastnické hrany

Vlastnící hrany proti referencím v grafu objektů HotPDF: DictionaryObject Items, ArrayObject Items, IndirectObject InternalObject a obě poloviny StreamObject se sledují a odpojí, zatímco číslo objektu THPDFLink a zpětný pointer FParent jsou jména objektů, které bydlí jinde, takže je CloseIndirectObjects nikdy nedereferencuje
Vlastnící hrana je pointer, za jehož cíl je zdroj povinen zničit; sledování reference místo ní by změnilo breadth-first průchod na nekonečnou smyčku nebo use-after-free, takže linky a parent pointery se nechávají na pokoji

Jak funguje třífázový teardown?

Fáze jedna je breadth-first sběr. Rutina nasadí worklist každou položkou IndirectObjects a pak pro každý uzel přidá cíle vlastnických hran toho uzlu, přeskočí všechno už viděné. Seen-set je open-addressing pole surových pointerů hashovaných HPDFFastCacheHashInt64 přes hodnotu pointeru, s lineárním probingem a zdvojujícím GrowSeen, když dosáhne půlky plna. Nic v té struktuře nealokuje na uzel, což má význam, když dokument nese pár set tisíc objektů. Stream payloady jdou do separátní listy Streams, protože jsou potomky TStream a ne uzly THPDFObject a uvolňují se ve vlastním průchodu

Třífázový teardown CloseIndirectObjects v HotPDF: breadth-first collect nasadí worklist z IndirectObjects a sleduje jen vlastnické hrany skrz open-addressed seen-set hashovaný HPDFFastCacheHashInt64, fáze dva odpojí každou hranu přes MarkAsFreed a přiřazení nil a fáze tři uvolní každý uzel a stream payload právě jednou
Useknutí hran, než se spustí jakýkoli destruktor, je to, co dělá stávající destruktory bezpečnými k reuse: každý pak nenajde nic, do čeho by rekurzoval, takže sdílené děti, cykly i aliasy wrapper-tělo sestoupí všechny bez double 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ž posbíráno
    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;

// Fáze jedna: nasadit registrem, pak sledovat jen vlastnické 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;

Fáze dva je ta část, která dělá destruktory bezpečnými ke spuštění: každá vlastnící hrana se nastaví na nil, než se kterýkoli destruktor vykoná. Wrapper dostane MarkAsFreed, který vymaže FInternalObject a nastaví příznak, který jeho destruktor kontroluje jako první. Stream objekt má Dictionary a Stream přiřazené na nil. Každá položka slovníku má vymazané Item^.Value a každý slot pole se přepíše na nil. Po tomhle průchodu graf nemá žádné hrany, takže když fáze tři zavolá Free na každém uzlu v Nodes a pak na každém payloadu v Streams, každý destruktor nenajde nic, do čeho by rekurzoval, a zničí jen sebe

// Fáze dva: odpojit každou vlastnickou hranu, než se cokoli uvolní
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;

// Fáze tři: každý unikátní uzel a payload se uvolní právě jednou
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);

Podívejte se, co ten rozdělek kupuje. Slovník sdílený dvěma stream objekty se posbírá jednou, odpojí od obou a uvolní jednou. Cyklus, ve kterém pole vypisuje vlastní rodičovský slovník, se ukončí, protože seen-set druhou návštěvu odmítne. Wrapper a jeho tělo, oba zaregistrované jako kořeny, jsou dva odlišné pointery v setu, takže oba se uvolní a destruktor wrapperu už se nepokouší uvolnit tělo, protože MarkAsFreed tu hranu už vzal. Jediný TMemoryStream přiřazený jako payload dvěma stream objektům sedí v Streams právě jednou. Žádný z těch případů nepotřebuje speciální obsluhu, což je známka, že model je správně

Jak rozlišíte leak od retence alokátoru?

Kontrolou, jestli počet živých alokací memory manageru se hýbe s pracovní zátěží, ne jen jeho rezervovaný footprint. Delphi memory manager drží uvolněné velké bloky pohromadě pro reuse, takže proces, který zůstane na 400 MiB po zavření dokumentu, nemusel nutně leaksovat; proces, jehož počet živých bloků stoupá o jednu na stránku za běh, ano. Sonda, která táhla tuhle opravu, byla záměrně malá: jeden THotPDF writer produkující jedinou stránku, pak tři readery, které ji načtou. Po uvolnění všech čtyř ukázal heap report přesně čtyři živé alokace po 512 KiB, jednu na instanci, což je payload content streamu, který každá vlastnila a nikdy neuvolnila. Škálování nahoru udělalo ze stejného vzoru neomylný. Dvojité spuštění parallel render pipeline posunulo číslo alokovaných large-block z 384 MiB na 640 MiB, nárůst úměrný počtu stránek, který retence alokátoru vysvětlit nedokáže. Po přepisu hlásila jednostránková diagnostika nulových large bajtů alokovaných a nulu rezervovaných, jakmile zmizely instance. Pokud u svého procesu pronásledujete stejný druh růstu, graf závislostí objektů s retained bytes vám řekne, které objekty drží paměť, dokud je dokument otevřený; tenhle postup je o jejich chování při uvolnění, když se zavře

Paměťové prahy dělají křehké regresní testy, takže dodané testy počítají místo toho volání destruktorů. Fixture postaví patologický graf ručně, se sdíleným slovníkem pod dvěma streamy, polem obsahujícím jak sdílený slovník, tak svůj vlastní kořen, jeden payload přiřazený oběma streamům, kořen zaregistrovaný dvakrát a wrapper, jehož tělo je zaregistrované samostatně, pak uvolní dokument a assertuje jedno zničení na unikátní objekt: jeden payload, dva streamy, dva slovníky, jedno pole, jeden wrapper, jedno číslo. Pod starým kódem hlásily všechny tři lifetime testy nula zničení, což je nejpřímočařejší možné vyjádření toho, co znamená „ponech to na exitu procesu"

Co se musí stát, než graf sestoupí?

Jakákoli práce na pozadí, která si půjčuje objekty z grafu, se musí nejdřív zastavit a jakákoli cache držící display listy nebo bitmapy zkompilované z těch objektů se musí zahodit, jinak worker thread nebo cachovaná reference čtou uvolněnou paměť. CloseIndirectObjects proto otvírá CancelLoadedPagePrefetch a pak invaliduje cache vykreslených stránek, než sahne k registru. Reload cesta v LoadFromFile a LoadFromStream i destruktor komponenty obě jdou skrz něj, takže totéž pořadí platí, zda nahrazujete dokument, nebo likvidujete instanci; pravidla pro reuse jednoho THotPDF napříč dokumenty na tu garanci stojí. Dva detaily v tom preamble vyšly na povrch jen během testů. Za prvé, destruktor už do té doby zlikvidoval frequency sketches za render a display list cache, než graf zavře, takže invalidace se hlídá na to, že ta pole nejsou nil, místo aby se volala bezpodmínečně. Za druhé, InvalidateRenderedPageCache je rutina, která odpálí OnLoadedDocumentModified s indexem stránky -1 a volající, který znovu načítá soubor, nemá dostat edit notifikaci za interní teardown starého dokumentu. Handler se uloží, kolem volání se nastaví na nil a vrátí se ve finally a reload regrese assertuje počet notifikací nula po druhém LoadFromStream. Paměťová oprava, která potichu změní event kontrakt, je regrese s lepším PR, takže dostane vlastní assert. Pokud provozujete parallel render pipeline proti dokumentu a pak ho znovu načtete, cancel krok je to, co brání worker poolu závodit s teardownem

Reuse vzoru ve vašem vlastním Delphi kódu

Technika není specifická pro PDF. Jakýkoli Delphi objektový model, ve kterém destruktory vlastní děti nekonzistentně, kde se ke stejnému dítěti dá dostat z několika rodičů nebo kde koexistují zpětné a dopředné pointery, padne nebo leaksuje pod naivním per-object Free. Oprava je vždy stejného tvaru: rozhodněte, která pointerová pole jsou vlastnící a která reference, posbírejte closure vlastnických hran pointer setem, který toleruje znovunávštěvy, usekněte každou hranu a pak zničte plochou listu. Useknutí je krok, který lidé vynechávají, a je to ten, který dělá stávající destruktory bezpečnými k reuse místo vynucení přepisu každé třídy v modelu. Hranice ale stojí za výslovnou řeč. Pointer set používá adresu objektu jako identitu, takže objekt, který už byl uvolněný a jehož adresa byla znovu použita čerstvou alokací, by byl nerozeznatelný; pořadí garantuje, že během sběru neběží žádný destruktor, což to vylučuje. Průchod vidí jen čtyři druhy hran, které zná, takže nová třída, která vlastní dítě polem, které průchod neinspectuje, bude to dítě leaksat, dokud se průchod o něm nenaučí. A protože se linky rozřešují přes registr místo sledování, objekt referencovaný jen linkou a nikdy neregistrovaný není tímto teardownem dosažitelný vůbec; v HotPDF registraci garantuje parser, ale ručně stavěný graf musí respektovat totéž pravidlo

Všechno tohle je uvnitř komponenty, takže viditelný efekt pro aplikaci je prostě ten, že zavření nebo znovunačtení dokumentu vrátí jeho paměť, bez změny API. HotPDF je nativní VCL PDF knihovna pro Delphi a C++Builder s plným zdrojákem; API reference a trial build jsou na stránce komponenty HotPDF Delphi PDF