Techninis straipsnis

PDF objektų grafo atlaisvinimas vieną kartą HotPDF Delphi

HotPDF Delphi Component atlaisvina kiekvieną PDF objektą, kurį dokumentas valdo, kai tas dokumentas uždaromas ar įkeliamas iš naujo: THotPDF.CloseIndirectObjects pereina objektų registrą, surenka kiekvieną nuosavybės briauną į rodyklių aibę, atkabina visas tas briaunas ir tik tada atlaisvina kiekvieną unikalų mazgą bei kiekvieną srauto turinį lygiai po vieną kartą. Būtent ta trijų fazių tvarka leidžia bendriems vaikams, nuosavybės ciklams, pasikartojančioms registracijoms ir apvalkalo bei kūno aliasams visiems nusileisti be dvigubo atlaisvinimo ir nieko nepaliekant. Iki v2.752.4 ta pati rutina darė ką nors kur kas paprasčiau ir kur kas blogiau: atlaisvindavo tingius failo srauto šaltinius, iškviečia Clear IndirectObjects sąrašui, atlaisvindavo patį sąrašo konteinerį, o kiekvieną tikrą PDF objektą palikdavo proceso pabaigai susigrąžinti. Komentaras tame kode tai irgi pripažino atvirai. Atskirų objektų atlaisvinimas keldavo prieigos pažeidimus, tad „saugus kelias“ buvo jų visai neatlaisvinti. Šis įrašas apie tai, kodėl atskiras kelias tikrai krisdavo ir kaip atrodo veikiantis ardymas kalboje su rankiniu atminties valdymu

Kodėl negalima tiesiog iškviesti Free kiekvienam registruotam objektui?

Nes objektų klasių destruktoriai nesutaria, kas kam priklauso, o registre yra įrašų keliuose tos pačios nuosavybės grandinės lygiuose. Perėjimas per sąrašą ir Free kvietimas kiekvienam įrašui todėl vieną atmintį atlaisvina du kartus, o kitos neatlaisvina niekada – priklausomai nuo to, kurios klasės atsitiktinai atsiduria viena šalia kitos

Problemą sukuria trys asimetrijos HPDFObjs.pas ir HPDFDoc.pas failuose. THPDFDictionaryObject.Destroy pereina savo Items ir atlaisvina reikšmę tik tada, kai IsIndirect yra False, remdamasis prielaida, kad netiesioginiai vaikai priklauso registrui ir bus atlaisvinti ten. THPDFArrayObject.Destroy tokio skirtumo nedaro ir atlaisvina kiekvieną laikomą elementą. O THPDFIndirectObject.Destroy – apvalkalas, nešantis objekto numerį – atlaisvina savo InternalObject kūną. Dabar įsivaizduokite registrą, kuriame yra netiesioginis žodynas, masyvas, įvardijantis tą patį žodyną vienoje iš savo vietų, ir apvalkalas, kurio kūnas taip pat užregistruotas kaip atskira šaknis – būtent tai analizatorius ir pagamina tikruose failuose. Atlaisvinkite masyvą pirmą, ir žodyno jau nebėra, kai registras jį pasiekia. Atlaisvinkite apvalkalą ir kūną bet kuria tvarka, ir antras kvietimas paleidžia destruktorių ant kabančios rodyklės. Atlaisvinkite vien žodyną, ir kiekvienas netiesioginis vaikas, kurį jis praleido, lieka alokuotas amžinai. Jokia registro tvarka to neištaiso, nes registras yra plokščias sąrašas, o nuosavybės santykis yra grafas, ir vienintelė išeitis yra mąstyti apie grafą

Kodėl kiekvieno HotPDF registro įrašo atlaisvinimas krisdavo: THPDFDictionaryObject.Destroy praleidžia netiesioginius vaikus, o THPDFArrayObject.Destroy atlaisvina viską, ką laiko, ir THPDFIndirectObject.Destroy atlaisvina savo InternalObject kūną, tad kai viename plokščiame IndirectObjects sąraše yra apvalkalas, masyvas ir bendras žodynas, viena atmintis miršta du kartus, o kita niekada
Destruktoriai nesutaria, kas kam priklauso, o registre yra įrašų keliuose tos pačios nuosavybės grandinės lygiuose, tad jokia plokščio sąrašo tvarka nepavers naivaus kiekvieno objekto Free teisingu ardymu

Kas PDF objektų grafe laikoma nuosavybės briauna?

Nuosavybės briauna yra rodyklė, kurios taikinį šaltinis yra atsakingas sunaikinti; nuoroda yra visa kita, ir ardymas turi sekti pirmąją rūšį ir ignoruoti antrąją. HotPDF tai duoda lygiai keturias briaunų rūšis: THPDFDictionaryObject Items, THPDFArrayObject Items, InternalObject už THPDFIndirectObject ir abi THPDFStreamObject pusės – jo Dictionary ir jo Stream turinys. Nuorodų rūšys svarbios tiek pat, nes vienos jų sekimas grafo ėjimą paverčia nesibaigiančia kilpa arba naudojimu po atlaisvinimo. THPDFLink laiko objekto numerį ir kartą, o būtent taip ISO 32000-1 §7.3.10 apibrėžia netiesioginę nuorodą: vardą objektui, kuris gyvena kitur, o ne patį objektą. To numerio išsprendimas per registrą duoda mazgą, kurį jau valdo kokia nors kita briauna, tad CloseIndirectObjects nuorodų apskritai nedereferencina. FParent atgalinė rodyklė, kurią laiko žodynai ir masyvai, yra ta pati istorija kita kryptimi; tėvas jau valdo vaiką, tad ėjimas rodykle aukštyn tik sugrąžintų į mazgą, per kurį ėjimas jau buvo praėjęs. Abu paliekami ramybėje, ir komentaras šaltinyje tai pasako viena eilute: nuorodos ir tėvų rodyklės yra nuorodos, o ne nuosavybės briaunos

Nuosavybės briaunos ir nuorodos HotPDF objektų grafe: DictionaryObject Items, ArrayObject Items, IndirectObject InternalObject ir abi StreamObject pusės yra sekamos ir atkabinamos, o THPDFLink objekto numeris ir FParent atgalinė rodyklė yra vardai objektams, kurie gyvena kitur, tad CloseIndirectObjects jų niekada nedereferencina
Nuosavybės briauna yra rodyklė, kurios taikinį šaltinis turi sunaikinti; nuorodos sekimas vietoj to paverstų ėjimą į plotį nesibaigiančia kilpa arba naudojimu po atlaisvinimo, tad nuorodos ir tėvų rodyklės paliekamos ramybėje

Kaip veikia trijų fazių ardymas?

Pirmoji fazė – rinkimas į plotį. Rutina užpildo darbų sąrašą kiekvienu IndirectObjects įrašu, tada kiekvienam mazgui prideda to mazgo nuosavybės briaunų taikinius, praleisdama viską, kas jau matyta. Matytų aibė yra atviro adresavimo masyvas neapdorotų rodyklių, maišuojamų su HPDFFastCacheHashInt64 pagal rodyklės reikšmę, su linijiniu zondavimu ir dvigubinamu GrowSeen, kai ji pasiekia pusę užpildymo. Niekas toje struktūroje nealokuoja atskirai kiekvienam mazgui, ir tai svarbu, kai dokumentas neša kelis šimtus tūkstančių objektų. Srautų turiniai patenka į atskirą Streams sąrašą, nes jie yra TStream palikuonys, o ne THPDFObject mazgai, ir atlaisvinami savo atskiru praėjimu

Trijų fazių CloseIndirectObjects ardymas HotPDF: rinkimas į plotį užpildo darbų sąrašą iš IndirectObjects ir seka tik nuosavybės briaunas per atviro adresavimo matytų aibę, maišuojamą su HPDFFastCacheHashInt64, antroji fazė atkabina kiekvieną briauną su MarkAsFreed ir nil priskyrimu, o trečioji fazė atlaisvina kiekvieną mazgą ir srauto turinį lygiai po kartą
Briaunų nukirpimas prieš paleidžiant bet kurį destruktorių yra tai, kas esamus destruktorius padaro saugius naudoti iš naujo: kiekvienas jų tada neranda į ką rekursyviai leistis, tad bendri vaikai, ciklai ir apvalkalo bei kūno aliasai visi nusileidžia be dvigubo atlaisvinimo
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;   // jau surinkta
    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;

// Pirmoji fazė: sekla yra registras, tada sekamos tik nuosavybės briaunos
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;

Antroji fazė yra ta dalis, kuri destruktorius padaro saugius paleisti: kiekviena nuosavybės briauna nustatoma į nil dar prieš įvykdant bet kurį destruktorių. Apvalkalas gauna MarkAsFreed, kuri išvalo FInternalObject ir nustato vėliavėlę, kurią jo destruktorius tikrina pirmiausia. Srauto objektui Dictionary ir Stream priskiriami nil. Kiekvienam žodyno elementui Item^.Value išvaloma, o kiekviena masyvo vieta perrašoma nil. Po šio praėjimo grafas nebeturi jokių briaunų, tad kai trečioji fazė iškviečia Free kiekvienam Nodes mazgui ir tada kiekvienam Streams turiniui, kiekvienas destruktorius neranda į ką rekursyviai leistis ir sunaikina tik save patį

// Antroji fazė: atkabinama kiekviena nuosavybės briauna dar nieko neatlaisvinus
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;

// Trečioji fazė: kiekvienas unikalus mazgas ir turinys atlaisvinami lygiai po kartą
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);

Pažiūrėkite, ką tas padalijimas duoda. Žodynas, bendras dviem srauto objektams, surenkamas vieną kartą, atkabinamas nuo abiejų ir atlaisvinamas vieną kartą. Ciklas, kai masyvas įvardija savo paties tėvinį žodyną, baigiasi, nes matytų aibė atsisako antro apsilankymo. Apvalkalas ir jo kūnas, abu užregistruoti kaip šaknys, yra dvi skirtingos rodyklės aibėje, tad abu atlaisvinami, o apvalkalo destruktorius nebe bando atlaisvinti kūno, nes MarkAsFreed tą briauną jau atėmė. Vienas TMemoryStream, priskirtas kaip dviejų srauto objektų turinys, Streams sąraše yra lygiai vieną kartą. Nė vienam iš tų atvejų nereikia specialaus apdorojimo, ir tai yra ženklas, kad modelis teisingas

Kaip atskirti nuotėkį nuo alokatoriaus pasilaikymo?

Tikrindami, ar atminties valdytojo gyvų alokacijų skaičius kinta kartu su darbo krūviu, o ne vien jo rezervuota talpa. Delphi atminties valdytojas atlaisvintus didelius blokus pasilieka pakartotiniam naudojimui, tad procesas, kuris po dokumento uždarymo tebelaikosi ties 400 MiB, nebūtinai nutekėjo; procesas, kurio gyvų blokų skaičius kiekvieno paleidimo metu pakyla po vieną už puslapį, nutekėjo. Šią pataisą išprovokavęs zondas buvo sąmoningai mažas: vienas THotPDF rašytojas, pagaminantis vieną puslapį, o tada trys skaitytuvai, jį įkeliantys. Visus keturis atlaisvinus, krūvos ataskaita rodė lygiai keturias gyvas 512 KiB alokacijas – po vieną kiekvienam egzemplioriui – o tai yra turinio srauto turinys, kurį kiekvienas jų valdė ir niekada neatlaisvino. Padidinus mastą tas pats modelis tapo neabejotinas. Du kartus paleidus lygiagretų atvaizdavimo konvejerį, didelių blokų alokuota reikšmė pakilo nuo 384 MiB iki 640 MiB – padidėjimas, proporcingas puslapių skaičiui, kurio alokatoriaus pasilaikymas paaiškinti negali. Po perrašymo vieno puslapio diagnostika pranešė nulį alokuotų didelių baitų ir nulį rezervuotų, kai egzempliorių jau nebebuvo. Jei savo procese medžiojate tokias pat augimo apraiškas, objektų priklausomybių grafas su pasilaikomais baitais pasako, kurie objektai laiko atmintį, kol dokumentas atidarytas; šis įrašas apie tai, kaip jie atlaisvinami, kai jis uždaromas

Atminties slenksčiai daro regresijos testus trapius, tad išleisti testai vietoj to skaičiuoja destruktorių kvietimus. Testinis failas patologinį grafą pastato rankomis: bendras žodynas po dviem srautais, masyvas, talpinantis ir tą bendrą žodyną, ir savo paties šaknį, vienas turinys, priskirtas abiem srautams, šaknis, užregistruota du kartus, ir apvalkalas, kurio kūnas užregistruotas atskirai; tada dokumentas atlaisvinamas ir tvirtinama po vieną sunaikinimą kiekvienam unikaliam objektui: vienas turinys, du srautai, du žodynai, vienas masyvas, vienas apvalkalas, vienas skaičius. Su senuoju kodu visi trys gyvavimo trukmės testai pranešė nulį sunaikinimų – tai tiesiausias įmanomas teiginys, ką reiškia „palikti proceso pabaigai“

Kas turi įvykti dar prieš grafui nusileidžiant?

Bet koks foninis darbas, skolinantis objektus iš grafo, turi sustoti pirmiausia, o bet koks podėlis, laikantis iš tų objektų sukompiliuotus ekrano sąrašus ar bitų mapas, turi būti išmestas, kitaip darbuotojo gija ar podėlyje laikoma nuoroda skaito atlaisvintą atmintį. CloseIndirectObjects todėl pradedamas CancelLoadedPagePrefetch, tada prieš liesdamas registrą invaliduoja atvaizduotų puslapių podėlį. Pakartotinio įkėlimo kelias LoadFromFile ir LoadFromStream viduje ir komponento destruktorius abu eina per jį, tad ta pati tvarka galioja, ar keičiate dokumentą, ar atsikratote egzemplioriaus; vieno THotPDF naudojimo iš naujo taisyklės remiasi ta garantija. Du niuansai toje įžangoje išryškėjo tik paleidus testus. Pirma, destruktorius jau yra atsikratęs dažnių eskizų, esančių už atvaizdavimo ir ekrano sąrašų podėlių, tuo metu, kai uždaro grafą, tad invalidavimas saugomas sąlyga, kad tie laukai nelygūs nil, o ne kviečiamas besąlygiškai. Antra, InvalidateRenderedPageCache yra ta rutina, kuri sukelia OnLoadedDocumentModified su puslapio indeksu -1, o kviečiantysis, įkeliantis failą iš naujo, neturi gauti redagavimo pranešimo apie senojo dokumento vidinį ardymą. Apdorojimo blokas išsaugomas, nustatomas į nil aplink kvietimą ir atstatomas finally bloke, o pakartotinio įkėlimo regresija tvirtina nulinį pranešimų skaičių po antro LoadFromStream. Atminties pataisa, kuri tyliai pakeičia įvykių kontraktą, yra regresija su geresniu PR, tad jai skiriamas atskiras tvirtinimas. Jei paleidžiate lygiagretų atvaizdavimo konvejerį prieš dokumentą ir tada jį įkeliate iš naujo, atšaukimo žingsnis ir neleidžia darbuotojų baseinui lenktyniauti su ardymu

Šio šablono naudojimas savo Delphi kode

Ši technika nėra specifinė PDF. Bet kuris Delphi objektų modelis, kuriame destruktoriai vaikus valdo nenuosekliai, kuriame tą patį vaiką galima pasiekti iš kelių tėvų arba kuriame atgalinės ir pirmyn nukreiptos rodyklės sugyvena, kris arba nutekės su naiviu Free kiekvienam objektui. Pataisa visada tos pačios formos: nuspręskite, kurie rodyklių laukai yra nuosavybės, o kurie nuorodos, surinkite nuosavybės briaunų uždarinį per rodyklių aibę, toleruojančią pakartotinius apsilankymus, nukirpkite kiekvieną briauną, tada sunaikinkite plokščią sąrašą. Kirpimo žingsnis yra tas, kurį žmonės praleidžia, ir būtent jis esamus destruktorius padaro saugius naudoti iš naujo, o ne verčia perrašyti kiekvieną modelio klasę. Vis dėlto ribas verta pasakyti tiesiai. Rodyklių aibė kaip tapatybę naudoja objekto adresą, tad objektas, kuris jau buvo atlaisvintas ir kurio adresą perėmė nauja alokacija, būtų neatskiriamas; tvarka garantuoja, kad surinkimo metu neįvyksta joks destruktorius, ir būtent tai tą galimybę atmeta. Ėjimas mato tik tas keturias briaunų rūšis, kurias pažįsta, tad nauja klasė, valdanti vaiką per lauką, kurio ėjimas neapžiūri, tą vaiką nutekins tol, kol ėjimas nebus išmokytas jį apžiūrėti. O kadangi nuorodos sprendžiamos per registrą, o ne sekamos, objektas, į kurį nurodo tik nuoroda ir kuris niekada nebuvo užregistruotas, šiam ardymui apskritai nepasiekiamas; HotPDF registraciją garantuoja analizatorius, bet rankomis sukurtas grafas turi gerbti tą pačią taisyklę

Viso to yra komponento viduje, tad matomas poveikis programai yra tiesiog toks, kad dokumento uždarymas ar pakartotinis įkėlimas grąžina jo atmintį, ir jokių API pakeitimų. HotPDF yra savoji VCL PDF biblioteka, skirta Delphi ir C++Builder, su visu šaltinio kodu; API nuoroda ir bandomoji versija yra HotPDF Delphi PDF komponento puslapyje