HotPDF Delphi Component frigjør hvert PDF-objekt et dokument eier når dokumentet lukkes eller lastes på nytt: THotPDF.CloseIndirectObjects går gjennom objektregisteret, samler hver eierkant inn i et pekersett, kobler alle disse kantene fra, og frigjør først deretter hver unike node og hver strømlast nøyaktig én gang. Denne trefaserte rekkefølgen er det som lar delte barn, eierskapssykler, dupliserte registreringer og wrapper/kropp-aliaser komme ned uten dobbel frigjøring og uten å etterlate noe. Før v2.752.4 gjorde den samme rutinen noe mye enklere og mye verre: den frigjorde de late filstrømkildene, kalte Clear på IndirectObjects-listen, frigjorde listebeholderen og overlot hvert faktiske PDF-objekt til prosessavslutningen. Kommentaren i den koden var ærlig om det også. Å frigjøre objektene enkeltvis forårsaket access violations, så «den trygge tilnærmingen» var å ikke frigjøre dem i det hele tatt. Dette innlegget handler om hvorfor den enkeltvise tilnærmingen virkelig krasjet, og hvordan en nedrivning som fungerer, ser ut i et språk med manuell minnehåndtering
Hvorfor kan du ikke bare frigjøre hvert registrert objekt?
Fordi destruktørene til objektklassene er uenige om hvem som eier hva, og registeret inneholder oppføringer på flere nivåer av den samme eierskapskjeden. Å gå gjennom listen og kalle Free på hver oppføring frigjør derfor noe minne to ganger og noe minne aldri, avhengig av hvilke klasser som tilfeldigvis ligger ved siden av hverandre
Tre asymmetrier i HPDFObjs.pas og HPDFDoc.pas skaper problemet. THPDFDictionaryObject.Destroy går gjennom Items og frigjør en verdi bare når IsIndirect er False, ut fra antakelsen om at indirekte barn tilhører registeret og frigjøres der. THPDFArrayObject.Destroy gjør ingen slik forskjell og frigjør hvert element den holder. Og THPDFIndirectObject.Destroy, wrapperen som bærer et objektnummer, frigjør kroppen sin InternalObject. Tenk deg nå et register som holder en indirekte ordbok, en array som lister den samme ordboken i ett av sporene sine, og en wrapper hvis kropp også er registrert som en separat rot, som er nøyaktig det parseren produserer på reelle filer. Frigjør arrayen først, og ordboken er borte før registeret når den. Frigjør wrapperen og kroppen, i hvilken som helst rekkefølge, og det andre kallet kjører en destruktør på en dinglende peker. Frigjør ordboken alene, og ethvert indirekte barn den hoppet over, forblir allokert for alltid. Ingen rekkefølge i registeret fikser dette, fordi registeret er en flat liste og eierskapsrelasjonen er en graf, og å resonnere om grafen er den eneste veien ut
Hva regnes som en eierkant i en PDF-objektgraf?
En eierkant er en peker der kilden er ansvarlig for å ødelegge målet; en referanse er alt annet, og nedrivningen må følge den første typen og ignorere den andre. I HotPDF gir det nøyaktig fire kanttyper: Items i en THPDFDictionaryObject, Items i en THPDFArrayObject, InternalObject bak en THPDFIndirectObject, og begge halvdelene av en THPDFStreamObject, dens Dictionary og dens Stream-last. Referansetypene betyr like mye, fordi å følge én av dem gjør en grafgjennomgang til en uendelig løkke eller en use-after-free. En THPDFLink holder et objektnummer og en generasjon, som er hvordan ISO 32000-1 §7.3.10 definerer en indirekte referanse: et navn på et objekt som lever et annet sted, ikke objektet selv. Å løse opp det nummeret gjennom registeret gir en node som en annen kant allerede eier, så CloseIndirectObjects avrefererer aldri lenker i det hele tatt. FParent-bakpekeren som ordbøker og arrayer holder, er samme historie i motsatt retning; forelderen eier allerede barnet, så å følge pekeren oppover ville bare gjenbesøke en node gjennomgangen har vært innom. Begge får være i fred, og kommentaren i kilden sier det i én linje: lenker og foreldrepekere er referanser, ikke eierkanter
Hvordan fungerer nedrivningen i tre faser?
Fase én er en bredde-først-innsamling. Rutinen seeder en arbeidsliste med hver oppføring i IndirectObjects, og legger deretter for hver node til målene til den nodens eierkanter, mens den hopper over alt som allerede er sett. Sett-strukturen er en åpen-adressert array av rå pekere, hashet med HPDFFastCacheHashInt64 over pekerverdien, med lineær probing og en doblende GrowSeen når den er halvfull. Ingenting i den strukturen allokerer per node, noe som betyr noe når et dokument bærer noen hundre tusen objekter. Strømlaster går i en egen Streams-liste fordi de er TStream-etterkommere og ikke THPDFObject-noder, og frigjøres i sin egen runde
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; // allerede samlet inn
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;
// Fase én: seed med registeret, følg deretter bare eierkanter
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;
Fase to er den delen som gjør destruktørene trygge å kjøre: hver eierkant settes til nil før noen destruktør utføres. En wrapper får MarkAsFreed, som tømmer FInternalObject og setter flagget destruktøren sjekker først. Et strømobjekt får Dictionary og Stream satt til nil. Hvert ordbokelement får Item^.Value tømt, og hvert array-spor overskrives med nil. Etter denne runden har grafen ingen kanter igjen, så når fase tre kaller Free på hver node i Nodes og deretter hver last i Streams, finner hver destruktør ingenting å rekursere inn i og ødelegger bare seg selv
// Fase to: koble fra hver eierkant før noe frigjøres
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;
// Fase tre: hver unike node og last frigjøres nøyaktig én gang
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);
Se på hva oppdelingen gir. En ordbok som deles av to strømobjekter, samles inn én gang, kobles fra begge og frigjøres én gang. En sykel der en array lister sin egen foreldreordbok, terminerer fordi sett-strukturen nekter det andre besøket. En wrapper og kroppen dens som begge er registrert som røtter, er to forskjellige pekere i settet, så begge frigjøres, og wrapperens destruktør prøver ikke lenger å frigjøre kroppen fordi MarkAsFreed allerede tok den kanten bort. En enkelt TMemoryStream som er tildelt som lasten til to strømobjekter, ligger i Streams nøyaktig én gang. Ingen av disse tilfellene trenger spesialhåndtering, og det er tegnet på at modellen er riktig
Hvordan skiller du en lekkasje fra allokatorretensjon?
Ved å sjekke om minnehåndtererens antall levende allokeringer beveger seg med arbeidsmengden, ikke bare det reserverte fotavtrykket. En Delphi-minnehåndterer beholder frigjorte store blokker for gjenbruk, så en prosess som står på 400 MiB etter å ha lukket et dokument, har ikke nødvendigvis lekket; en prosess der antallet levende blokker klatrer med én per side per kjøring, har det. Proben som drev denne fiksen, var med vilje liten: én THotPDF-skriver som produserer en enkelt side, og deretter tre lesere som laster den. Etter at alle fire var frigjort, viste heap-rapporten nøyaktig fire levende 512 KiB-allokeringer, én per instans, som er den innholdsstrømlasten hver av dem eide og aldri slapp. Å skalere opp gjorde samme mønster umiskjennelig. Å kjøre den parallelle gjengivelsespipelinen to ganger flyttet tallet for allokerte store blokker fra 384 MiB til 640 MiB, en økning proporsjonal med sidetallet som allokatorretensjon ikke kan forklare. Etter omskrivningen rapporterte diagnosen for én side null allokerte store byte og null reservert når instansene var borte. Hvis du jakter på samme type vekst i din egen prosess, forteller objektavhengighetsgrafen med beholdte byte deg hvilke objekter som holder minnet mens dokumentet er åpent; dette innlegget handler om hvordan de frigjøres når det lukkes
Minneterskler gir skjøre regresjonstester, så de leverte testene teller destruktørkall i stedet. En fikstur bygger den patologiske grafen for hånd, med en delt ordbok under to strømmer, en array som inneholder både den delte ordboken og sin egen rot, én last tildelt begge strømmene, roten registrert to ganger, og en wrapper hvis kropp er separat registrert, og frigjør deretter dokumentet og påstår én ødeleggelse per unike objekt: én last, to strømmer, to ordbøker, én array, én wrapper, ett tall. Under den gamle koden rapporterte alle tre levetidstestene null ødeleggelser, som er den mest direkte mulige beskrivelsen av hva «la det ligge til prosessavslutning» betyr
Hva må skje før grafen rives ned?
Alt bakgrunnsarbeid som låner objekter fra grafen, må stoppe først, og enhver cache som holder visningslister eller bitmaps kompilert fra de objektene, må slippes, ellers leser en arbeidstråd eller en cachet referanse frigjort minne. CloseIndirectObjects åpner derfor med CancelLoadedPagePrefetch og ugyldiggjør deretter cachen for gjengitte sider før den rører registeret. Omlastingsstien i LoadFromFile og LoadFromStream og komponentdestruktøren går begge gjennom den, så samme rekkefølge gjelder enten du bytter ut et dokument eller kvitter deg med instansen; reglene for å gjenbruke én THotPDF på tvers av dokumenter lener seg på den garantien. To detaljer i den innledningen dukket bare opp ved å kjøre testene. For det første har destruktøren allerede kvittet seg med frekvensskissene bak gjengivelses- og visningslistecachene når den lukker grafen, så ugyldiggjøringen er vaktet på at de feltene er ikke-nil i stedet for å kalles ubetinget. For det andre er InvalidateRenderedPageCache rutinen som fyrer OnLoadedDocumentModified med en sideindeks på -1, og en kaller som laster en fil på nytt, bør ikke få et redigeringsvarsel for det gamle dokumentets interne nedrivning. Handtereren lagres, settes til nil rundt kallet og gjenopprettes i en finally, og omlastingsregresjonen påstår et varselantall på null etter den andre LoadFromStream. En minnefiks som stille endrer en hendelseskontrakt, er en regresjon med bedre PR, så den får sin egen påstand. Hvis du kjører den parallelle gjengivelsespipelinen mot et dokument og deretter laster det på nytt, er avbruddstrinnet det som hindrer arbeiderpoolen i å kappes med nedrivningen
Å gjenbruke mønsteret i din egen Delphi-kode
Teknikken er ikke spesifikk for PDF. Enhver Delphi-objektmodell der destruktører eier barn inkonsekvent, der det samme barnet kan nås fra flere foreldre, eller der bakpekere og frempekere eksisterer side om side, vil krasje eller lekke under en naiv Free per objekt. Fiksen har alltid samme form: bestem hvilke pekerfelt som er eierkanter og hvilke som er referanser, samle lukningen av eierkanter gjennom et pekersett som tåler gjenbesøk, kutt hver kant, og ødelegg deretter den flate listen. Kuttetrinnet er det folk hopper over, og det er det som gjør de eksisterende destruktørene trygge å gjenbruke i stedet for å tvinge frem en omskrivning av hver klasse i modellen. Grensene er likevel verdt å si rett ut. Pekersettet bruker objektadressen som identitet, så et objekt som allerede er frigjort og hvis adresse er gjenbrukt av en ny allokering, ville være umulig å skille; rekkefølgen garanterer at ingen destruktør kjører under innsamlingen, og det er det som utelukker det. Gjennomgangen ser bare de fire kanttypene den kjenner, så en ny klasse som eier et barn gjennom et felt gjennomgangen ikke inspiserer, vil lekke det barnet til gjennomgangen læres opp. Og fordi lenker løses opp gjennom registeret i stedet for å følges, er et objekt som bare refereres av en lenke og som aldri ble registrert, ikke nåbart for denne nedrivningen i det hele tatt; i HotPDF garanterer parseren registrering, men en håndbygget graf må respektere samme regel
Alt dette ligger inne i komponenten, så den synlige effekten for en applikasjon er rett og slett at lukking eller omlasting av et dokument gir minnet tilbake, uten API-endring. HotPDF er et native VCL PDF-bibliotek for Delphi og C++Builder med full kildekode; API-referansen og en prøveversjon ligger på siden for HotPDF Delphi PDF-komponenten