HotPDF Delphi Component eliberează fiecare obiect PDF pe care îl deține un document când documentul acela se închide sau se reîncarcă: THotPDF.CloseIndirectObjects parcurge registrul de obiecte, colectează fiecare muchie de proprietate într-un set de pointeri, desprinde toate muchiile acelea și abia apoi eliberează exact o dată fiecare nod unic și fiecare payload de stream. Ordinea asta în trei faze este ce lasă copiii partajați, ciclurile de proprietate, înregistrările duplicate și aliasurile wrapper/corp să cadă toate fără o dublă eliberare și fără să rămână ceva în urmă. Înainte de v2.752.4, aceeași rutină făcea ceva mult mai simplu și mult mai rău: elibera sursele leneșe de stream de fișier, apela Clear pe lista IndirectObjects, elibera containerul listei și lăsa fiecare obiect PDF real în seama ieșirii din proces. Comentariul din codul acela era onest în privința asta: eliberarea obiectelor individual cauza încălcări de acces, așa că „abordarea sigură” era să nu le eliberezi deloc. Articolul de față este despre de ce abordarea individuală chiar crăpa și cum arată o eliberare care funcționează într-un limbaj cu gestionare manuală a memoriei
De ce nu poți pur și simplu să dai Free fiecărui obiect înregistrat?
Pentru că destructorii claselor de obiecte nu cad de acord asupra cui deține ce, iar registrul conține intrări de la mai multe niveluri ale aceleiași lanțuri de proprietate. Parcurgerea listei și apelul Free pe fiecare intrare eliberează deci o parte din memorie de două ori și o altă parte niciodată, în funcție de ce clase se nimerește să stea una lângă alta
Trei asimetrii din HPDFObjs.pas și HPDFDoc.pas creează problema. THPDFDictionaryObject.Destroy își parcurge Items și eliberează o valoare doar când IsIndirect este False, pe presupunerea că copiii indirecți aparțin registrului și vor fi eliberați acolo. THPDFArrayObject.Destroy nu face nicio distincție de acest fel și eliberează fiecare element pe care îl ține. Iar THPDFIndirectObject.Destroy, wrapper-ul care cară un număr de obiect, își eliberează corpul InternalObject. Acum luați în considerare un registru care ține un dicționar indirect, un tablou care listează același dicționar într-unul din sloturile sale și un wrapper al cărui corp este înregistrat și el ca rădăcină separată, exact ce produce parserul pe fișiere reale. Eliberați tabloul primul și dicționarul dispare înainte ca registrul să ajungă la el. Eliberați wrapper-ul și corpul, în orice ordine, și al doilea apel rulează un destructor pe un pointer atârnat. Eliberați dicționarul singur și orice copil indirect pe care l-a sărit rămâne alocat pentru totdeauna. Nicio ordonare a registrului nu repară asta, pentru că registrul este o listă plată, iar relația de proprietate este un graf, iar raționamentul pe graf este singura ieșire
Ce contează drept muchie de proprietate într-un graf de obiecte PDF?
O muchie de proprietate este un pointer a cărui țintă este responsabilitatea sursei să o distrugă; o referință este orice altceva, iar eliberarea trebuie să urmeze primul tip și să îl ignore pe al doilea. În HotPDF asta dă exact patru tipuri de muchii: Items ale unui THPDFDictionaryObject, Items ale unui THPDFArrayObject, InternalObject din spatele unui THPDFIndirectObject și ambele jumătăți ale unui THPDFStreamObject, Dictionary al lui și payload-ul Stream. Tipurile de referință contează la fel de mult, pentru că urmarea uneia transformă o parcurgere de graf într-o buclă infinită sau într-o utilizare după eliberare. Un THPDFLink ține un număr de obiect și o generație, care este felul în care ISO 32000-1 §7.3.10 definește o referință indirectă: un nume pentru un obiect care trăiește în altă parte, nu obiectul însuși. Rezolvarea acelui număr prin registru dă un nod pe care îl deține deja o altă muchie, așa că CloseIndirectObjects nu dereferențiază niciodată link-uri. Pointerul invers FParent pe care îl țin dicționarele și tablourile este aceeași poveste în cealaltă direcție; părintele deține deja copilul, așa că urmarea pointerului în sus ar revizita doar un nod prin care parcurgerea a trecut deja. Ambele sunt lăsate în pace, iar comentariul din sursă spune asta într-o singură linie: link-urile și pointerii de părinte sunt referințe, nu muchii de proprietate
Cum funcționează eliberarea în trei faze?
Faza întâi este o colectare în lățime. Rutina înființează o listă de lucru cu fiecare intrare din IndirectObjects, apoi pentru fiecare nod adaugă țintele muchiilor de proprietate ale acelui nod, sărind peste ce a fost deja văzut. Setul de noduri văzute este un tablou cu adresare deschisă de pointeri bruți, hash-uiți cu HPDFFastCacheHashInt64 peste valoarea pointerului, cu sondare liniară și un GrowSeen care dublează când ajunge pe jumătate plin. Nimic din structura aceea nu alocă per nod, ceea ce contează când un document cară câteva sute de mii de obiecte. Payload-urile de stream merg într-o listă separată Streams, pentru că sunt descendenți TStream, nu noduri THPDFObject, și sunt eliberate într-o trecere proprie
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; // deja colectat
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 întâi: înființează din registru, apoi urmează doar muchiile de proprietate
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 a doua este partea care face destructorii siguri de rulat: fiecare muchie de proprietate este pusă pe nil înainte de a executa vreun destructor. Un wrapper primește MarkAsFreed, care curăță FInternalObject și setează flag-ul pe care destructorul lui îl verifică mai întâi. Un obiect de stream are Dictionary și Stream puse pe nil. Fiecare element de dicționar are Item^.Value curățat, iar fiecare slot de tablou este suprascris cu nil. După trecerea asta, grafului nu-i mai rămâne nicio muchie, așa că atunci când faza a treia apelează Free pe fiecare nod din Nodes și apoi pe fiecare payload din Streams, fiecare destructor găsește nimic în care să recurseze și se distruge doar pe el însuși
// Faza a doua: desprinde fiecare muchie de proprietate înainte de a elibera ceva
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 a treia: fiecare nod și payload unic este eliberat exact o dată
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);
Uitați-vă ce cumpără împărțirea asta. Un dicționar partajat de două obiecte de stream este colectat o dată, desprins de la amândouă și eliberat o dată. Un ciclu în care un tablou își listează propriul dicționar părinte se termină pentru că setul de noduri văzute refuză a doua vizită. Un wrapper și corpul lui, ambele înregistrate ca rădăcini, sunt doi pointeri distincți în set, așa că ambele sunt eliberate, iar destructorul wrapper-ului nu mai încearcă să elibereze corpul pentru că MarkAsFreed a luat deja muchia aceea. Un singur TMemoryStream atribuit ca payload la două obiecte de stream stă în Streams exact o dată. Niciunul dintre cazurile acestea nu are nevoie de tratament special, ceea ce este semnul că modelul este corect
Cum distingi o scurgere de reținerea alocatorului?
Verificând dacă numărul de alocări vii al managerului de memorie se mișcă odată cu volumul de lucru, nu doar amprenta lui rezervată. Un manager de memorie Delphi ține blocurile mari eliberate deoparte pentru refolosire, așa că un proces care rămâne la 400 MiB după ce închide un document nu a scurs neapărat memoria; un proces al cărui număr de blocuri vii crește cu unul pe pagină la fiecare rulare, da. Sonda care a condus reparația asta a fost deliberat de mică: un writer THotPDF care produce o singură pagină, apoi trei cititori care o încarcă. După ce toate patru au fost eliberate, raportul de heap arăta exact patru alocări vii de 512 KiB, una pe instanță, adică payload-ul de stream de conținut pe care fiecare îl deținea și nu îl elibera niciodată. Scalarea a făcut același tipar imposibil de confundat. Rularea de două ori a pipeline-ului paralel de randare a mișcat cifra alocată pentru blocuri mari de la 384 MiB la 640 MiB, o creștere proporțională cu numărul de pagini pe care reținerea alocatorului nu o poate explica. După rescriere, diagnosticul pe o singură pagină raporta zero octeți mari alocați și zero rezervați odată ce instanțele dispăreau. Dacă vânați același tip de creștere în procesul dumneavoastră, graful de dependențe de obiecte cu octeții reținuți vă spune care obiecte țin memoria cât timp documentul este deschis; articolul de față este despre cum sunt ele eliberate când se închide
Pragurile de memorie fac teste de regresie fragile, așa că testele livrate numără apeluri de destructor în loc. Un fixture construiește manual graful patologic, cu un dicționar partajat sub două stream-uri, un tablou care conține și dicționarul partajat, și propria rădăcină, un singur payload atribuit la ambele stream-uri, rădăcina înregistrată de două ori și un wrapper al cărui corp este înregistrat separat, apoi eliberează documentul și asertează o singură distrugere per obiect unic: un payload, două stream-uri, două dicționare, un tablou, un wrapper, un număr. Sub codul vechi, toate cele trei teste de durată de viață raportau zero distrugeri, care este afirmația cea mai directă cu putință despre ce înseamnă „lasă-l pentru ieșirea din proces”
Ce trebuie să se întâmple înainte ca graful să cadă?
Orice muncă de fundal care împrumută obiecte din graf trebuie să se oprească mai întâi, iar orice cache care ține liste de afișare sau bitmap-uri compilate din obiectele acelea trebuie aruncat, altfel un thread de lucru sau o referință din cache citește memorie eliberată. CloseIndirectObjects deschide deci cu CancelLoadedPagePrefetch, apoi invalidează cache-ul de pagini randate înainte de a atinge registrul. Calea de reîncărcare din LoadFromFile și LoadFromStream și destructorul componentei trec amândouă prin el, așa că aceeași ordonare se aplică fie că înlocuiți un document, fie că aruncați instanța; regulile pentru refolosirea unui THotPDF între documente se sprijină pe garanția aceea. Două detalii din preambulul acela au ieșit la iveală abia la rularea testelor. Întâi, destructorul a aruncat deja schițele de frecvență din spatele cache-urilor de randare și de liste de afișare până când închide graful, așa că invalidarea este păzită de faptul că acele câmpuri sunt nenule, în loc să fie apelată necondiționat. Al doilea, InvalidateRenderedPageCache este rutina care declanșează OnLoadedDocumentModified cu un index de pagină de -1, iar un apelant care reîncarcă un fișier nu ar trebui să primească o notificare de editare pentru eliberarea internă a documentului vechi. Handler-ul este salvat, pus pe nil în jurul apelului și restaurat într-un finally, iar regresia de reîncărcare asertează un număr de notificări de zero după al doilea LoadFromStream. O reparație de memorie care schimbă în tăcere un contract de evenimente este o regresie cu PR mai bun, așa că primește propria aserțiune. Dacă rulați pipeline-ul paralel de randare pe un document și apoi îl reîncărcați, pasul de anulare este ce ține pool-ul de workeri să nu se întreacă cu eliberarea
Refolosirea tiparului în propriul cod Delphi
Tehnica nu este specifică PDF-ului. Orice model de obiecte Delphi în care destructorii dețin copiii inconsecvent, în care același copil poate fi atins din mai mulți părinți sau în care pointerii inversi și cei direcți coexistă va crăpa sau va scăpa memorie sub un Free naiv per obiect. Reparația are mereu aceeași formă: decideți care câmpuri de pointer sunt de proprietate și care sunt referințe, colectați închiderea muchiilor de proprietate printr-un set de pointeri care tolerează revizite, tăiați fiecare muchie, apoi distrugeți lista plată. Pasul de tăiere este cel pe care lumea îl sare, și este cel care face destructorii existenți siguri de refolosit în loc să forțeze rescrierea fiecărei clase din model. Limitele merită totuși spuse fără ocolișuri. Setul de pointeri folosește adresa obiectului ca identitate, așa că un obiect care a fost deja eliberat și a cărui adresă a fost refolosită de o alocare nouă ar fi imposibil de distins; ordonarea garantează că niciun destructor nu rulează în timpul colectării, și exact asta exclude cazul. Parcurgerea vede doar cele patru tipuri de muchii pe care le cunoaște, așa că o clasă nouă care deține un copil printr-un câmp pe care parcurgerea nu îl inspectează va scăpa acel copil până când parcurgerea este învățată despre el. Și pentru că link-urile sunt rezolvate prin registru, nu urmărite, un obiect referit doar de un link și care nu a fost niciodată înregistrat nu este accesibil deloc pentru eliberarea asta; în HotPDF parserul garantează înregistrarea, dar un graf construit manual trebuie să respecte aceeași regulă
Toate acestea sunt în interiorul componentei, așa că efectul vizibil pentru o aplicație este pur și simplu că închiderea sau reîncărcarea unui document îi returnează memoria, fără nicio schimbare de API. HotPDF este o librărie PDF VCL nativă pentru Delphi și C++Builder, cu sursă completă; referința de API și un build de probă sunt pe pagina componentei HotPDF pentru Delphi