HotPDF Delphi Component oslobađa svaki PDF objekt koji dokument posjeduje kad se taj dokument zatvori ili ponovno učita: THotPDF.CloseIndirectObjects prolazi kroz registar objekata, skuplja svaki vlasnički brid u pointer set, odvaja sve te bridove i tek onda oslobađa svaki jedinstveni čvor i svaki payload streama točno jednom. Taj redoslijed u tri faze ono je što pušta da dijeljena djeca, ciklusi vlasništva, duplicirane registracije i alijasi wrappera i tijela svi padnu bez dvostrukog oslobađanja i bez da bilo što ostane za sobom. Prije v2.752.4 ista rutina radila je nešto mnogo jednostavnije i mnogo gore: oslobodila je izvore lijenih file streamova, pozvala Clear nad listom IndirectObjects, oslobodila kontejner liste i ostavila svaki stvarni PDF objekt da ga proces pri izlasku povrati. Komentar u tom kodu bio je iskren oko toga. Pojedinačno oslobađanje objekata izazivalo je access violation, pa je "siguran pristup" bio ne osloboditi ih uopće. Ovaj tekst je o tome zašto je pojedinačni pristup doista padao i kako izgleda teardown koji radi u jeziku s ručnim upravljanjem memorijom
Zašto ne možete jednostavno osloboditi svaki registrirani objekt?
Zato što se destruktori klasa objekata ne slažu oko toga ko što posjeduje, a registar sadrži unose na nekoliko razina istog lanca vlasništva. Prolazak kroz listu i poziv Free nad svakim unosom zato neku memoriju oslobađa dvaput, a neku nikad, ovisno o tome koje klase slučajno stoje jedna pokraj druge
Tri asimetrije u HPDFObjs.pas i HPDFDoc.pas stvaraju problem. THPDFDictionaryObject.Destroy prolazi kroz svoje Items i oslobađa vrijednost samo kad je IsIndirect False, pod pretpostavkom da neizravna djeca pripadaju registru i da će ondje 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 tijelo InternalObject. Zamislite sad registar koji drži neizravni rječnik, niz koji taj isti rječnik navodi u jednom svom slotu, i wrapper čije je tijelo također registrirano kao zaseban korijen, što je upravo ono što parser proizvodi na stvarnim datotekama. Oslobodite prvo niz i rječnik je nestao prije nego registar dođe do njega. Oslobodite wrapper i tijelo, u bilo kojem redoslijedu, i drugi poziv vrti destruktor nad visećim pokazivačem. Oslobodite samo rječnik i svako neizravno dijete koje je preskočio ostaje alocirano zauvijek. Nijedan redoslijed registra to ne popravlja, jer je registar ravna lista, a relacija vlasništva je graf, i razmišljanje o grafu jedini je izlaz
Što se računa kao vlasnički brid u PDF objektnom grafu?
Vlasnički brid je pokazivač čiji je cilj izvor odgovoran uništiti; referenca je sve ostalo, i teardown mora slijediti prvu vrstu, a ignorirati drugu. U HotPDF-u to daje točno četiri vrste bridova: Items rječnika THPDFDictionaryObject, Items niza THPDFArrayObject, InternalObject iza THPDFIndirectObject, i obje polovice THPDFStreamObject, njegov Dictionary i njegov payload Stream. Vrste referenci jednako su važne, jer praćenje jedne pretvara prolaz kroz graf u beskonačnu petlju ili use-after-free. THPDFLink drži broj objekta i generaciju, a upravo tako ISO 32000-1 §7.3.10 definira neizravnu referencu: ime za objekt koji živi drugdje, a ne sam objekt. Razrješavanje tog broja kroz registar daje čvor koji neki drugi brid već posjeduje, pa CloseIndirectObjects nikad uopće ne dereferencira linkove. Back-pointer FParent koji rječnici i nizovi drže ista je priča u drugom smjeru; roditelj već posjeduje dijete, pa bi praćenje pokazivača prema gore samo ponovno posjetilo čvor kroz koji je prolaz već prošao. Oba su ostavljena na miru, a komentar u izvornom kodu kaže to u jednom retku: linkovi i parent pokazivači su reference, a ne vlasnički bridovi
Kako radi teardown u tri faze?
Faza jedan je prikupljanje u širinu. Rutina zasijava radnu listu svakim unosom IndirectObjects, zatim za svaki čvor dodaje ciljeve vlasničkih bridova tog čvora, preskačući sve što je već viđeno. Skup viđenih je array s otvorenim adresiranjem sirovih pokazivača, hashiran s HPDFFastCacheHashInt64 nad vrijednošću pokazivača, s linearnim probanjem i udvostručujućim GrowSeen kad se napuni do pola. Ništa u toj strukturi ne alocira po čvoru, što je važno kad dokument nosi nekoliko stotina tisuća objekata. Payloadi streamova idu u zasebnu listu Streams jer su TStream potomci, a ne THPDFObject čvorovi, i oslobađaju se u vlastitom prolazu
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ć prikupljeno
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 jedan: zasijaj registrom, pa slijedi samo vlasničke bridove
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 dva onaj je dio koji destruktore čini sigurnima za izvršavanje: svaki vlasnički brid postavlja se na nil prije nego se izvrši ijedan destruktor. Wrapper dobiva MarkAsFreed, koji čisti FInternalObject i postavlja zastavicu koju njegov destruktor provjerava prvo. Stream objektu Dictionary i Stream dodjeljuju se nil. Svakom elementu rječnika čisti se Item^.Value, a svaki slot niza prepisuje se s nil. Nakon tog prolaza graf nema više nijedan brid, pa kad faza tri pozove Free nad svakim čvorom u Nodes, a zatim nad svakim payloadom u Streams, svaki destruktor ne nalazi ništa u što bi rekurzivno ušao i uništava samo sebe
// Faza dva: odvoji svaki vlasnički brid prije nego se bilo što oslobodi
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 tri: svaki jedinstveni čvor i payload oslobađa se toč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 što ta podjela donosi. Rječnik koji dijele dva stream objekta prikuplja se jednom, odvaja od obaju i oslobađa jednom. Ciklus u kojem niz navodi vlastiti roditeljski rječnik završava jer seen-set odbija drugi posjet. Wrapper i njegovo tijelo, oba registrirana kao korijeni, dva su različita pokazivača u skupu, pa se oba oslobađaju, a destruktor wrappera više ne pokušava osloboditi tijelo jer je MarkAsFreed taj brid već oduzeo. Jedan TMemoryStream dodijeljen kao payload dvama stream objektima sjedi u Streams točno jednom. Nijedan od tih slučajeva ne treba posebno rukovanje, što je znak da je model ispravan
Kako razlikovati curenje od zadržavanja alokatora?
Tako da provjerite pomiče li se broj živih alokacija memory managera s opsegom posla, a ne samo njegov rezervirani otisak. Delphi memory manager drži oslobođene velike blokove naokolo za ponovnu upotrebu, pa proces koji nakon zatvaranja dokumenta ostane na 400 MiB nije nužno procurio; proces kojemu broj živih blokova raste za jedan po stranici po prolazu jest. Sonda koja je pokrenula ovaj popravak bila je namjerno mala: jedan THotPDF writer koji proizvodi jednu stranicu, a zatim tri čitača koji je učitavaju. Nakon što su sva četiri oslobođena, heap izvještaj pokazao je točno četiri žive alokacije od 512 KiB, po jednu na instancu, a to je payload content streama koji je svaka posjedovala i nikad ga nije oslobodila. Povećanje je isti obrazac učinilo nepogrešivim. Dvostruko pokretanje paralelnog render pipelinea pomaknulo je brojku alociranih velikih blokova s 384 MiB na 640 MiB, povećanje proporcionalno broju stranica koje zadržavanje alokatora ne može objasniti. Nakon prepisivanja, dijagnostika s jednom stranicom prijavila je nula alociranih velikih bajtova i nula rezerviranih nakon što su instance nestale. Ako lovite istu vrstu rasta u vlastitom procesu, graf ovisnosti objekata sa zadržanim bajtovima kaže vam koji objekti drže memoriju dok je dokument otvoren; ovaj tekst je o tome kako se ti objekti oslobađaju kad se dokument zatvori
Memorijski pragovi čine regresijske testove krhkima, pa isporučeni testovi broje pozive destruktora. Uzorak ručno gradi patološki graf, s dijeljenim rječnikom pod dvama streamovima, nizom koji sadrži i dijeljeni rječnik i vlastiti korijen, jednim payloadom dodijeljenim obama streamovima, korijenom registriranim dvaput i wrapperom čije je tijelo registrirano zasebno, zatim oslobađa dokument i tvrdi jedno uništenje po jedinstvenom objektu: jedan payload, dva streama, dva rječnika, jedan niz, jedan wrapper, jedan broj. U starom kodu sva tri testa životnog vijeka prijavljivala su nula uništenja, što je najizravnija moguća izjava onoga što "ostavi procesu pri izlasku" znači
Što se mora dogoditi prije nego graf padne?
Svaki pozadinski posao koji posuđuje objekte iz grafa mora prvo stati, a svaki cache koji drži display liste ili bitmape kompajlirane iz tih objekata mora se odbaciti, inače radni thread ili cachirana referenca čita oslobođenu memoriju. CloseIndirectObjects zato otvara s CancelLoadedPagePrefetch, zatim invalidira cache renderiranih stranica prije nego dotakne registar. Put ponovnog učitavanja u LoadFromFile i LoadFromStream te destruktor komponente oba prolaze kroz njega, pa isti redoslijed vrijedi i kad zamjenjujete dokument i kad raspolažete instancom; pravila za ponovnu upotrebu jednog THotPDF-a kroz dokumente oslanjaju se na to jamstvo. Dvije pojedinosti u tom uvodu isplivale su tek pokretanjem testova. Prvo, destruktor je već odložio frequency sketches iza cacheva renderiranja i display liste u trenutku kad zatvara graf, pa je invalidacija čuvana provjerom da ta polja nisu nil, a ne poziva se bezuvjetno. Drugo, InvalidateRenderedPageCache je rutina koja aktivira OnLoadedDocumentModified s indeksom stranice -1, a pozivatelj koji ponovno učita datoteku ne bi trebao dobiti obavijest o uređivanju zbog internog teardowna starog dokumenta. Handler se sprema, postavlja na nil oko poziva i vraća u finally bloku, a regresijski test ponovnog učitavanja tvrdi nula obavijesti nakon drugog LoadFromStream. Memorijski popravak koji tiho promijeni ugovor o događajima regresija je s boljim PR-om, pa dobiva vlastitu tvrdnju. Ako pokrenete paralelni render pipeline nad dokumentom i zatim ga ponovno učitate, korak otkazivanja ono je što čuva worker pool od utrkivanja s teardownom
Ponovna upotreba tog obrasca u vlastitom Delphi kodu
Tehnika nije specifična za PDF. Svaki Delphi objektni model u kojem destruktori nedosljedno posjeduju djecu, u kojem se do istog djeteta može doći iz nekoliko roditelja ili u kojem back-pointeri i forward pointeri koegzistiraju, past će ili procuriti pod naivnim Free po objektu. Popravak uvijek ima isti oblik: odlučite koja su polja pokazivača vlasnička, a koja reference, prikupite zatvarač vlasničkih bridova kroz pointer set koji tolerira ponovne posjete, prerežite svaki brid, zatim uništite ravnu listu. Korak rezanja onaj je koji ljudi preskaču, i on je taj koji postojeće destruktore čini sigurnima za ponovnu upotrebu umjesto da prisili prepisivanje svake klase u modelu. Granice ipak vrijedi reći otvoreno. Pointer set koristi adresu objekta kao identitet, pa bi objekt koji je već oslobođen i čiju je adresu preuzela nova alokacija bio nerazlučiv; redoslijed jamči da nijedan destruktor ne radi tijekom prikupljanja, što je ono što to isključuje. Prolaz vidi samo četiri vrste bridova koje poznaje, pa će nova klasa koja posjeduje dijete kroz polje koje prolaz ne pregledava procuriti to dijete dok se prolaz ne nauči o njemu. A budući da se linkovi razrješavaju kroz registar, a ne prate, objekt na koji upućuje samo link i koji nikad nije registriran ovom teardownu uopće nije dohvatljiv; u HotPDF-u parser jamči registraciju, ali ručno izgrađen graf mora poštovati isto pravilo
Sve je to unutar komponente, pa je vidljiv učinak za aplikaciju jednostavno to da zatvaranje ili ponovno učitavanje dokumenta vraća njegovu memoriju, bez promjene API-ja. HotPDF je izvorna VCL PDF biblioteka za Delphi i C++Builder s punim izvornim kodom; referenca API-ja i probna verzija nalaze se na stranici HotPDF Delphi PDF komponente