HotPDF Delphi Component vapauttaa jokaisen asiakirjan omistaman PDF-objektin, kun asiakirja suljetaan tai ladataan uudelleen: THotPDF.CloseIndirectObjects käy objektirekisterin läpi, kerää jokaisen omistussärmän osoitinsettiin, irrottaa kaikki nuo särmät ja vasta sitten vapauttaa jokaisen yksittäisen solmun ja jokaisen stream-hyötykuorman täsmälleen kerran. Juuri tuo kolmivaiheinen järjestys saa jaetut lapset, omistussykit, tuplarekisteröinnit ja wrapperin ja rungon aliakset laskeutumaan ilman tuplavapautusta ja jättämättä mitään jäljelle. Ennen versiota v2.752.4 sama rutiini teki jotain paljon yksinkertaisempaa ja paljon huonompaa: se vapautti laiskat tiedostostream-lähteet, kutsui IndirectObjects-listalle Clear-metodia, vapautti listan säiliön ja jätti jokaisen varsinaisen PDF-objektin prosessin lopetuksen siivottavaksi. Tuon koodin kommentti oli siitä rehellinen. Objektien vapauttaminen yksitellen aiheutti access violation -virheitä, joten ”turvallinen lähestymistapa” oli olla vapauttamatta niitä lainkaan. Tämä kirjoitus kertoo, miksi yksittäinen lähestymistapa todella kaatui, ja miltä toimiva purku näyttää kielessä, jossa muisti hallitaan käsin
Miksi et voi vain Free-kutsua jokaiseen rekisteröityyn objektiin?
Koska objektiluokkien destruktorit ovat eri mieltä siitä, kuka omistaa mitäkin, ja rekisteri sisältää merkintöjä saman omistusketjun usealta tasolta. Listan läpikäynti ja Free-kutsu jokaiselle merkinnälle vapauttaa siksi osan muistista kahdesti ja osan ei koskaan, riippuen siitä mitkä luokat sattuvat olemaan vierekkäin
Ongelma syntyy kolmesta epäsymmetriasta HPDFObjs.pas- ja HPDFDoc.pas -tiedostoissa. THPDFDictionaryObject.Destroy käy Items-kokoelmansa läpi ja vapauttaa arvon vain kun IsIndirect on False, olettaen että epäsuorat lapset kuuluvat rekisterille ja vapautetaan siellä. THPDFArrayObject.Destroy ei tee tuollaista eroa ja vapauttaa jokaisen hallussaan olevan alkion. Ja THPDFIndirectObject.Destroy, objektinumeron kantava wrapper, vapauttaa InternalObject-runkonsa. Ajatellaanpa rekisteriä, jossa on epäsuora sanakirja, taulukko joka luettelee saman sanakirjan yhdessä paikassaan, ja wrapper, jonka runko on rekisteröity myös erillisenä juurena, eli täsmälleen se mitä jäsennin tuottaa oikeilla tiedostoilla. Vapauta taulukko ensin, niin sanakirja on poissa ennen kuin rekisteri yltää siihen. Vapauta wrapper ja runko kummassa tahansa järjestyksessä, niin toinen kutsu ajaa destruktorin roikkuvalle osoittimelle. Vapauta pelkkä sanakirja, niin mikä tahansa sen ohittama epäsuora lapsi jää varatuksi ikuisesti. Mikään rekisterin järjestys ei korjaa tätä, koska rekisteri on lattea lista ja omistussuhde on graafi, ja ainoa ulospääsy on graafin päättely
Mikä lasketaan omistussärmäksi PDF-objektigraafissa?
Omistussärmä on osoitin, jonka kohteen lähteen on vastattava tuhoamisesta; viittaus on mitä tahansa muuta, ja purun on seurattava ensimmäistä lajia ja jätettävä toinen huomiotta. HotPDF:ssä se antaa täsmälleen neljä särmälajia: THPDFDictionaryObject-olion Items-kokoelma, THPDFArrayObject-olion Items-kokoelma, THPDFIndirectObject-olion takana oleva InternalObject sekä THPDFStreamObject-olion molemmat puoliskot, sen Dictionary ja sen Stream-hyötykuorma. Viittauslajeilla on yhtä paljon väliä, koska niitä seuraamalla graafikävelystä tulee ikuinen silmukka tai use-after-free. THPDFLink sisältää objektinumeron ja sukupolven, ja juuri niin ISO 32000-1 §7.3.10 määrittelee epäsuoran viittauksen: nimi objektille, joka elää muualla, ei objektia itse. Tuon numeron ratkaiseminen rekisteristä antaa solmun, jonka jokin toinen särmä omistaa jo, joten CloseIndirectObjects ei koskaan kohdista linkkeihin lainkaan. Sanakirjojen ja taulukoiden pitämä FParent-takaisinosoitin on sama tarina toiseen suuntaan; vanhempi omistaa lapsen jo, joten ylöspäin seuraaminen vain palaisi solmuun, jonka kävely on jo käynyt läpi. Molemmat jätetään rauhaan, ja lähdekoodin kommentti sanoo sen yhdellä rivillä: linkit ja vanhempiosoittimet ovat viittauksia, eivät omistussärmiä
Miten kolmivaiheinen purku toimii?
Vaihe yksi on leveyssuuntainen keräys. Rutiini alustaa työlistan jokaisella IndirectObjects-merkinnällä ja lisää sitten jokaiselle solmulle kyseisen solmun omistussärmien kohteet ohittaen kaiken jo nähdyn. Nähty-joukko on avoimenosoituksen taulukko raakoja osoittimia, jotka tiivistetään osoittimen arvosta HPDFFastCacheHashInt64-funktiolla, lineaarisella luotauksella ja kaksinkertaistavalla GrowSeen-kasvatuksella, kun se täyttyy puoliksi. Mikään tuossa rakenteessa ei varaa tilaa solmua kohti, millä on väliä kun asiakirja kuljettaa muutaman sadantuhannen objektin. Stream-hyötykuormat menevät erilliseen Streams-listaan, koska ne ovat TStream-jälkeläisiä eivätkä THPDFObject-solmuja, ja ne vapautetaan omalla kierroksellaan
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; // jo kerätty
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;
// Vaihe yksi: alusta rekisterillä ja seuraa sitten vain omistussärmiä
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;
Vaihe kaksi on se osa, joka tekee destruktoreista turvallisia ajaa: jokainen omistussärmä asetetaan nilliksi ennen kuin yksikään destruktori suoritetaan. Wrapper saa MarkAsFreed-kutsun, joka tyhjentää FInternalObject-kentän ja asettaa lipun, jonka sen destruktori tarkistaa ensimmäiseksi. Stream-objektin Dictionary- ja Stream-kentät saavat nillin. Jokaisen sanakirja-alkion Item^.Value tyhjennetään ja jokainen taulukon paikka ylikirjoitetaan nillillä. Tämän kierroksen jälkeen graafissa ei ole yhtään särmää jäljellä, joten kun vaihe kolme kutsuu Free-metodia jokaiselle Nodes-listan solmulle ja sitten jokaiselle Streams-listan hyötykuormalle, jokainen destruktori ei löydä mitään mihin rekursiivisesti mennä ja tuhoaa vain itsensä
// Vaihe kaksi: irrota jokainen omistussärmä ennen kuin vapautetaan mitää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;
// Vaihe kolme: jokainen yksittäinen solmu ja hyötykuorma vapautetaan tasan kerran
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);
Katso, mitä jaottelu ostaa. Kahden stream-objektin jakama sanakirja kerätään kerran, irrotetaan molemmista ja vapautetaan kerran. Sykli, jossa taulukko luettelee oman vanhempisanakirjansa, päättyy, koska nähty-joukko kieltäytyy toisesta vierailusta. Wrapper ja sen runko, molemmat juuriksi rekisteröityinä, ovat joukossa kaksi erillistä osoitinta, joten molemmat vapautetaan, eikä wrapperin destruktori enää yritä vapauttaa runkoa, koska MarkAsFreed otti sen särmän jo pois. Yksi ainoa TMemoryStream, joka on sijoitettu kahden stream-objektin hyötykuormaksi, on Streams-listassa täsmälleen kerran. Mikään noista tapauksista ei tarvitse erityiskäsittelyä, mikä on merkki siitä että malli on oikea
Miten vuoto erotetaan allokaattorin pidättämästä muistista?
Tarkistamalla, liikkuuko muistinhallinnan elävien varausten määrä kuormituksen mukana eikä vain sen varattu jalanjälki. Delphin muistinhallinta pitää vapautetut suuret lohkot tallessa uudelleenkäyttöä varten, joten prosessi, joka jää 400 MiB:iin asiakirjan sulkemisen jälkeen, ei välttämättä ole vuotanut; prosessi, jonka elävien lohkojen määrä nousee yhdellä sivua ja ajokertaa kohti, on. Tätä korjausta ohjannut koetin oli tarkoituksella pieni: yksi THotPDF-kirjoittaja tuotti yhden sivun, minkä jälkeen kolme lukijaa latasi sen. Kun kaikki neljä oli vapautettu, kehäraportti näytti täsmälleen neljää elävää 512 KiB:n varausta, yhtä instanssia kohti, ja se on sisältöstreamin hyötykuorma, jonka kukin omisti eikä koskaan vapauttanut. Suurentaminen teki samasta kuviosta kiistattoman. Rinnakkaisen renderöintiputken ajaminen kahdesti siirsi suurten lohkojen varatun luvun 384 MiB:stä 640 MiB:iin, mikä on sivumäärään verrannollinen kasvu, jota allokaattorin pidättäminen ei selitä. Uudelleenkirjoituksen jälkeen yksisivuinen diagnostiikka ilmoitti nolla varattua suurta tavua ja nolla varattua, kun instanssit olivat poissa. Jos metsästät samanlaista kasvua omassa prosessissasi, objektien riippuvuusgraafi ja pidätetyt tavut kertoo, mitkä objektit pitävät muistia hallussaan asiakirjan ollessa auki; tämä kirjoitus käsittelee niiden vapautusta sen sulkeutuessa
Muistin kynnysarvot tekevät hauraita regressiotestejä, joten toimitetut testit laskevat sen sijaan destruktorikutsuja. Fixture rakentaa patologisen graafin käsin: jaettu sanakirja kahden streamin alla, taulukko joka sisältää sekä jaetun sanakirjan että oman juurensa, yksi hyötykuorma sijoitettuna molempiin streameihin, juuri rekisteröitynä kahdesti ja wrapper, jonka runko on rekisteröity erikseen, minkä jälkeen se vapauttaa asiakirjan ja varmistaa yhden tuhoamisen jokaista yksittäistä objektia kohti: yksi hyötykuorma, kaksi streamia, kaksi sanakirjaa, yksi taulukko, yksi wrapper, yksi numero. Vanhalla koodilla kaikki kolme elinikätestiä ilmoittivat nolla tuhoamista, mikä on suorin mahdollinen ilmaisu siitä, mitä ”jätä prosessin lopetettavaksi” tarkoittaa
Mitä on tapahduttava ennen kuin graafi laskeutuu?
Kaiken taustatyön, joka lainaa objekteja graafista, on loputtava ensin, ja jokainen välimuisti, joka pitää hallussaan noista objekteista koottuja näyttölistoja tai bittikarttoja, on pudotettava, muuten työsäie tai välimuistiin jäänyt viittaus lukee vapautettua muistia. CloseIndirectObjects avautuukin CancelLoadedPagePrefetch-kutsulla ja mitätöi sitten renderöityjen sivujen välimuistin ennen kuin se koskee rekisteriin. LoadFromFile- ja LoadFromStream -kutsujen uudelleenlatauspolku ja komponentin destruktori kulkevat molemmat sen kautta, joten sama järjestys pätee riippumatta siitä, korvaatko asiakirjan vai hävitätkö instanssin; säännöt yhden THotPDF:n uudelleenkäytöstä asiakirjojen välillä nojaavat tuohon takeeseen. Kaksi yksityiskohtaa tuossa alkusoitossa nousi esiin vasta testien ajamisesta. Ensimmäinen: destruktori on jo hävittänyt renderöinti- ja näyttölistavälimuistien taustalla olevat taajuusluonnokset siihen mennessä kun se sulkee graafin, joten mitätöinti on vartioitu noiden kenttien ei-nil-arvolla sen sijaan että se kutsuttaisiin ehdoitta. Toinen: InvalidateRenderedPageCache on se rutiini, joka laukaisee OnLoadedDocumentModified-tapahtuman sivun indeksillä -1, eikä tiedoston uudelleenlataavan kutsujan pitäisi saada muokkausilmoitusta vanhan asiakirjan sisäisestä purusta. Käsittelijä tallennetaan, asetetaan nilliksi kutsun ajaksi ja palautetaan finally-lohkossa, ja uudelleenlatauksen regressio varmistaa ilmoitusten määräksi nollan toisen LoadFromStream-kutsun jälkeen. Muistinkorjaus, joka hiljaa muuttaa tapahtumasopimusta, on regressio paremmalla PR:llä, joten se saa oman väitteensä. Jos ajat rinnakkaista renderöintiputkea asiakirjaa vasten ja lataat sen sitten uudelleen, peruutusvaihe on se, mikä estää työsäiepoolia kilpailemasta purun kanssa
Kuvion uudelleenkäyttö omassa Delphi-koodissasi
Tekniikka ei ole PDF-kohtainen. Mikä tahansa Delphi-objektimalli, jossa destruktorit omistavat lapsia epäjohdonmukaisesti, jossa sama lapsi on tavoitettavissa useasta vanhemmasta tai jossa takaisin- ja eteenpäinosoittimet elävät rinnakkain, kaatuu tai vuotaa naiivilla objektikohtaisella Free-kutsulla. Korjaus on aina samanmuotoinen: päätä, mitkä osoitinkentät ovat omistavia ja mitkä viittauksia, kerää omistussärmien sulkeuma sellaisen osoitinjoukon kautta, joka sietää uudelleenkäyntejä, katkaise jokainen särmä ja tuhoa sitten lattea lista. Katkaisuvaihe on se, jonka ihmiset ohittavat, ja juuri se tekee olemassa olevista destruktoreista turvallisia käyttää uudelleen sen sijaan että joka luokka mallissa pitäisi kirjoittaa uudelleen. Rajat kannattaa silti todeta suoraan. Osoitinjoukko käyttää identiteettinä objektin osoitetta, joten objekti, joka on jo vapautettu ja jonka osoitteen tuore varaus on ottanut uudelleen käyttöön, olisi erottamaton; järjestys takaa, ettei yksikään destruktori aja keräyksen aikana, ja juuri se sulkee tuon pois. Kävely näkee vain ne neljä särmälajia, jotka se tuntee, joten uusi luokka, joka omistaa lapsen kentän kautta jota kävely ei tutki, vuotaa tuon lapsen kunnes kävelylle opetetaan se. Ja koska linkit ratkaistaan rekisterin kautta eikä niitä seurata, objekti, johon viitataan vain linkillä eikä jota koskaan rekisteröity, ei ole tämän purun tavoitettavissa lainkaan; HotPDF:ssä jäsennin takaa rekisteröinnin, mutta käsin rakennetun graafin on noudatettava samaa sääntöä
Kaikki tämä on komponentin sisällä, joten sovellukselle näkyvä vaikutus on yksinkertaisesti se, että asiakirjan sulkeminen tai uudelleenlataus palauttaa sen muistin ilman rajapintamuutoksia. HotPDF on natiivi VCL-PDF-kirjasto Delphille ja C++Builderille täydellä lähdekoodilla; rajapintaviite ja kokeiluversio ovat HotPDF Delphi PDF -komponentin sivulla