Műszaki cikk

PDF-objektumgráf egyszeri felszabadítása Delphiben

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ért crashelt minden HotPDF regisztrációs bejegyzés felszabadítása: a THPDFDictionaryObject.Destroy kihagyja az indirekt gyerekeket, míg a THPDFArrayObject.Destroy mindent felszabadít, amit tart, a THPDFIndirectObject.Destroy pedig felszabadítja az InternalObject törzsét, így egy flat IndirectObjects listában lévő wrapperrel, tömbbel és megosztott szótárral néhány memória kétszer hal meg, néhány pedig soha
A destruktorok nem értenek egyet abban, ki mit birtokol, a regisztráció pedig ugyanazon birtoklási lánc több szintjén tart bejegyzéseket, így egy lapos lista semmilyen sorrendje nem tud egy naiv objektumonkénti Free-ből helyes lebontást csinálni

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

Birtokló élek és referenciák a HotPDF objektumgráfjában: a DictionaryObject Items, az ArrayObject Items, az IndirectObject InternalObject és a StreamObject mindkét fele követve és leválasztva van, míg a THPDFLink objektumszáma és az FParent visszamutató máshol élő objektumokra mutató nevek, így a CloseIndirectObjects soha nem dereferálja őket
Egy birtokló él olyan pointer, aminek a célját a forrásnak meg kell semmisítenie; egy referencia követése helyette végtelen ciklussá vagy use-after-free-vé tenné a szélességi bejárást, ezért a linkeket és a szülőpointereket békén hagyjuk

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

A háromfázisú CloseIndirectObjects lebontás a HotPDF-ben: a szélességi gyűjtés az IndirectObjects-ből tölti fel a munkalistát, és csak birtokló éleket követ egy HPDFFastCacheHashInt64-gyel hashelt nyílt címzésű látott-halmazon át, a második fázis leválaszt minden élt MarkAsFreed-del és nil értékadással, a harmadik fázis pedig pontosan egyszer szabadítja fel az egyes csomópontokat és stream-hasznos terheket
Az élek levágása azelőtt, hogy bármelyik destruktor lefutna, az, ami biztonságossá teszi a meglévő destruktorok újrahasználatát: mindegyik úgy találja, hogy nincs mibe rekurzívan belemennie, így a megosztott gyerekek, a ciklusok és a wrapper–törzs álnevek mind lejönnek dupla free nélkül
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ó