A HotPDF Delphi Component felszabadítja a dokumentum minden PDF-objektumát, amikor az a dokumentum lezárul vagy újratöltődik: a THotPDF.CloseIndirectObjects bejárja az objektumregisztrációt, minden birtokló élet összegyűjt egy pointerhalmazba, leválasztja mindazokat az éleket, és csak ezután szabadítja fel minden egyedi csomópontot és minden stream-hasznos terhet pontosan egyszer. Ez a háromfázisú sorrend az, ami miatt a megosztott gyerekek, a birtoklási ciklusok, a duplikált regisztrációk és a wrapper/törzs álnevek mind lejönnek dupla free nélkül és úgy, hogy semmi nem marad hátra. A v2.752.4 előtt ugyanez a rutin valami sokkal egyszerűbbet és sokkal rosszabbat tett: elengedte a lusta fájlstream-forrásokat, meghívta a Clear-t az IndirectObjects listán, felszabadította a lista konténerét, és minden tényleges PDF-objektumot a folyamat kilépésére hagyott, hogy az szedje össze. A kódban lévő komment erről is őszinte volt. Az objektumok egyenkénti felszabadítása access violationöket okozott, így a „biztonságos megközelítés” az volt, hogy egyáltalán nem szabadítjuk fel őket. Ez a bejegyzés arról szól, miért is crashelt valóban az egyenkénti megközelítés, és hogyan néz ki egy működő lebontás egy manuális memóriakezelésű nyelvben
Miért nem lehet egyszerűen Free-olni minden regisztrált objektumot?
Mert az objektumosztályok destruktorai nem értenek egyet abban, ki mit birtokol, a regisztráció pedig ugyanazon birtoklási lánc több szintjén is tartalmaz bejegyzéseket. A listán végigmenve és minden bejegyzésen Free-t hívva ezért néhány memória kétszer szabadul fel, néhány pedig soha, attól függően, hogy épp mely osztályok kerülnek egymás mellé
A HPDFObjs.pas és a HPDFDoc.pas három aszimmetriája hozza létre a problémát. A THPDFDictionaryObject.Destroy bejárja az Items-ét, és egy értéket csak akkor szabadít fel, ha az IsIndirect hamis, abból a feltevésből kiindulva, hogy az indirekt gyerekek a regisztrációhoz tartoznak, és ott fognak felszabadulni. A THPDFArrayObject.Destroy nem tesz ilyen különbséget, és minden elemet felszabadít, amit tart. A THPDFIndirectObject.Destroy, az objektumszámot hordozó wrapper pedig felszabadítja a InternalObject törzsét. Vegyünk most egy regisztrációt, ami tartalmaz egy indirekt szótárat, egy tömböt, ami ugyanazt a szótárat listázza az egyik helyén, és egy wrappert, aminek a törzse külön gyökérként is regisztrálva van — pontosan ezt állítja elő a parser a valódi fájlokon. Szabadítsd fel először a tömböt, és a szótár már eltűnt, mire a regisztráció odaér. Szabadítsd fel a wrappert és a törzset, bármelyik sorrendben, és a második hívás egy lógó pointeren futtat destruktort. Szabadítsd fel egyedül a szótárat, és minden kihagyott indirekt gyereke örökre allokálva marad. Ezt a regisztráció semmilyen sorrendje nem javítja, mert a regisztráció egy lapos lista, a birtoklási viszony pedig egy gráf, és az egyetlen kiút a gráfban való gondolkodás
Mi számít birtokló élnek egy PDF-objektumgráfban?
Egy birtokló él egy pointer, aminek a céljáért a forrás felelős a megsemmisítésért; a referencia minden más, és a lebontásnak az első fajtát kell követnie, a másodikat figyelmen kívül hagynia. A HotPDF-ben ez pontosan négy élfajtát ad: egy THPDFDictionaryObject Items-ét, egy THPDFArrayObject Items-ét, egy THPDFIndirectObject mögötti InternalObject-et, és egy THPDFStreamObject mindkét felét, a Dictionary-jét és a Stream hasznos terhét. A referenciafajták legalább annyit számítanak, mert az egyik követése egy gráfbejárást végtelen ciklussá vagy use-after-free-vé változtat. Egy THPDFLink egy objektumszámot és generációt hordoz, ami pontosan az, ahogy az ISO 32000-1 §7.3.10 definiálja az indirekt hivatkozást: egy név egy máshol élő objektumra, nem maga az objektum. Ha azt a számot a regisztráción keresztül feloldjuk, egy olyan csomópontot kapunk, amit valamely másik él már birtokol, így a CloseIndirectObjects egyáltalán nem dereferálja a linkeket. A szótárak és tömbök által tartott FParent visszamutató ugyanez a történet a másik irányban; a szülő már birtokolja a gyereket, így a pointer felfelé követése csak újra meglátogatna egy csomópontot, amin a bejárás már átment. Mindkettőt békén hagyjuk, és a forrásban lévő komment ezt egy sorban ki is mondja: a linkek és a szülőpointerek referenciák, nem birtoklási élek
Hogyan működik a háromfázisú lebontás?
Az első fázis egy szélességi gyűjtés. A rutin feltölti a munkalistát az IndirectObjects minden bejegyzésével, majd minden csomópontnál hozzáfűzi a csomópont birtokló éleinek célpontjait, kihagyva mindent, amit már látott. A látott-halmaz egy nyílt címzésű tömb nyers pointerekből, a pointerértéken HPDFFastCacheHashInt64-gyel hashelve, lineáris próbával és duplázó GrowSeen-nel, amikor eléri a félig telítettséget. Ebben a struktúrában semmi nem allokál csomópontonként, ami számít, amikor egy dokumentum néhány százezer objektumot hordoz. A stream-hasznos terhek egy külön Streams listába kerülnek, mert ők TStream leszármazottak, nem THPDFObject csomópontok, és a saját menetükben szabadulnak fel
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; // már összegyűjtve
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;
// Első fázis: feltöltés a regisztrációból, majd csak birtokló élek követése
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;
A második fázis az a rész, ami biztonságossá teszi a destruktorok futtatását: minden birtokló él nil-re állítódik, mielőtt bármelyik destruktor végrehajtódna. Egy wrapper megkapja a MarkAsFreed-et, ami törli az FInternalObject-et, és beállítja a flaget, amit a destruktora először ellenőriz. Egy stream-objektumnak a Dictionary és a Stream mezője nil-t kap. Minden szótárbejegyzésnél az Item^.Value törlődik, minden tömbelem pedig nil-re íródik. Ez a menet után a gráfban nem maradt él, így amikor a harmadik fázis Free-t hív a Nodes minden csomópontján, majd a Streams minden hasznos terhén, minden destruktor úgy találja, hogy nincs mibe rekurzívan belemennie, és csak önmagát semmisíti meg
// Második fázis: minden birtokló él leválasztása, mielőtt bármit felszabadítanánk
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;
// Harmadik fázis: minden egyedi csomópont és hasznos teher pontosan egyszer szabadul fel
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);
Nézd meg, mit hoz a szétválasztás. Egy szótár, amit két stream-objektum oszt meg, egyszer gyűjtődik össze, mindkettőről leválik, és egyszer szabadul fel. Egy ciklus, amiben egy tömb a saját szülőszótárát listázza, lezárul, mert a látott-halmaz visszautasítja a második látogatást. Egy wrapper és a törzse, mindkettő gyökérként regisztrálva, két különböző pointer a halmazban, így mindkettő felszabadul, a wrapper destruktora pedig nem próbálja többé felszabadítani a törzset, mert a MarkAsFreed már elvette azt az élt. Egyetlen TMemoryStream, amit két stream-objektum hasznos tereként állítanak be, pontosan egyszer ül a Streams-ben. Ezek közül egyik eset sem igényel külön kezelést, ami annak a jele, hogy a modell helyes
Hogyan különböztetsz meg egy szivárgást az allokátor megtartásától?
Úgy, hogy megnézed, a memóriakezelő élő allokációinak száma mozog-e a terheléssel, nem csak a lefoglalt lábnyoma. A Delphi memóriakezelő életben tartja a felszabadított nagy blokkokat újrafelhasználásra, így egy folyamat, ami 400 MiB-on marad egy dokumentum lezárása után, nem feltétlenül szivárgott; az szivárgott, aminek az élő blokkszáma futásonkénti oldalszámmal nő. A próba, ami ezt a javítást elindította, szándékosan kicsi volt: egy THotPDF író, ami egyetlen oldalt állít elő, majd három olvasó, ami betölti. Miután mind a négy felszabadult, a heap-jelentés pontosan négy élő 512 KiB-os allokációt mutatott, egyet példányonként, ami az a tartalom-stream hasznos teher, amit mindegyik birtokolt és soha nem engedett el. A felméretezés ugyanezt a mintát félreérthetetlenné tette. A párhuzamos render-pipeline kétszeri lefuttatása a nagy blokkokra allokált értéket 384 MiB-ról 640 MiB-ra mozgatta, ami az oldalszámmal arányos növekedés, és amit az allokátor megtartása nem magyaráz. Az újraírás után az egylapos diagnosztika nulla allokált nagy bájtot és nulla lefoglaltat jelentett, miután a példányok eltűntek. Ha ugyanezt a fajta növekedést vadászod a saját folyamatodban, az objektum-függőségi gráf a megtartott bájtokkal megmondja, mely objektumok tartják a memóriát, amíg a dokumentum nyitva van; ez a bejegyzés arról szól, hogyan szabadulnak fel, amikor lezárul
A memóriaküszöbök törékeny regressziós teszteket adnak, ezért a szállított tesztek destruktorhívásokat számolnak. Egy fixture kézzel építi fel a patológiás gráfot: egy megosztott szótár két stream alatt, egy tömb, ami a megosztott szótárat és a saját gyökerét is tartalmazza, egy hasznos teher, ami mindkét streamhez hozzá van rendelve, a gyökér kétszer regisztrálva, és egy wrapper, aminek a törzse külön regisztrált, majd felszabadítja a dokumentumot, és egy megsemmisülést állít minden egyedi objektumra: egy hasznos teher, két stream, két szótár, egy tömb, egy wrapper, egy szám. A régi kód alatt mindhárom élettartam-teszt nulla megsemmisülést jelentett, ami a lehető legközvetlenebb kimondása annak, mit jelent az, hogy „hagyd a folyamat kilépésére”
Mit kell megtörténnie, mielőtt a gráf lejön?
Minden háttérmunkának, ami objektumokat kölcsönöz a gráfból, előbb le kell állnia, és minden cache-t, ami azokból az objektumokból fordított display listákat vagy bitmapokat tart, el kell dobni, különben egy munkaszál vagy egy cache-elt referencia felszabadított memóriát olvas. A CloseIndirectObjects ezért a CancelLoadedPagePrefetch-csel nyit, majd érvényteleníti a renderelt oldal cache-t, mielőtt a regisztrációhoz hozzányúlna. A LoadFromFile és a LoadFromStream újratöltési útvonala és a komponens destruktora is ezen keresztül vezet, így ugyanaz a sorrend érvényes, akár dokumentumot cserélsz, akár a példánytól szabadulsz meg; az egy THotPDF dokumentumok közötti újrahasználatának szabályai erre a garanciára támaszkodnak. Két részlet abból a preambulumból csak a tesztek futtatásából került elő. Először, a destruktor már eltakarította a render- és display list cache-ek mögötti frequency sketch-eket, mire lezárja a gráfot, ezért az érvénytelenítés arra van őrizve, hogy azok a mezők nem nil-ek, nem pedig feltétel nélkül hívódik. Másodszor, az InvalidateRenderedPageCache az a rutin, ami elsüti az OnLoadedDocumentModified eseményt -1 oldalindexszel, és egy hívó, aki újratölt egy fájlt, nem kaphat szerkesztési értesítést a régi dokumentum belső lebontásáról. A handlert elmentjük, a hívás körül nil-re állítjuk, és egy finally-ben visszaállítjuk, az újratöltési regresszió pedig nulla értesítésszámot állít a második LoadFromStream után. Egy memóriajavítás, ami csendben megváltoztat egy eseményszerződést, egy jobb PR-ral rendelkező regresszió, ezért saját állítást kap. Ha a párhuzamos render-pipeline-t egy dokumentumon futtatod, majd újratöltöd, a megszakítás lépése az, ami megóvja a worker poolt attól, hogy versenyezzen a lebontással
A minta újrahasználata a saját Delphi-kódodban
A technika nem PDF-specifikus. Bármely Delphi objektummodell, ahol a destruktorok következetlenül birtokolják a gyerekeket, ahol ugyanaz a gyerek több szülőtől is elérhető, vagy ahol vissza- és előremutatók együtt élnek, összeomlik vagy szivárog egy naiv objektumonkénti Free alatt. A javítás mindig ugyanolyan alakú: döntsd el, mely pointermezők birtoklók és melyek referenciák, gyűjtsd össze a birtokló élek lezárását egy olyan pointerhalmazon keresztül, ami tolerálja az ismételt látogatásokat, vágd le az összes élt, majd semmisítsd meg a lapos listát. A levágás lépése az, amit az emberek kihagynak, és ez az, ami a meglévő destruktorokat biztonságossá teszi az újrahasználatra ahelyett, hogy a modell minden osztályát újra kellene írni. A határokat viszont érdemes egyenesen kimondani. A pointerhalmaz az objektum címét használja azonosságként, így egy már felszabadított objektumot, aminek a címét egy friss allokáció újrahasználta, megkülönböztethetetlen lenne; a sorrend garantálja, hogy a gyűjtés közben egy destruktor sem fut le, ami kizárja ezt. A bejárás csak azt a négy élfajtát látja, amiről tud, így egy új osztály, ami olyan mezőn keresztül birtokol gyereket, amit a bejárás nem vizsgál, addig szivárogtatni fogja azt a gyereket, amíg a bejárást meg nem tanítják rá. És mivel a linkek a regisztráción keresztül oldódnak fel, nem pedig követődnek, egy olyan objektum, amire csak egy link hivatkozik, és soha nem regisztrálták, egyáltalán nem érhető el ezzel a lebontással; a HotPDF-ben a parser garantálja a regisztrációt, de egy kézzel épített gráfnak ugyanezt a szabályt kell tiszteletben tartania
Mindez a komponensen belül van, így egy alkalmazás számára a látható hatás egyszerűen az, hogy egy dokumentum lezárása vagy újratöltése visszaadja a memóriáját, API-változás nélkül. A HotPDF egy natív VCL PDF-könyvtár Delphihez és C++Builderhez, teljes forráskóddal; az API-referencia és egy próbaverzió a HotPDF Delphi PDF component oldalán található