Tehnički članak

Oslobađanje PDF grafa objekata tačno jednom u Delphi-ju

HotPDF Delphi Component oslobađa svaki PDF objekat koji dokument poseduje kada se taj dokument zatvori ili ponovo učita: THotPDF.CloseIndirectObjects prolazi kroz registar objekata, skuplja svaku posedujuću ivicu u set pointera, odvaja sve te ivice, i tek onda oslobađa svaki jedinstveni čvor i svaki stream payload tačno jednom. Taj redosled u tri faze je ono što pušta deljenu decu, cikluse vlasništva, duplirane registracije i alijase wrapper/telo da se svi sruše bez dvostrukog oslobađanja i bez toga da bilo šta ostane za sobom. Pre v2.752.4 ista rutina radila je nešto mnogo prostije i mnogo gore: oslobađala je izvore lazy file stream-ova, pozivala Clear nad listom IndirectObjects, oslobađala kontejner liste, a svaki stvarni PDF objekat ostavljala je izlasku procesa da ga povrati. I komentar u tom kodu bio je iskren o tome. Oslobađanje objekata pojedinačno izazivalo je access violation, pa je „bezbedan pristup“ bio da se ne oslobađaju uopšte. Ovaj tekst je o tome zašto je pojedinačni pristup zaista padao, i kako izgleda teardown koji radi u jeziku sa ručnim upravljanjem memorijom

Zašto ne možete prosto da pozovete Free nad svakim registrovanim objektom?

Zato što se destruktori klasa objekata ne slažu oko toga ko šta poseduje, a registar sadrži unose na nekoliko nivoa istog lanca vlasništva. Prolazak kroz listu i poziv Free nad svakim unosom zato oslobađa neku memoriju dvaput a neku nikada, u zavisnosti od toga koje klase slučajno sede jedna pored druge

Tri asimetrije u HPDFObjs.pas i HPDFDoc.pas stvaraju problem. THPDFDictionaryObject.Destroy prolazi kroz svoje Items i oslobađa vrednost samo kada je IsIndirect False, uz pretpostavku da indirektna deca pripadaju registru i da će tamo biti oslobođena. THPDFArrayObject.Destroy ne pravi takvu razliku i oslobađa svaki element koji drži. A THPDFIndirectObject.Destroy, wrapper koji nosi broj objekta, oslobađa svoje telo InternalObject. Zamislite sada registar koji drži indirektni rečnik, niz koji taj isti rečnik navodi u jednom svom slotu, i wrapper čije je telo takođe registrovano kao zaseban koren, što je upravo ono što parser proizvodi na pravim fajlovima. Oslobodite prvo niz i rečnik je nestao pre nego što registar stigne do njega. Oslobodite wrapper i telo, u bilo kojem redosledu, i drugi poziv izvršava destruktor nad visećim pointerom. Oslobodite samo rečnik i svako indirektno dete koje je preskočio ostaje alocirano zauvek. Nijedan redosled registra ovo ne popravlja, jer je registar ravna lista a relacija vlasništva je graf, i razmišljanje o grafu je jedini izlaz

Zašto je oslobađanje svakog HotPDF unosa u registru padalo: THPDFDictionaryObject.Destroy preskače indirektnu decu dok THPDFArrayObject.Destroy oslobađa sve što drži a THPDFIndirectObject.Destroy oslobađa svoje telo InternalObject, pa uz wrapper, niz i deljeni rečnik u jednoj ravnoj listi IndirectObjects neka memorija umire dvaput a neka nikada
Destruktori se ne slažu oko toga ko šta poseduje, a registar drži unose na nekoliko nivoa istog lanca vlasništva, pa nijedan redosled ravne liste ne može naivni Free po objektu da pretvori u ispravan teardown

Šta se računa kao posedujuća ivica u PDF grafu objekata?

Posedujuća ivica je pointer za čiji je cilj izvor odgovoran da ga uništi; referenca je sve ostalo, a teardown mora da prati prvu vrstu i ignoriše drugu. U HotPDF-u to daje tačno četiri vrste ivica: Items kod THPDFDictionaryObject, Items kod THPDFArrayObject, InternalObject iza THPDFIndirectObject, i obe polovine THPDFStreamObject, njegov Dictionary i njegov Stream payload. Vrste referenci su jednako važne, jer praćenje jedne pretvara prolazak kroz graf u beskonačnu petlju ili use-after-free. THPDFLink drži broj objekta i generaciju, što je način na koji ISO 32000-1 §7.3.10 definiše indirektnu referencu: ime za objekat koji živi negde drugde, ne sam objekat. Razrešavanje tog broja kroz registar daje čvor koji neka druga ivica već poseduje, pa CloseIndirectObjects nikada ne dereferencira linkove. Back-pointer FParent koji rečnici i nizovi drže je ista priča u drugom smeru; roditelj već poseduje dete, pa bi praćenje pointera nagore samo ponovo posetilo čvor kroz koji je prolazak već prošao. Oba se ostavljaju na miru, i komentar u izvornom kodu kaže to u jednoj liniji: linkovi i parent pointeri su reference, ne posedujuće ivice

Posedujuće ivice naspram referenci u HotPDF grafu objekata: DictionaryObject Items, ArrayObject Items, IndirectObject InternalObject i obe polovine StreamObject-a se prate i odvajaju, dok su broj objekta u THPDFLink i back-pointer FParent imena za objekte koji žive negde drugde, pa ih CloseIndirectObjects nikada ne dereferencira
Posedujuća ivica je pointer za čiji cilj izvor mora da uništi; praćenje reference umesto toga pretvorilo bi breadth-first prolazak u beskonačnu petlju ili use-after-free, pa se linkovi i parent pointeri ostavljaju na miru

Kako radi teardown u tri faze?

Faza prva je breadth-first sakupljanje. Rutina započinje radnu listu svakim unosom iz IndirectObjects, zatim za svaki čvor dodaje ciljeve njegovih posedujućih ivica, preskačući sve što je već viđeno. Set viđenih je niz sa otvorenim adresiranjem sirovih pointera hash-ovanih sa HPDFFastCacheHashInt64 preko vrednosti pointera, sa linearnim probanjem i udvostručavanjem GrowSeen kada dođe do pola pun. Ništa u toj strukturi ne alocira po čvoru, što je važno kada dokument nosi nekoliko stotina hiljada objekata. Stream payload-i idu u zasebnu listu Streams jer su TStream naslednici, a ne THPDFObject čvorovi, i oslobađaju se u sopstvenom prolazu

CloseIndirectObjects teardown u tri faze u HotPDF-u: breadth-first sakupljanje puni radnu listu iz IndirectObjects i prati samo posedujuće ivice kroz set viđenih sa otvorenim adresiranjem hash-ovan sa HPDFFastCacheHashInt64, faza dva odvaja svaku ivicu sa MarkAsFreed i dodelom nil, a faza tri oslobađa svaki čvor i stream payload tačno jednom
Presecanje ivica pre nego što se ijedan destruktor izvrši je ono što postojeće destruktore čini bezbednim za ponovno korišćenje: svaki od njih tada ne nalazi ništa u šta bi rekurzirao, pa se deljena deca, ciklusi i alijasi wrapper-telo svi ruše bez dvostrukog oslobađanja
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;   // već sakupljeno
    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;

// Faza prva: napuni iz registra, pa prati samo posedujuće ivice
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;

Faza druga je deo koji destruktore čini bezbednim za izvršavanje: svaka posedujuća ivica postavlja se na nil pre nego što se ijedan destruktor izvrši. Wrapper dobija MarkAsFreed, koji briše FInternalObject i postavlja flag koji njegov destruktor proverava prvi. Stream objektu se Dictionary i Stream dodeljuju nil. Svakom elementu rečnika Item^.Value se briše, a svaki slot niza prepisuje se nil-om. Posle ovog prolaza graf nema više nijednu ivicu, pa kada faza treća pozove Free nad svakim čvorom u Nodes i zatim nad svakim payload-om u Streams, svaki destruktor ne nalazi ništa u šta bi rekurzirao i uništava samo sebe

// Faza druga: odvoji svaku posedujuću ivicu pre bilo kakvog oslobađanja
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;

// Faza treća: svaki jedinstveni čvor i payload oslobađa se tačno jednom
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);

Pogledajte šta ta podela donosi. Rečnik koji dele dva stream objekta sakuplja se jednom, odvaja od oba i oslobađa jednom. Ciklus u kojem niz navodi sopstveni roditeljski rečnik završava se jer set viđenih odbija drugu posetu. Wrapper i njegovo telo, oba registrovana kao korenovi, dva su različita pointera u setu, pa se oba oslobađaju, a destruktor wrapper-a više ne pokušava da oslobodi telo jer je MarkAsFreed tu ivicu već oduzeo. Jedan TMemoryStream dodeljen kao payload dva stream objekta sedi u Streams tačno jednom. Nijedan od tih slučajeva ne zahteva posebno rukovanje, što je znak da je model ispravan

Kako razlikovati curenje od zadržavanja alokatora?

Tako što proverite da li se broj živih alokacija memory manager-a pomera sa opterećenjem, a ne samo njegov rezervisani otisak. Delphi memory manager drži oslobođene velike blokove pri ruci za ponovno korišćenje, pa proces koji ostane na 400 MiB posle zatvaranja dokumenta nije nužno procureo; proces čiji broj živih blokova raste za jedan po stranici po pokretanju jeste. Sonda koja je pokrenula ovu popravku bila je namerno mala: jedan THotPDF writer koji proizvodi jednu stranicu, pa tri čitača koji je učitavaju. Pošto su sva četiri oslobođena, izveštaj heap-a pokazao je tačno četiri žive alokacije od 512 KiB, po jednu za svaku instancu, a to je content stream payload koji je svaka posedovala i nikada oslobodila. Povećanje razmere učinilo je isti obrazac nepogrešivim. Dvostruko pokretanje paralelnog render pipeline-a pomerilo je cifru alociranih velikih blokova sa 384 MiB na 640 MiB, povećanje proporcionalno broju stranica koje zadržavanje alokatora ne može da objasni. Posle prepisivanja, jednostranična dijagnostika prijavila je nula alociranih velikih bajtova i nula rezervisanih kada su instance nestale. Ako lovite istu vrstu rasta u sopstvenom procesu, graf zavisnosti objekata sa zadržanim bajtovima kaže vam koji objekti drže memoriju dok je dokument otvoren; ovaj tekst je o tome kako se oslobađaju kada se zatvori

Memorijski pragovi čine regresione testove krhkim, pa isporučeni testovi broje pozive destruktora. Fikstur gradi patološki graf ručno, sa deljenim rečnikom pod dva stream-a, nizom koji sadrži i deljeni rečnik i sopstveni koren, jednim payload-om dodeljenim obama stream-ovima, korenom registrovanim dvaput, i wrapper-om čije je telo registrovano odvojeno, zatim oslobađa dokument i tvrdi jedno uništenje po jedinstvenom objektu: jedan payload, dva stream-a, dva rečnika, jedan niz, jedan wrapper, jedan broj. Pod starim kodom sva tri testa životnog veka prijavljivala su nula uništenja, što je najdirektnija moguća izjava onoga šta „ostavi izlasku procesa“ znači

Šta mora da se desi pre nego što se graf sruši?

Svaki pozadinski posao koji pozajmljuje objekte iz grafa mora prvo da se zaustavi, i svaki cache koji drži display list-e ili bitmap-e kompajlirane iz tih objekata mora da se odbaci, inače worker thread ili keširana referenca čita oslobođenu memoriju. CloseIndirectObjects zato počinje sa CancelLoadedPagePrefetch, pa invalidira cache renderovanih stranica pre nego što dotakne registar. Putanja ponovnog učitavanja u LoadFromFile i LoadFromStream i destruktor komponente obe prolaze kroz to, pa isti redosled važi bilo da zamenjujete dokument ili odlažete instancu; pravila za ponovno korišćenje jednog THotPDF-a kroz više dokumenata oslanjaju se na tu garanciju. Dva detalja u tom uvodu isplivala su samo iz pokretanja testova. Prvo, destruktor je već odložio frequency sketch-eve iza cache-a za render i display list u trenutku kada zatvara graf, pa je invalidacija zaštićena uslovom da ta polja nisu nil, umesto da se poziva bezuslovno. Drugo, InvalidateRenderedPageCache je rutina koja aktivira OnLoadedDocumentModified sa indeksom stranice -1, a pozivalac koji ponovo učitava fajl ne treba da dobije notifikaciju o izmeni za interni teardown starog dokumenta. Handler se čuva, postavlja na nil oko poziva, i vraća u finally, a regresija za ponovno učitavanje tvrdi broj notifikacija nula posle drugog LoadFromStream. Memorijska popravka koja tiho promeni ugovor o događajima je regresija sa boljim PR-om, pa dobija sopstvenu tvrdnju. Ako pokrenete paralelni render pipeline nad dokumentom a zatim ga ponovo učitate, korak otkazivanja je ono što čuva worker pool od trke sa teardown-om

Ponovno korišćenje obrasca u sopstvenom Delphi kodu

Tehnika nije specifična za PDF. Svaki Delphi model objekata u kojem destruktori nekonzistentno poseduju decu, u kojem se do istog deteta može stići iz nekoliko roditelja, ili u kojem back-pointeri i forward pointeri koegzistiraju, padaće ili curiti pod naivnim Free-om po objektu. Popravka uvek ima isti oblik: odlučite koji su pointer polja posedujuća a koji reference, sakupite zatvaranje posedujućih ivica kroz set pointera koji toleriše ponovne posete, presecite svaku ivicu, pa uništite ravnu listu. Korak presecanja je onaj koji ljudi preskaču, i on je taj koji postojeće destruktore čini bezbednim za ponovno korišćenje umesto da natera prepisivanje svake klase u modelu. Granice ipak vredi izneti jasno. Set pointera koristi adresu objekta kao identitet, pa bi objekat koji je već oslobođen i čiju je adresu preuzela sveža alokacija bio nerazlučiv; redosled garantuje da nijedan destruktor ne radi tokom sakupljanja, što to isključuje. Prolazak vidi samo četiri vrste ivica koje poznaje, pa će nova klasa koja poseduje dete kroz polje koje prolazak ne pregleda curiti to dete dok se prolazak ne nauči o njemu. I pošto se linkovi razrešavaju kroz registar umesto da se prate, objekat na koji se referencira samo linkom i koji nikada nije registrovan nije uopšte dohvatljiv ovim teardown-om; u HotPDF-u parser garantuje registraciju, ali ručno građen graf mora da poštuje isto pravilo

Sve je ovo unutar komponente, pa je vidljivi efekat za aplikaciju prosto da zatvaranje ili ponovno učitavanje dokumenta vraća njegovu memoriju, bez promene API-ja. HotPDF je nativna VCL PDF biblioteka za Delphi i C++Builder sa punim izvornim kodom; referenca API-ja i probna verzija su na stranici HotPDF Delphi PDF komponente